7 Commits

Author SHA1 Message Date
HRiggs cf0693f319 versioning: 0.0.9
Build Release EXE / build-windows-exe (release) Successful in 51s
2026-06-09 11:24:33 -04:00
HRiggs 8fac3d2bae feat: chat 2026-06-09 11:24:15 -04:00
HRiggs 454bb57484 feat: deepseek
Build Release EXE / build-windows-exe (release) Successful in 1m2s
2026-06-08 23:41:46 -04:00
HRiggs 00cf6f8747 feat: infrance
Build Release EXE / build-windows-exe (release) Successful in 58s
2026-06-08 20:28:06 -04:00
HRiggs 6bd1e81a51 versioning: 0.0.6, ux: move buttons, feat: add cloud providers, feat: increese tool call limit
Build Release EXE / build-windows-exe (release) Successful in 51s
2026-05-08 14:48:51 -04:00
HRiggs a5a718b3e4 versioning: 0.0.5
Build Release EXE / build-windows-exe (release) Successful in 50s
2026-05-08 00:37:31 -04:00
HRiggs 7b65b62f58 ux: plan mode moved 2026-05-08 00:37:09 -04:00
30 changed files with 7672 additions and 394 deletions
+14 -1
View File
@@ -1,12 +1,25 @@
MODEL_PROVIDER=ollama
OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=qwen3.5:9b OLLAMA_MODEL=qwen3.5:9b
OLLAMA_NUM_CTX=64512 OLLAMA_NUM_CTX=64512
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_MODEL=gpt-5.4-mini
OPENAI_API_KEY=
DEEPSEEK_BASE_URL=https://api.deepseek.com
DEEPSEEK_MODEL=deepseek-v4-flash
DEEPSEEK_API_KEY=
MODEL_REASONING_EFFORT=medium
CODEX_COMMAND=codex
CODEX_MODEL=gpt-5.4
UEX_BASE_URL=https://api.uexcorp.space/2.0 UEX_BASE_URL=https://api.uexcorp.space/2.0
SCMDB_BASE_URL=https://scmdb.net SCMDB_BASE_URL=https://scmdb.net
CORNERSTONE_BASE_URL=https://finder.cstone.space CORNERSTONE_BASE_URL=https://finder.cstone.space
SCWIKI_BASE_URL=https://starcitizen.tools
SCWIKI_API_BASE_URL=https://api.star-citizen.wiki
UEX_SECRET_KEY= UEX_SECRET_KEY=
UEX_BEARER_TOKEN= UEX_BEARER_TOKEN=
UEX_NEGOTIATION_CLOSE_ENDPOINT=marketplace_negotiations_close
TRADERAI_USER_NAME= TRADERAI_USER_NAME=
TRADERAI_MEMORY_PATH= TRADERAI_MEMORY_PATH=
UEX_NOTIFICATION_POLL_SECONDS=60 UEX_NOTIFICATION_POLL_SECONDS=300
REQUIRE_WRITE_APPROVAL=true REQUIRE_WRITE_APPROVAL=true
+6 -2
View File
@@ -1,6 +1,6 @@
# TraderAI # TraderAI
Local Ollama-powered chat for UEX marketplace workflows. Local Ollama-, DeepSeek-, OpenAI-, or Codex-powered chat for UEX marketplace workflows.
## What It Does ## What It Does
@@ -25,6 +25,10 @@ Local Ollama-powered chat for UEX marketplace workflows.
``` ```
3. Create `.env` from `.env.example` and set `UEX_SECRET_KEY` and/or `UEX_BEARER_TOKEN` if you want authenticated actions. 3. Create `.env` from `.env.example` and set `UEX_SECRET_KEY` and/or `UEX_BEARER_TOKEN` if you want authenticated actions.
If you want the cheapest hosted default, set `MODEL_PROVIDER=deepseek`, set `DEEPSEEK_API_KEY`, and keep `DEEPSEEK_MODEL=deepseek-v4-flash` unless you specifically want `deepseek-v4-pro`.
If you want to use OpenAI instead of Ollama, set `MODEL_PROVIDER=openai`, set `OPENAI_API_KEY`, and optionally change `OPENAI_MODEL` from the default `gpt-5.4-mini`.
If you want to use Codex models with ChatGPT/Codex OAuth, install the Codex CLI, set `MODEL_PROVIDER=codex`, and optionally change `CODEX_MODEL` from the default `gpt-5.4`. TraderAI uses the local `codex app-server` JSON-RPC interface for both authentication and chat turns.
`MODEL_REASONING_EFFORT` controls reasoning depth for DeepSeek, OpenAI, and Codex and defaults to `medium`.
`SCMDB_BASE_URL` defaults to `https://scmdb.net`. `SCMDB_BASE_URL` defaults to `https://scmdb.net`.
`CORNERSTONE_BASE_URL` defaults to `https://finder.cstone.space`. `CORNERSTONE_BASE_URL` defaults to `https://finder.cstone.space`.
4. Install and run: 4. Install and run:
@@ -38,7 +42,7 @@ Local Ollama-powered chat for UEX marketplace workflows.
## Notes ## Notes
Ollama runs locally at `http://localhost:11434` by default. This app talks to Ollama's native chat API with tool schemas, then executes approved UEX calls in the FastAPI backend. `OLLAMA_NUM_CTX` controls the per-request Ollama context window; `64512` is the default because Ollama recommends at least 64k tokens for agent-style workflows when hardware allows it. Ollama runs locally at `http://localhost:11434` by default. This app can talk to Ollama's native chat API, DeepSeek's OpenAI-compatible Chat Completions API, OpenAI's Chat Completions API, or the local Codex App Server authenticated through ChatGPT/Codex OAuth, then executes approved UEX calls in the FastAPI backend. `OLLAMA_NUM_CTX` controls the per-request Ollama context window; `64512` is the default because Ollama recommends at least 64k tokens for agent-style workflows when hardware allows it. DeepSeek context caching is provider-side and automatic when repeated prompt prefixes line up.
## Releases And Updates ## Releases And Updates
+8 -2
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "traderai" name = "traderai"
version = "0.0.4" version = "0.0.9"
description = "Local Ollama-powered assistant for UEX marketplace workflows." description = "Local Ollama, OpenAI, or Codex assistant for UEX marketplace workflows."
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"apscheduler>=3.10.4", "apscheduler>=3.10.4",
@@ -38,3 +38,9 @@ include = ["traderai*"]
+175
View File
@@ -1,5 +1,6 @@
import pytest import pytest
import asyncio import asyncio
import itertools
from traderai.agent import OllamaAgent, SYSTEM_PROMPT from traderai.agent import OllamaAgent, SYSTEM_PROMPT
from traderai.memory import MemoryStore from traderai.memory import MemoryStore
@@ -64,6 +65,19 @@ class TitleAgent(OllamaAgent):
return {"message": {"role": "assistant", "content": "Done"}} return {"message": {"role": "assistant", "content": "Done"}}
class ImageCaptureAgent(OllamaAgent):
def __init__(self, memory):
super().__init__("http://127.0.0.1:1", "missing-model", EmptyTools(), memory=memory)
self.last_messages = None
async def ensure_available(self):
return None
async def _chat_once(self, query="", messages=None, **kwargs):
self.last_messages = messages
return {"message": {"role": "assistant", "content": "Seen"}}
class SlowToolTools(EmptyTools): class SlowToolTools(EmptyTools):
schemas = [ schemas = [
{ {
@@ -204,6 +218,150 @@ def test_ollama_options_include_num_ctx():
assert agent._ollama_options() == {"num_ctx": 64000} assert agent._ollama_options() == {"num_ctx": 64000}
def test_deepseek_tool_rounds_are_not_capped_at_ten():
agent = OllamaAgent("https://api.deepseek.com", "deepseek-v4-flash", EmptyTools(), provider="deepseek", api_key="test")
rounds = list(itertools.islice(agent._tool_rounds(), 12))
assert len(rounds) == 12
def test_plan_draft_normalization_extracts_json_and_defaults():
seed = {"title": "Wikelo Polaris", "objective": "Find parts", "kind": "buying", "constraints": {}, "items": []}
raw = 'draft:\n{"title":"Wikelo Polaris Parts","objective":"Find and draft deals for the parts below","kind":"buying","cadence":"0 */3 * * *","constraints":{"message_tone":"casual","instructions":"Prioritize cheap listings first."},"items":[{"item_name":"RCMBNT-RGL-1","desired_quantity":2}]}'
draft = OllamaAgent._normalize_plan_draft(raw, seed)
assert draft["title"] == "Wikelo Polaris Parts"
assert draft["cadence"] == "0 */3 * * *"
assert draft["constraints"]["message_tone"] == "casual"
assert draft["items"][0]["item_name"] == "RCMBNT-RGL-1"
assert draft["items"][0]["desired_quantity"] == 2
def test_plan_draft_heuristic_fills_in_basic_instructions():
seed = {"title": "Watch open negotiations", "objective": "", "kind": "custom", "constraints": {}, "items": []}
draft = OllamaAgent._heuristic_plan_draft(seed)
assert draft["kind"] == "custom"
assert draft["cadence"] == "0 */4 * * *"
assert "summarize" in draft["constraints"]["instructions"].casefold()
assert draft["constraints"]["message_tone"] == "friendly and direct"
def test_codex_prompt_mentions_tools_and_images(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
agent = OllamaAgent("codex", "gpt-5.3-codex", EmptyTools(), memory=memory, provider="codex")
prompt = agent._codex_cli_prompt(
"check listing",
[
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": "Look at this",
"images": ["ZmFrZQ=="],
"image_content_types": ["image/png"],
},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_123",
"type": "function",
"function": {"name": "search_marketplace_listings", "arguments": "{\"commodity\":\"gold\"}"},
}
],
},
{
"role": "tool",
"tool_name": "search_marketplace_listings",
"tool_call_id": "call_123",
"content": "{\"ok\":true}",
},
],
)
assert "Available tools" in prompt
assert "attached images: 1" in prompt
assert "search_marketplace_listings" in prompt
assert "tool search_marketplace_listings" in prompt
def test_deepseek_openai_messages_include_reasoning_content_for_tool_turns():
agent = OllamaAgent("https://api.deepseek.com", "deepseek-v4-flash", EmptyTools(), provider="deepseek", api_key="test")
messages = agent._openai_messages(
"check listing",
[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": "Check this listing"},
{
"role": "assistant",
"content": "",
"reasoning_content": "I should check the current listing first.",
"tool_calls": [
{
"id": "call_123",
"type": "function",
"function": {"name": "search_marketplace_listings", "arguments": "{\"query\":\"panel\"}"},
}
],
},
{"role": "tool", "tool_name": "search_marketplace_listings", "tool_call_id": "call_123", "content": "{\"ok\":true}"},
],
)
assistant_turn = next(message for message in messages if message["role"] == "assistant")
assert assistant_turn["reasoning_content"] == "I should check the current listing first."
def test_codex_structured_response_extracts_text_and_tool_calls():
agent = OllamaAgent("codex", "gpt-5.3-codex", EmptyTools(), provider="codex")
result = agent._codex_structured_response(
{
"kind": "tool_call",
"message": "",
"tool_name": "search_marketplace_listings",
"arguments_json": "{\"commodity\":\"gold\"}",
}
)
assert result["message"]["content"] == ""
assert result["message"]["tool_calls"] == [
{
"id": result["message"]["tool_calls"][0]["id"],
"type": "function",
"function": {
"name": "search_marketplace_listings",
"arguments": "{\"commodity\":\"gold\"}",
},
}
]
def test_parse_codex_exec_output_reads_final_json():
agent = OllamaAgent("codex", "gpt-5.3-codex", EmptyTools(), provider="codex")
result = agent._parse_codex_exec_output(
{
"returncode": 0,
"stdout": "",
"stderr": "",
"events": [
{"type": "thread.started", "thread_id": "abc"},
{"type": "item.completed", "item": {"type": "agent_message", "text": "{\"kind\":\"final\",\"message\":\"hello\",\"tool_name\":\"\",\"arguments_json\":\"{}\"}"}},
{"type": "turn.completed"},
],
}
)
assert result == {"kind": "final", "message": "hello", "tool_name": "", "arguments_json": "{}"}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_wake_response_executes_tool_calls(tmp_path): async def test_wake_response_executes_tool_calls(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3")) memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
@@ -229,6 +387,23 @@ async def test_first_chat_message_generates_thread_title(tmp_path):
assert memory.get_thread(thread["id"])["title"] == "UEX Market Check" assert memory.get_thread(thread["id"])["title"] == "UEX Market Check"
@pytest.mark.asyncio
async def test_chat_includes_pasted_images_and_memory_note(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
agent = ImageCaptureAgent(memory)
result = await agent.chat(
"",
images=[{"name": "listing.png", "content_type": "image/png", "image_data": "ZmFrZS1pbWFnZQ=="}],
)
assert result["message"] == "Seen"
user_message = next(message for message in reversed(agent.last_messages) if message.get("role") == "user")
assert user_message["images"] == ["ZmFrZS1pbWFnZQ=="]
assert user_message["content"] == "Please analyze the attached image."
assert "[Attached 1 pasted image]" in memory.recent_conversation()[-2]["content"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_chat_events_returns_fallback_after_slow_tool_and_empty_final_response(tmp_path): async def test_chat_events_returns_fallback_after_slow_tool_and_empty_final_response(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3")) memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
+43
View File
@@ -0,0 +1,43 @@
from traderai.config import Settings
def test_model_provider_codex_falls_back_to_ollama():
settings = Settings(model_provider="codex")
assert settings.model_provider == "ollama"
def test_model_provider_openai_falls_back_to_ollama():
settings = Settings(model_provider="openai")
assert settings.model_provider == "ollama"
def test_model_provider_accepts_deepseek():
settings = Settings(model_provider="deepseek")
assert settings.model_provider == "deepseek"
def test_model_provider_invalid_value_falls_back_to_ollama():
settings = Settings(model_provider="something-else")
assert settings.model_provider == "ollama"
def test_reasoning_effort_normalizes_invalid_values():
settings = Settings(model_reasoning_effort="whatever")
assert settings.model_reasoning_effort == "medium"
def test_reasoning_effort_accepts_supported_values():
settings = Settings(model_reasoning_effort="high")
assert settings.model_reasoning_effort == "high"
def test_reasoning_effort_accepts_max():
settings = Settings(model_reasoning_effort="max")
assert settings.model_reasoning_effort == "max"
+19
View File
@@ -55,3 +55,22 @@ def test_memory_store_renames_threads_and_deletes_outbox_items(tmp_path):
assert renamed["title"] == "Market Check" assert renamed["title"] == "Market Check"
assert deleted is True assert deleted is True
assert store.list_outbox() == [] assert store.list_outbox() == []
def test_memory_store_uses_absolute_path_across_working_directory_changes(tmp_path, monkeypatch):
original_cwd = tmp_path / "start"
original_cwd.mkdir()
monkeypatch.chdir(original_cwd)
store = MemoryStore("data/memory.sqlite3")
moved_cwd = tmp_path / "moved"
moved_cwd.mkdir()
monkeypatch.chdir(moved_cwd)
store.add_outbox("Notification survived cwd change")
snapshot = store.inspect()
assert store.path.is_absolute()
assert snapshot["outbox"][0]["content"] == "Notification survived cwd change"
+148
View File
@@ -0,0 +1,148 @@
import pytest
from traderai.memory import MemoryStore
from traderai.negotiations import NegotiationSyncService, extract_negotiation_hash
from traderai.tools import ToolRegistry
class FakeNegotiationUEX:
def __init__(self):
self.list_calls = []
self.message_calls = []
self.posts = []
async def list_negotiations(self, id=None, id_listing=None, hash=None):
self.list_calls.append({"id": id, "id_listing": id_listing, "hash": hash})
data = [
{
"id": 11,
"hash": "open-hash",
"id_listing": 101,
"listing_slug": "rgl-open",
"listing_title": "RGL Set",
"advertiser_username": "seller_a",
"client_username": "pilot_hudson",
"date_modified": 1_780_975_053,
},
{
"id": 12,
"hash": "closed-recent",
"id_listing": 102,
"listing_slug": "rgl-closed",
"listing_title": "Closed Deal",
"advertiser_username": "seller_b",
"client_username": "pilot_hudson",
"date_modified": 1_780_975_053,
"date_closed": 1_780_975_054,
},
]
if hash:
data = [item for item in data if item["hash"] == hash]
return {"status": "ok", "negotiations": data}
async def get_negotiation_messages(self, hash=None, id_negotiation=None):
self.message_calls.append({"hash": hash, "id_negotiation": id_negotiation})
return {
"status": "ok",
"messages": [
{
"id": 201,
"negotiation_hash": hash,
"user_username": "seller_a" if hash == "open-hash" else "seller_b",
"user_name": "Seller",
"message": "Still available.",
"date_added": 1_780_975_053,
}
],
}
async def send_negotiation_message(self, **payload):
self.posts.append({"kind": "message", **payload})
return {"status": "ok", "posted": self.posts[-1]}
async def close_negotiation(self, **payload):
self.posts.append({"kind": "close", **payload})
return {"status": "ok", "posted": self.posts[-1]}
def test_extract_negotiation_hash_handles_uex_redirects():
assert extract_negotiation_hash("https://uexcorp.space/marketplace/negotiate/hash/abc-123") == "abc-123"
assert extract_negotiation_hash("/marketplace/negotiate/hash/def-456") == "def-456"
assert extract_negotiation_hash("/marketplace/item/info/foo") is None
@pytest.mark.asyncio
async def test_startup_sync_keeps_open_and_recent_threads(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
memory.set_profile("uex_user", {"username": "pilot_hudson"})
service = NegotiationSyncService(memory, FakeNegotiationUEX())
result = await service.startup_sync()
negotiations = memory.list_negotiations(limit=10)
assert result["count"] == 2
assert {item["hash"] for item in negotiations} == {"open-hash", "closed-recent"}
detail = memory.get_negotiation("open-hash")
assert detail["messages"][0]["body"] == "Still available."
@pytest.mark.asyncio
async def test_notification_refresh_targets_only_changed_negotiation(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
memory.set_profile("uex_user", {"username": "pilot_hudson"})
fake = FakeNegotiationUEX()
service = NegotiationSyncService(memory, fake)
await service.startup_sync()
fake.message_calls.clear()
await service.handle_notifications(
[
{
"id": 99,
"message": "seller_a: ping",
"redir": "https://uexcorp.space/marketplace/negotiate/hash/open-hash",
"date_added": 1_780_975_060,
"date_read": 0,
}
]
)
assert fake.message_calls == [{"hash": "open-hash", "id_negotiation": None}]
assert memory.get_negotiation("open-hash")["unread_count"] == 1
@pytest.mark.asyncio
async def test_manual_send_refreshes_local_thread(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
memory.set_profile("uex_user", {"username": "pilot_hudson"})
fake = FakeNegotiationUEX()
service = NegotiationSyncService(memory, fake)
await service.startup_sync()
result = await service.manual_send_message("open-hash", "I can buy tonight.")
assert result["posted"]["kind"] == "message"
assert fake.message_calls[-1]["hash"] == "open-hash"
@pytest.mark.asyncio
async def test_draft_negotiation_close_creates_pending_action(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
registry = ToolRegistry(FakeNegotiationUEX(), memory=memory)
result = await registry.draft_negotiation_close(
hash="open-hash",
deal_closed=True,
deal_value=1_000_000,
currency="UEC",
clarity_rating=5,
speed_rating=5,
respect_rating=5,
fairness_rating=4,
comment="Smooth trade",
)
pending = result["pending_action"]
assert pending["endpoint"] == "marketplace_negotiations_close"
assert pending["payload"]["deal_closed"] == 1
assert pending["payload"]["is_production"] == 1
+39
View File
@@ -104,6 +104,26 @@ def test_continual_plan_store_creates_buying_checklist(tmp_path):
assert plan["items"][0]["desired_quantity"] == 2 assert plan["items"][0]["desired_quantity"] == 2
def test_continual_plan_store_deletes_plan_and_related_records(tmp_path):
_, store, _, _, _ = plan_stack(tmp_path)
plan = store.create_plan(
"Delete me",
objective="Remove everything",
items=[{"item_name": "Wikelo Idris panel", "desired_quantity": 1}],
)
item_id = int(plan["items"][0]["id"])
candidate = store.upsert_candidate(plan["id"], item_id, {"id": "listing-1", "title": "Panel", "price": 10}, 0.9)
store.add_negotiation(plan["id"], item_id, int(candidate["id"]), {"listing_id": "listing-1", "listing_slug": "panel", "id_negotiation": "neg-1", "hash": "hash-1"})
assert store.delete_plan(plan["id"]) is True
assert store.get_plan(plan["id"]) is None
assert store.list_items(plan["id"]) == []
assert store.list_candidates(plan["id"]) == []
assert store.list_negotiations(plan["id"]) == []
assert store.list_events(plan["id"]) == []
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_buying_runner_tracks_candidates_and_drafts_only(tmp_path): async def test_buying_runner_tracks_candidates_and_drafts_only(tmp_path):
memory, store, tools, runner, _ = plan_stack(tmp_path) memory, store, tools, runner, _ = plan_stack(tmp_path)
@@ -122,6 +142,9 @@ async def test_buying_runner_tracks_candidates_and_drafts_only(tmp_path):
assert len(tools.pending_actions) == 1 assert len(tools.pending_actions) == 1
assert not tools.uex.posts assert not tools.uex.posts
assert "Drafted 1 negotiation" in memory.list_outbox()[0]["content"] assert "Drafted 1 negotiation" in memory.list_outbox()[0]["content"]
pending = next(iter(tools.pending_actions.values()))
assert "Tone note" not in pending.payload["message"]
assert "Polaris build" not in pending.payload["message"] or "putting together parts for a Polaris build" in pending.payload["message"]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -215,3 +238,19 @@ async def test_scheduler_schedules_overdue_plan_catchup_on_start(tmp_path):
assert catchup is not None assert catchup is not None
assert any(event["kind"] == "catchup_scheduled" for event in snapshot["events"]) assert any(event["kind"] == "catchup_scheduled" for event in snapshot["events"])
@pytest.mark.asyncio
async def test_tools_delete_continual_plan_removes_it(tmp_path):
_, store, tools, _, _ = plan_stack(tmp_path)
plan = store.create_plan(
"Delete through tools",
objective="Remove via registry",
items=[{"item_name": "Wikelo Idris panel"}],
)
result = await tools.delete_continual_plan(plan["id"])
assert result["deleted"] is True
assert result["plan_id"] == plan["id"]
assert store.get_plan(plan["id"]) is None
+258
View File
@@ -0,0 +1,258 @@
from __future__ import annotations
from types import SimpleNamespace
from fastapi.testclient import TestClient
import traderai.server as server
def test_config_update_rebuilds_runtime_without_restart(monkeypatch, tmp_path):
state = {"settings": make_settings(tmp_path, model_provider="ollama", ollama_model="qwen3.5:9b")}
class FakeScheduler:
def __init__(self, memory):
self.memory = memory
def bind_agent(self, agent):
self.agent = agent
def bind_plan_runner(self, plan_runner):
self.plan_runner = plan_runner
def bind_uex_notifications(self, uex, poll_seconds=60):
self.uex = uex
self.poll_seconds = poll_seconds
def start(self):
return None
def shutdown(self):
return None
def list_jobs(self):
return []
class FakeUEXClient:
def __init__(self, *args, **kwargs):
pass
async def get_user(self, username=None, authenticated=False):
return {}
class FakeToolRegistry:
def __init__(self, *args, **kwargs):
self.pending_actions = {}
self.plan_runner = None
async def approve(self, action_id):
return {"approved": action_id}
async def decline(self, action_id):
return {"declined": action_id}
class FakePlanRunner:
def __init__(self, store, tools, memory, agent=None):
self.store = store
self.tools = tools
self.memory = memory
self.agent = agent
def bind_agent(self, agent):
self.agent = agent
class FakeClient:
def __init__(self, *args, **kwargs):
pass
async def fake_health(self):
return {
"online": True,
"provider": self.provider,
"model": self.model,
"model_available": True,
"message": f"{self.provider} ready",
}
async def fake_chat(self, content, thread_id=None, images=None):
return {"message": f"{self.provider}:{self.model}", "pending_actions": [], "thread_id": thread_id}
def fake_get_settings():
return state["settings"]
def fake_save_settings(values):
state["settings"] = make_settings(
tmp_path,
model_provider=values.get("model_provider", state["settings"].model_provider),
ollama_model=values.get("ollama_model", state["settings"].ollama_model),
codex_model=values.get("codex_model", state["settings"].codex_model),
deepseek_model=values.get("deepseek_model", state["settings"].deepseek_model),
)
return {"values": values, "fields": {}, "secrets_configured": {}, "app_data_dir": str(tmp_path)}
monkeypatch.setattr(server, "WakeScheduler", FakeScheduler)
monkeypatch.setattr(server, "UEXClient", FakeUEXClient)
monkeypatch.setattr(server, "ToolRegistry", FakeToolRegistry)
monkeypatch.setattr(server, "ContinualPlanRunner", FakePlanRunner)
monkeypatch.setattr(server, "SCMDBClient", FakeClient)
monkeypatch.setattr(server, "CornerstoneClient", FakeClient)
monkeypatch.setattr(server, "StarCitizenWikiClient", FakeClient)
monkeypatch.setattr(server, "get_settings", fake_get_settings)
monkeypatch.setattr(server, "save_settings", fake_save_settings)
monkeypatch.setattr(
server,
"settings_payload",
lambda settings=None: {"app_data_dir": str(tmp_path), "values": {}, "fields": {}, "secrets_configured": {}},
)
monkeypatch.setattr(server.OllamaAgent, "health", fake_health)
monkeypatch.setattr(server.OllamaAgent, "chat", fake_chat)
app = server.create_app()
with TestClient(app) as client:
before = client.get("/api/health").json()
assert before["model_provider"] == "ollama"
assert before["inference"]["provider"] == "ollama"
updated = client.post(
"/api/config",
json={"values": {"model_provider": "deepseek", "deepseek_model": "deepseek-v4-flash"}},
).json()
assert updated["restart_required"] is False
after = client.get("/api/health").json()
assert after["model_provider"] == "deepseek"
assert after["inference"]["provider"] == "deepseek"
chat = client.post("/api/chat", json={"message": "hi", "thread_id": "thread-1", "images": []}).json()
assert chat["message"] == "deepseek:deepseek-v4-flash"
def test_plan_draft_endpoint_returns_agent_draft(monkeypatch, tmp_path):
state = {"settings": make_settings(tmp_path)}
class FakeScheduler:
def __init__(self, memory):
self.memory = memory
def bind_agent(self, agent):
self.agent = agent
def bind_plan_runner(self, plan_runner):
self.plan_runner = plan_runner
def bind_uex_notifications(self, uex, poll_seconds=60):
self.uex = uex
self.poll_seconds = poll_seconds
def start(self):
return None
def shutdown(self):
return None
def list_jobs(self):
return []
class FakeUEXClient:
def __init__(self, *args, **kwargs):
pass
async def get_user(self, username=None, authenticated=False):
return {}
class FakeToolRegistry:
def __init__(self, *args, **kwargs):
self.pending_actions = {}
self.plan_runner = None
async def approve(self, action_id):
return {"approved": action_id}
async def decline(self, action_id):
return {"declined": action_id}
class FakePlanRunner:
def __init__(self, store, tools, memory, agent=None):
self.store = store
self.tools = tools
self.memory = memory
self.agent = agent
def bind_agent(self, agent):
self.agent = agent
class FakeClient:
def __init__(self, *args, **kwargs):
pass
def fake_get_settings():
return state["settings"]
monkeypatch.setattr(server, "WakeScheduler", FakeScheduler)
monkeypatch.setattr(server, "UEXClient", FakeUEXClient)
monkeypatch.setattr(server, "ToolRegistry", FakeToolRegistry)
monkeypatch.setattr(server, "ContinualPlanRunner", FakePlanRunner)
monkeypatch.setattr(server, "SCMDBClient", FakeClient)
monkeypatch.setattr(server, "CornerstoneClient", FakeClient)
monkeypatch.setattr(server, "StarCitizenWikiClient", FakeClient)
monkeypatch.setattr(server, "get_settings", fake_get_settings)
monkeypatch.setattr(
server,
"settings_payload",
lambda settings=None: {"app_data_dir": str(tmp_path), "values": {}, "fields": {}, "secrets_configured": {}},
)
async def fake_generate_plan_draft(self, title="", objective="", kind="buying", constraints=None, items=None):
return {
"title": title or "Draft title",
"objective": objective or "Draft objective",
"kind": kind,
"cadence": "0 */3 * * *",
"constraints": {"message_tone": "friendly and direct", "instructions": "Start with the best listings."},
"items": [{"item_name": "RCMBNT-RGL-1", "desired_quantity": 1, "max_unit_price": None}],
}
monkeypatch.setattr(server.OllamaAgent, "generate_plan_draft", fake_generate_plan_draft)
app = server.create_app()
with TestClient(app) as client:
response = client.post(
"/api/plans/draft",
json={"title": "Polaris parts", "objective": "Find the required parts", "kind": "buying", "constraints": {}, "items": []},
)
assert response.status_code == 200
draft = response.json()["draft"]
assert draft["cadence"] == "0 */3 * * *"
assert draft["constraints"]["instructions"] == "Start with the best listings."
assert draft["items"][0]["item_name"] == "RCMBNT-RGL-1"
def make_settings(tmp_path, model_provider="ollama", ollama_model="qwen3.5:9b", codex_model="gpt-5.4", deepseek_model="deepseek-v4-flash"):
return SimpleNamespace(
traderai_memory_path=str(tmp_path / "memory.sqlite3"),
model_provider=model_provider,
ollama_base_url="http://localhost:11434",
ollama_model=ollama_model,
ollama_num_ctx=64512,
openai_base_url="https://api.openai.com/v1",
openai_api_key=None,
openai_model="gpt-5.4-mini",
deepseek_base_url="https://api.deepseek.com",
deepseek_api_key=None,
deepseek_model=deepseek_model,
model_reasoning_effort="medium",
codex_command="codex",
codex_model=codex_model,
uex_base_url="https://api.uexcorp.space/2.0",
uex_secret_key=None,
uex_bearer_token=None,
uex_negotiation_close_endpoint="marketplace_negotiations_close",
traderai_user_name=None,
uex_notification_poll_seconds=60,
require_write_approval=True,
scmdb_base_url="https://scmdb.net",
cornerstone_base_url="https://finder.cstone.space",
scwiki_base_url="https://starcitizen.tools",
scwiki_api_base_url="https://api.star-citizen.wiki",
)
+314
View File
@@ -10,8 +10,10 @@ from traderai.uex_client import UEXClient
class FakeUEX: class FakeUEX:
def __init__(self): def __init__(self):
self.posts = [] self.posts = []
self.get_calls = []
async def get(self, path, params=None, authenticated=False): async def get(self, path, params=None, authenticated=False):
self.get_calls.append({"path": path, "params": params, "authenticated": authenticated})
if path == "commodities_prices_history": if path == "commodities_prices_history":
return { return {
"status": "ok", "status": "ok",
@@ -80,6 +82,34 @@ class FakeUEX:
}, },
], ],
} }
if path == "marketplace_trends":
return {
"status": "ok",
"data": [
{
"id_item": 2791,
"item_name": "\"Quantanium\" Water Bottle",
"item_slug": "quantanium-water-bottle",
"currency": "UEC",
"price_avg_sell": "937500",
"price_avg_month_sell": "1072222",
"price_min_sell": "750000",
"price_max_sell": "1200000",
"listings_count_sell": 4,
"price_avg_buy": "500000",
"price_avg_month_buy": "525000",
"price_min_buy": "450000",
"price_max_buy": "550000",
"listings_count_buy": 2,
"total_listings_count": 6,
"negotiations_count": 18,
"negotiations_open": 7,
"negotiations_success": 9,
"link_prices": "https://uexcorp.space/marketplace/home/?id_item=2791&mode=list",
"link_prices_history": "https://uexcorp.space/marketplace/averages/?id_item=2791&quality_tier=q0&unit=unit",
}
],
}
assert path == "marketplace_listings" assert path == "marketplace_listings"
return { return {
"data": [ "data": [
@@ -259,6 +289,116 @@ class FakeCornerstone:
} }
class FakeSCWiki:
base_url = "https://starcitizen.tools"
api_base_url = "https://api.star-citizen.wiki"
async def search_pages(self, query, limit=5):
assert query == "Carrack"
return [
{
"pageid": 415,
"title": "Carrack",
"description": "Deep-space multi-crew explorer manufactured by Anvil Aerospace",
"extract": "The Anvil Carrack is a multi-crew explorer.",
"thumbnail": "https://media.starcitizen.tools/carrack.webp",
"url": "https://starcitizen.tools/Carrack",
}
][:limit]
async def get_page_summary(self, title=None, pageid=None, chars=700):
assert title == "Carrack" or pageid == 415
return {
"pageid": 415,
"title": "Carrack",
"description": "Deep-space multi-crew explorer manufactured by Anvil Aerospace",
"extract": "The Anvil Carrack is a multi-crew explorer.",
"thumbnail": "https://media.starcitizen.tools/carrack.webp",
"url": "https://starcitizen.tools/Carrack",
}
async def search_verse(self, query):
assert query == "Carrack"
return [
{
"type": "vehicles",
"label": "Vehicles",
"results": [
{
"name": "Anvil Carrack",
"class_name": "ANVL_Carrack",
"extra_label": "Exploration",
"web_url": "https://api.star-citizen.wiki/vehicles/anvl-carrack",
"api_url": "https://api.star-citizen.wiki/api/vehicles/anvl-carrack",
}
],
}
]
async def get_vehicle(self, slug):
assert slug == "anvl-carrack"
return {
"name": "Carrack",
"game_name": "Anvil Carrack",
"slug": "anvl-carrack",
"manufacturer": {"name": "Anvil Aerospace"},
"career": "Exploration",
"role": "Expedition",
"size_class": 5,
"cargo_capacity": 456,
"crew": {"min": 6, "max": 6},
"msrp": 600,
"pledge_url": "https://robertsspaceindustries.com/pledge/ships/carrack/Carrack",
"uex_prices": {
"purchase": [
{
"price_buy": 34398000,
"terminal_name": "Astro Armada - Area 18",
"starmap_location": {"name": "Area18", "parent_name": "ArcCorp", "star_system_name": "Stanton"},
"game_version": "4.8.1-LIVE.11952564",
"date_updated": "2026-05-20T18:39:37-04:00",
"uex_link": "https://uexcorp.space/vehicles/home/list/in_game_sell/?id_terminal=148",
}
]
},
"description": {"en_EN": "The Anvil Carrack features reinforced fuel tanks for long-duration flight."},
"web_url": "https://api.star-citizen.wiki/vehicles/anvl-carrack",
"updated_at": "2026-06-08T00:34:00Z",
"version": "4.8.1-LIVE.11952564",
}
class FakeWikelo:
base_url = "https://wikelo-projects.test"
async def list_ship_projects(self):
return [
{
"id": "ship-1",
"ship_name": "Polaris Wikelo Special",
"description": "Now make Polaris. Short Time Deal",
"status": "planning",
"privacy": "public",
"owner_name": "Chimpanz33",
"required_materials": [
{"material_name": "Wikelo Favor", "quantity_needed": 50.0, "quantity_collected": 0.0},
{"material_name": "Polaris Bit", "quantity_needed": 15.0, "quantity_collected": 2.0},
],
},
{
"id": "ship-2",
"ship_name": "Guardian",
"description": "Guardian Fight Mod",
"status": "planning",
"privacy": "public",
"owner_name": "Chimpanz33",
"required_materials": [
{"material_name": "Wikelo Favor", "quantity_needed": 20.0, "quantity_collected": 0.0},
],
},
]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_search_marketplace_listings_filters_locally(): async def test_search_marketplace_listings_filters_locally():
registry = ToolRegistry(FakeUEX()) registry = ToolRegistry(FakeUEX())
@@ -312,6 +452,17 @@ def test_uex_client_uses_bearer_and_secret_headers():
assert headers["Authorization"] == "Bearer bearer" assert headers["Authorization"] == "Bearer bearer"
def test_uex_client_uses_configured_close_endpoint():
client = UEXClient(
"https://api.uexcorp.space/2.0",
secret_key="secret",
bearer_token="bearer",
negotiation_close_endpoint="custom_close_endpoint",
)
assert client.negotiation_close_endpoint == "custom_close_endpoint"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uex_get_projects_and_limits_results(): async def test_uex_get_projects_and_limits_results():
registry = ToolRegistry(FakeUEX()) registry = ToolRegistry(FakeUEX())
@@ -333,6 +484,65 @@ async def test_uex_get_projects_and_limits_results():
assert result["items"] == [{"id": 10, "commodity_name": "Gold", "price_buy": 4120}] assert result["items"] == [{"id": 10, "commodity_name": "Gold", "price_buy": 4120}]
@pytest.mark.asyncio
async def test_uex_get_marketplace_listings_accepts_item_and_operation_filters():
fake = FakeUEX()
registry = ToolRegistry(fake)
result = await registry.execute(
"get_uex_marketplace_listings",
{
"id_item": 2791,
"operation": "sell",
"fields": ["id", "slug", "operation"],
},
)
assert result["params"] == {"id_item": 2791, "operation": "sell"}
assert fake.get_calls[-1]["path"] == "marketplace_listings"
assert fake.get_calls[-1]["params"] == {"id_item": 2791, "operation": "sell"}
@pytest.mark.asyncio
async def test_get_marketplace_trends_returns_compact_wts_wtb_and_negotiation_metrics():
fake = FakeUEX()
registry = ToolRegistry(fake)
result = await registry.get_marketplace_trends(item_name="Quantanium", currency="UEC", quality_tier=0)
assert result["status"] == "ok"
assert result["count"] == 1
assert result["filters"] == {"item_name": "Quantanium", "currency": "UEC", "quality_tier": 0}
assert fake.get_calls[-1]["path"] == "marketplace_trends"
assert fake.get_calls[-1]["params"] == {"id_item": None, "item_name": "Quantanium", "item_slug": None, "id_category": None, "currency": "UEC", "quality_tier": 0}
assert result["trends"][0] == {
"id_item": 2791,
"item_name": "\"Quantanium\" Water Bottle",
"item_slug": "quantanium-water-bottle",
"currency": "UEC",
"sell": {
"avg_price": "937500",
"avg_price_month": "1072222",
"min_price": "750000",
"max_price": "1200000",
"listings_count": 4,
},
"buy": {
"avg_price": "500000",
"avg_price_month": "525000",
"min_price": "450000",
"max_price": "550000",
"listings_count": 2,
},
"total_listings_count": 6,
"negotiations_count": 18,
"negotiations_open": 7,
"negotiations_success": 9,
"link_prices": "https://uexcorp.space/marketplace/home/?id_item=2791&mode=list",
"link_prices_history": "https://uexcorp.space/marketplace/averages/?id_item=2791&quality_tier=q0&unit=unit",
}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uex_api_catalog_exposes_resources_without_live_call(): async def test_uex_api_catalog_exposes_resources_without_live_call():
registry = ToolRegistry(FakeUEX()) registry = ToolRegistry(FakeUEX())
@@ -368,6 +578,7 @@ def test_schemas_expose_specific_uex_tools_instead_of_generic_api_tool():
assert "get_uex_commodities_prices" in names assert "get_uex_commodities_prices" in names
assert "get_uex_vehicles" in names assert "get_uex_vehicles" in names
assert "get_marketplace_trends" in names
assert "draft_uex_marketplace_advertise" in names assert "draft_uex_marketplace_advertise" in names
assert "delete_uex_marketplace_listings" in names assert "delete_uex_marketplace_listings" in names
assert "uex_get" not in names assert "uex_get" not in names
@@ -395,6 +606,17 @@ def test_schemas_expose_cornerstone_item_tools():
assert "draft_marketplace_listing_with_cornerstone_image" in names assert "draft_marketplace_listing_with_cornerstone_image" in names
def test_schemas_expose_scwiki_tools():
registry = ToolRegistry(FakeUEX(), scwiki=FakeSCWiki())
names = {schema["function"]["name"] for schema in registry.schemas}
assert "search_scwiki_pages" in names
assert "get_scwiki_page" in names
assert "search_scwiki_vehicles" in names
assert "get_scwiki_vehicle" in names
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_search_scmdb_missions_returns_reward_summary(): async def test_search_scmdb_missions_returns_reward_summary():
registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB()) registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB())
@@ -469,6 +691,72 @@ async def test_get_cornerstone_item_media_returns_absolute_image_urls():
] ]
@pytest.mark.asyncio
async def test_search_scwiki_pages_returns_general_knowledge_matches():
registry = ToolRegistry(FakeUEX(), scwiki=FakeSCWiki())
result = await registry.search_scwiki_pages(query="Carrack")
assert result["source"] == "https://starcitizen.tools"
assert result["matched"] == 1
assert result["pages"][0]["title"] == "Carrack"
assert result["pages"][0]["url"] == "https://starcitizen.tools/Carrack"
@pytest.mark.asyncio
async def test_get_scwiki_vehicle_returns_ship_prices_and_store_context():
registry = ToolRegistry(FakeUEX(), scwiki=FakeSCWiki())
result = await registry.get_scwiki_vehicle(query="Carrack")
assert result["source"] == "https://api.star-citizen.wiki"
vehicle = result["vehicle"]
assert vehicle["name"] == "Carrack"
assert vehicle["manufacturer"] == "Anvil Aerospace"
assert vehicle["msrp"] == 600
assert vehicle["purchase_locations"] == [
{
"price_buy": 34398000,
"terminal_name": "Astro Armada - Area 18",
"location": "Area18",
"parent_location": "ArcCorp",
"star_system": "Stanton",
"game_version": "4.8.1-LIVE.11952564",
"date_updated": "2026-05-20T18:39:37-04:00",
"uex_link": "https://uexcorp.space/vehicles/home/list/in_game_sell/?id_terminal=148",
}
]
@pytest.mark.asyncio
async def test_search_wikelo_ship_projects_returns_material_matches():
registry = ToolRegistry(FakeUEX(), wikelo=FakeWikelo())
result = await registry.search_wikelo_ship_projects(query="Polaris")
assert result["source"] == "https://wikelo-projects.test/Ships"
assert result["matched"] == 1
assert result["projects"][0]["ship_name"] == "Polaris Wikelo Special"
assert result["projects"][0]["required_materials"][0]["material_name"] == "Wikelo Favor"
@pytest.mark.asyncio
async def test_get_wikelo_ship_project_returns_full_requirements():
registry = ToolRegistry(FakeUEX(), wikelo=FakeWikelo())
result = await registry.get_wikelo_ship_project(ship_name="Guardian")
assert result["project"]["ship_name"] == "Guardian"
assert result["project"]["materials_count"] == 1
assert result["project"]["required_materials"] == [
{
"material_name": "Wikelo Favor",
"quantity_needed": 20,
"quantity_collected": 0,
}
]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_draft_marketplace_listing_with_cornerstone_image_adds_image_data_and_redacts_display(): async def test_draft_marketplace_listing_with_cornerstone_image_adds_image_data_and_redacts_display():
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone()) registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
@@ -497,6 +785,32 @@ async def test_draft_marketplace_listing_with_cornerstone_image_adds_image_data_
assert pending["metadata"]["cornerstone_image_status"] == "included" assert pending["metadata"]["cornerstone_image_status"] == "included"
@pytest.mark.asyncio
async def test_draft_marketplace_listing_can_reuse_pasted_chat_image():
registry = ToolRegistry(FakeUEX())
with registry.chat_image_scope([{"name": "listing.png", "content_type": "image/png", "image_data": "ZmFrZS1pbWFnZQ=="}]):
result = await registry.draft_marketplace_listing(
id_category=3,
operation="sell",
type="item",
unit="unit",
title="Abrade Scraper Module",
description="Clean module, ready for pickup.",
price=21250,
currency="UEC",
language="en_US",
use_attached_image=True,
)
pending = result["pending_action"]
stored = registry.pending_actions[pending["id"]]
assert pending["payload"]["image_data"].startswith("<base64 image data redacted")
assert stored.payload["image_data"] == "ZmFrZS1pbWFnZQ=="
assert pending["metadata"]["attached_chat_image_name"] == "listing.png"
assert pending["metadata"]["attached_chat_image_status"] == "included"
def test_parse_cornerstone_item_page_extracts_locations(): def test_parse_cornerstone_item_page_extracts_locations():
parsed = parse_cornerstone_item_page( parsed = parse_cornerstone_item_page(
""" """
+453
View File
File diff suppressed because one or more lines are too long
+1343 -55
View File
File diff suppressed because it is too large Load Diff
+41 -3
View File
@@ -11,14 +11,27 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
CONFIG_FIELDS: dict[str, dict[str, Any]] = { CONFIG_FIELDS: dict[str, dict[str, Any]] = {
"model_provider": {"env": "MODEL_PROVIDER", "type": "string", "secret": False},
"ollama_base_url": {"env": "OLLAMA_BASE_URL", "type": "string", "secret": False}, "ollama_base_url": {"env": "OLLAMA_BASE_URL", "type": "string", "secret": False},
"ollama_model": {"env": "OLLAMA_MODEL", "type": "string", "secret": False}, "ollama_model": {"env": "OLLAMA_MODEL", "type": "string", "secret": False},
"ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False}, "ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False},
"openai_base_url": {"env": "OPENAI_BASE_URL", "type": "string", "secret": False},
"openai_model": {"env": "OPENAI_MODEL", "type": "string", "secret": False},
"deepseek_base_url": {"env": "DEEPSEEK_BASE_URL", "type": "string", "secret": False},
"deepseek_model": {"env": "DEEPSEEK_MODEL", "type": "string", "secret": False},
"model_reasoning_effort": {"env": "MODEL_REASONING_EFFORT", "type": "string", "secret": False},
"codex_command": {"env": "CODEX_COMMAND", "type": "string", "secret": False},
"codex_model": {"env": "CODEX_MODEL", "type": "string", "secret": False},
"uex_base_url": {"env": "UEX_BASE_URL", "type": "string", "secret": False}, "uex_base_url": {"env": "UEX_BASE_URL", "type": "string", "secret": False},
"scmdb_base_url": {"env": "SCMDB_BASE_URL", "type": "string", "secret": False}, "scmdb_base_url": {"env": "SCMDB_BASE_URL", "type": "string", "secret": False},
"cornerstone_base_url": {"env": "CORNERSTONE_BASE_URL", "type": "string", "secret": False}, "cornerstone_base_url": {"env": "CORNERSTONE_BASE_URL", "type": "string", "secret": False},
"scwiki_base_url": {"env": "SCWIKI_BASE_URL", "type": "string", "secret": False},
"scwiki_api_base_url": {"env": "SCWIKI_API_BASE_URL", "type": "string", "secret": False},
"openai_api_key": {"env": "OPENAI_API_KEY", "type": "string", "secret": True},
"deepseek_api_key": {"env": "DEEPSEEK_API_KEY", "type": "string", "secret": True},
"uex_secret_key": {"env": "UEX_SECRET_KEY", "type": "string", "secret": True}, "uex_secret_key": {"env": "UEX_SECRET_KEY", "type": "string", "secret": True},
"uex_bearer_token": {"env": "UEX_BEARER_TOKEN", "type": "string", "secret": True}, "uex_bearer_token": {"env": "UEX_BEARER_TOKEN", "type": "string", "secret": True},
"uex_negotiation_close_endpoint": {"env": "UEX_NEGOTIATION_CLOSE_ENDPOINT", "type": "string", "secret": False},
"traderai_user_name": {"env": "TRADERAI_USER_NAME", "type": "string", "secret": False}, "traderai_user_name": {"env": "TRADERAI_USER_NAME", "type": "string", "secret": False},
"traderai_memory_path": {"env": "TRADERAI_MEMORY_PATH", "type": "string", "secret": False}, "traderai_memory_path": {"env": "TRADERAI_MEMORY_PATH", "type": "string", "secret": False},
"uex_notification_poll_seconds": {"env": "UEX_NOTIFICATION_POLL_SECONDS", "type": "integer", "secret": False}, "uex_notification_poll_seconds": {"env": "UEX_NOTIFICATION_POLL_SECONDS", "type": "integer", "secret": False},
@@ -62,24 +75,49 @@ class Settings(BaseSettings):
env_file_encoding="utf-8", env_file_encoding="utf-8",
) )
model_provider: str = "ollama"
ollama_base_url: str = "http://localhost:11434" ollama_base_url: str = "http://localhost:11434"
ollama_model: str = "qwen3.5:9b" ollama_model: str = "qwen3.5:9b"
ollama_num_ctx: int = 64512 ollama_num_ctx: int = 64512
openai_base_url: str = "https://api.openai.com/v1"
openai_model: str = "gpt-5.4-mini"
deepseek_base_url: str = "https://api.deepseek.com"
deepseek_model: str = "deepseek-v4-flash"
model_reasoning_effort: str = "medium"
codex_command: str = "codex"
codex_model: str = "gpt-5.4"
uex_base_url: str = "https://api.uexcorp.space/2.0" uex_base_url: str = "https://api.uexcorp.space/2.0"
scmdb_base_url: str = "https://scmdb.net" scmdb_base_url: str = "https://scmdb.net"
cornerstone_base_url: str = "https://finder.cstone.space" cornerstone_base_url: str = "https://finder.cstone.space"
scwiki_base_url: str = "https://starcitizen.tools"
scwiki_api_base_url: str = "https://api.star-citizen.wiki"
openai_api_key: str | None = Field(default=None)
deepseek_api_key: str | None = Field(default=None)
uex_secret_key: str | None = Field(default=None) uex_secret_key: str | None = Field(default=None)
uex_bearer_token: str | None = Field(default=None) uex_bearer_token: str | None = Field(default=None)
uex_negotiation_close_endpoint: str = "marketplace_negotiations_close"
traderai_user_name: str | None = Field(default=None) traderai_user_name: str | None = Field(default=None)
traderai_memory_path: str = Field(default_factory=lambda: str(default_memory_path())) traderai_memory_path: str = Field(default_factory=lambda: str(default_memory_path()))
uex_notification_poll_seconds: int = 60 uex_notification_poll_seconds: int = 300
require_write_approval: bool = True require_write_approval: bool = True
@field_validator("uex_secret_key", "uex_bearer_token", "traderai_user_name", mode="before") @field_validator("openai_api_key", "deepseek_api_key", "uex_secret_key", "uex_bearer_token", "traderai_user_name", mode="before")
@classmethod @classmethod
def _blank_optional(cls, value: Any) -> Any: def _blank_optional(cls, value: Any) -> Any:
return None if value == "" else value return None if value == "" else value
@field_validator("model_provider", mode="before")
@classmethod
def _normalize_model_provider(cls, value: Any) -> str:
text = str(value or "ollama").strip().casefold()
return text if text in {"ollama", "deepseek"} else "ollama"
@field_validator("model_reasoning_effort", mode="before")
@classmethod
def _normalize_reasoning_effort(cls, value: Any) -> str:
text = str(value or "medium").strip().casefold()
return text if text in {"none", "minimal", "low", "medium", "high", "xhigh", "max"} else "medium"
@field_validator("traderai_memory_path", mode="before") @field_validator("traderai_memory_path", mode="before")
@classmethod @classmethod
def _blank_memory_path(cls, value: Any) -> Any: def _blank_memory_path(cls, value: Any) -> Any:
@@ -137,7 +175,7 @@ def save_settings(values: dict[str, Any]) -> dict[str, Any]:
def _coerce_value(key: str, value: Any) -> Any: def _coerce_value(key: str, value: Any) -> Any:
field_type = CONFIG_FIELDS[key]["type"] field_type = CONFIG_FIELDS[key]["type"]
if value == "": if value == "":
return None if key in {"uex_secret_key", "uex_bearer_token", "traderai_user_name"} else "" return None if key in {"openai_api_key", "deepseek_api_key", "uex_secret_key", "uex_bearer_token", "traderai_user_name"} else ""
if field_type == "integer": if field_type == "integer":
return int(value) return int(value)
if field_type == "boolean": if field_type == "boolean":
+28
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import os import os
from pathlib import Path from pathlib import Path
import shutil import shutil
@@ -25,6 +26,10 @@ def resource_path(*parts: str) -> Path:
def main() -> None: def main() -> None:
try: try:
_chdir_to_app_dir() _chdir_to_app_dir()
backend_port = _backend_port_from_args()
if backend_port is not None:
_run_server(backend_port)
return
_log("TraderAI desktop starting") _log("TraderAI desktop starting")
_log(f"cwd={Path.cwd()}") _log(f"cwd={Path.cwd()}")
_log(f"executable={sys.executable}") _log(f"executable={sys.executable}")
@@ -36,6 +41,10 @@ def main() -> None:
_log("existing TraderAI backend found; opening window") _log("existing TraderAI backend found; opening window")
_open_window(url) _open_window(url)
return return
if getattr(sys, "frozen", False):
backend_process = _start_backend_process(port)
_log(f"backend process started pid={backend_process.pid}")
else:
server_thread = threading.Thread(target=_run_server, args=(port,), daemon=True) server_thread = threading.Thread(target=_run_server, args=(port,), daemon=True)
server_thread.start() server_thread.start()
_log("backend thread started") _log("backend thread started")
@@ -62,6 +71,22 @@ def _select_port() -> int:
return _free_port() return _free_port()
def _backend_port_from_args() -> int | None:
args = sys.argv[1:]
if len(args) >= 2 and args[0] == "--backend-port":
return int(args[1])
return None
def _start_backend_process(port: int) -> subprocess.Popen:
command = [sys.executable, "--backend-port", str(port)]
_log(f"starting backend subprocess: {' '.join(command)}")
kwargs: dict[str, object] = {}
if sys.platform == "win32":
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
return subprocess.Popen(command, **kwargs)
def _port_available(port: int) -> bool: def _port_available(port: int) -> bool:
try: try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
@@ -88,6 +113,9 @@ def _existing_server_ready(url: str) -> bool:
def _run_server(port: int) -> NoReturn: def _run_server(port: int) -> NoReturn:
try: try:
_log(f"backend starting on port {port}") _log(f"backend starting on port {port}")
if sys.platform == "win32" and hasattr(asyncio, "WindowsProactorEventLoopPolicy"):
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
_log("set Windows Proactor event loop policy for subprocess-compatible backend")
from traderai.server import app from traderai.server import app
config = uvicorn.Config( config = uvicorn.Config(
+530 -1
View File
@@ -30,6 +30,16 @@ def parse_iso(value: str) -> datetime:
return parsed return parsed
def unix_to_iso(value: Any) -> str | None:
try:
timestamp = int(value)
except (TypeError, ValueError):
return None
if timestamp <= 0:
return None
return datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat()
def time_since(value: str, now: datetime | None = None) -> str: def time_since(value: str, now: datetime | None = None) -> str:
then = parse_iso(value) then = parse_iso(value)
current = now or utc_now() current = now or utc_now()
@@ -55,7 +65,7 @@ def _plural(value: int, unit: str) -> str:
class MemoryStore: class MemoryStore:
def __init__(self, path: str) -> None: def __init__(self, path: str) -> None:
self.path = Path(path) self.path = Path(path).expanduser().resolve()
self.path.parent.mkdir(parents=True, exist_ok=True) self.path.parent.mkdir(parents=True, exist_ok=True)
self._init_db() self._init_db()
@@ -138,6 +148,56 @@ class MemoryStore:
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
delivered_at TEXT delivered_at TEXT
); );
CREATE TABLE IF NOT EXISTS negotiation_threads (
negotiation_hash TEXT PRIMARY KEY,
uex_negotiation_id INTEGER,
listing_id INTEGER,
listing_slug TEXT,
title TEXT,
counterparty_username TEXT,
status TEXT NOT NULL DEFAULT 'open',
last_message_at TEXT,
last_synced_at TEXT NOT NULL,
last_notification_id INTEGER,
last_notification_at TEXT,
unread_count INTEGER NOT NULL DEFAULT 0,
closed_at TEXT,
metadata_json TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS negotiation_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
negotiation_hash TEXT NOT NULL,
uex_message_id INTEGER,
author TEXT,
author_username TEXT,
is_me INTEGER NOT NULL DEFAULT 0,
body TEXT NOT NULL,
sent_at TEXT,
source TEXT,
raw_json TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS negotiation_ratings (
negotiation_hash TEXT PRIMARY KEY,
deal_closed INTEGER NOT NULL,
deal_value REAL,
currency TEXT,
clarity_rating INTEGER,
speed_rating INTEGER,
respect_rating INTEGER,
fairness_rating INTEGER,
comment TEXT,
submitted_at TEXT NOT NULL,
raw_json TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS negotiation_sync_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
""" """
) )
self._ensure_column(db, "conversations", "thread_id", "TEXT") self._ensure_column(db, "conversations", "thread_id", "TEXT")
@@ -384,6 +444,24 @@ class MemoryStore:
"SELECT id, content, created_at, delivered_at FROM outbox ORDER BY id DESC LIMIT ?", "SELECT id, content, created_at, delivered_at FROM outbox ORDER BY id DESC LIMIT ?",
(limit,), (limit,),
).fetchall() ).fetchall()
negotiation_threads = db.execute(
"""
SELECT negotiation_hash, title, counterparty_username, status, unread_count, last_message_at, last_synced_at
FROM negotiation_threads
ORDER BY COALESCE(last_message_at, last_synced_at) DESC
LIMIT ?
""",
(limit,),
).fetchall()
negotiation_messages = db.execute(
"""
SELECT negotiation_hash, author_username, is_me, body, sent_at
FROM negotiation_messages
ORDER BY COALESCE(sent_at, '') DESC, id DESC
LIMIT ?
""",
(limit,),
).fetchall()
profile = [] profile = []
for row in profile_rows: for row in profile_rows:
@@ -402,6 +480,8 @@ class MemoryStore:
"profile": profile, "profile": profile,
"scheduled_jobs": [dict(row) for row in jobs], "scheduled_jobs": [dict(row) for row in jobs],
"outbox": [dict(row) for row in outbox], "outbox": [dict(row) for row in outbox],
"negotiation_threads": [dict(row) for row in negotiation_threads],
"negotiation_messages": [dict(row) for row in negotiation_messages],
} }
def clear( def clear(
@@ -425,6 +505,10 @@ class MemoryStore:
deleted["scheduled_jobs"] = db.execute("DELETE FROM scheduled_jobs").rowcount deleted["scheduled_jobs"] = db.execute("DELETE FROM scheduled_jobs").rowcount
if include_outbox: if include_outbox:
deleted["outbox"] = db.execute("DELETE FROM outbox").rowcount deleted["outbox"] = db.execute("DELETE FROM outbox").rowcount
deleted["negotiation_threads"] = db.execute("DELETE FROM negotiation_threads").rowcount
deleted["negotiation_messages"] = db.execute("DELETE FROM negotiation_messages").rowcount
deleted["negotiation_ratings"] = db.execute("DELETE FROM negotiation_ratings").rowcount
deleted["negotiation_sync_state"] = db.execute("DELETE FROM negotiation_sync_state").rowcount
return deleted return deleted
def set_profile(self, key: str, value: Any) -> None: def set_profile(self, key: str, value: Any) -> None:
@@ -555,3 +639,448 @@ class MemoryStore:
except json.JSONDecodeError: except json.JSONDecodeError:
data["metadata"] = {} data["metadata"] = {}
return data return data
def set_negotiation_sync_state(self, key: str, value: Any) -> None:
with self._connect() as db:
db.execute(
"""
INSERT INTO negotiation_sync_state(key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
""",
(key, json.dumps(value), iso_now()),
)
def get_negotiation_sync_state(self, key: str, default: Any = None) -> Any:
with self._connect() as db:
row = db.execute(
"SELECT value FROM negotiation_sync_state WHERE key = ?",
(key,),
).fetchone()
if not row:
return default
try:
return json.loads(row["value"])
except json.JSONDecodeError:
return default
def upsert_negotiation(
self,
negotiation_hash: str,
*,
uex_negotiation_id: int | None = None,
listing_id: int | None = None,
listing_slug: str | None = None,
title: str | None = None,
counterparty_username: str | None = None,
status: str = "open",
last_message_at: str | None = None,
last_synced_at: str | None = None,
last_notification_id: int | None = None,
last_notification_at: str | None = None,
unread_count: int | None = None,
closed_at: str | None = None,
metadata: dict[str, Any] | None = None,
) -> None:
if not negotiation_hash.strip():
return
now = last_synced_at or iso_now()
with self._connect() as db:
existing = db.execute(
"""
SELECT unread_count, metadata_json
FROM negotiation_threads
WHERE negotiation_hash = ?
""",
(negotiation_hash,),
).fetchone()
current_unread = int(existing["unread_count"]) if existing else 0
merged_metadata = {}
if existing:
try:
merged_metadata = json.loads(existing["metadata_json"])
except json.JSONDecodeError:
merged_metadata = {}
if metadata:
merged_metadata.update(metadata)
db.execute(
"""
INSERT INTO negotiation_threads(
negotiation_hash,
uex_negotiation_id,
listing_id,
listing_slug,
title,
counterparty_username,
status,
last_message_at,
last_synced_at,
last_notification_id,
last_notification_at,
unread_count,
closed_at,
metadata_json
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(negotiation_hash) DO UPDATE SET
uex_negotiation_id = COALESCE(excluded.uex_negotiation_id, negotiation_threads.uex_negotiation_id),
listing_id = COALESCE(excluded.listing_id, negotiation_threads.listing_id),
listing_slug = COALESCE(excluded.listing_slug, negotiation_threads.listing_slug),
title = COALESCE(excluded.title, negotiation_threads.title),
counterparty_username = COALESCE(excluded.counterparty_username, negotiation_threads.counterparty_username),
status = COALESCE(excluded.status, negotiation_threads.status),
last_message_at = COALESCE(excluded.last_message_at, negotiation_threads.last_message_at),
last_synced_at = excluded.last_synced_at,
last_notification_id = COALESCE(excluded.last_notification_id, negotiation_threads.last_notification_id),
last_notification_at = COALESCE(excluded.last_notification_at, negotiation_threads.last_notification_at),
unread_count = COALESCE(excluded.unread_count, negotiation_threads.unread_count),
closed_at = COALESCE(excluded.closed_at, negotiation_threads.closed_at),
metadata_json = excluded.metadata_json
""",
(
negotiation_hash.strip(),
uex_negotiation_id,
listing_id,
listing_slug,
title,
counterparty_username,
status or "open",
last_message_at,
now,
last_notification_id,
last_notification_at,
current_unread if unread_count is None else max(0, int(unread_count)),
closed_at,
json.dumps(merged_metadata),
),
)
def replace_negotiation_messages(
self,
negotiation_hash: str,
messages: list[dict[str, Any]],
*,
mark_read: bool = False,
) -> None:
if not negotiation_hash.strip():
return
normalized = [self._normalize_negotiation_message(negotiation_hash, item) for item in messages]
normalized = [item for item in normalized if item]
with self._connect() as db:
db.execute("DELETE FROM negotiation_messages WHERE negotiation_hash = ?", (negotiation_hash,))
for item in normalized:
db.execute(
"""
INSERT INTO negotiation_messages(
negotiation_hash,
uex_message_id,
author,
author_username,
is_me,
body,
sent_at,
source,
raw_json
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
negotiation_hash,
item["uex_message_id"],
item["author"],
item["author_username"],
1 if item["is_me"] else 0,
item["body"],
item["sent_at"],
item["source"],
json.dumps(item["raw_json"]),
),
)
last_message_at = normalized[-1]["sent_at"] if normalized else None
db.execute(
"""
UPDATE negotiation_threads
SET last_message_at = COALESCE(?, last_message_at),
last_synced_at = ?,
unread_count = CASE WHEN ? THEN 0 ELSE unread_count END
WHERE negotiation_hash = ?
""",
(last_message_at, iso_now(), 1 if mark_read else 0, negotiation_hash),
)
def mark_negotiation_notified(
self,
negotiation_hash: str,
*,
notification_id: int | None = None,
notification_at: str | None = None,
) -> None:
with self._connect() as db:
db.execute(
"""
UPDATE negotiation_threads
SET unread_count = unread_count + 1,
last_notification_id = COALESCE(?, last_notification_id),
last_notification_at = COALESCE(?, last_notification_at)
WHERE negotiation_hash = ?
""",
(notification_id, notification_at, negotiation_hash),
)
def mark_negotiation_read(self, negotiation_hash: str) -> None:
with self._connect() as db:
db.execute(
"UPDATE negotiation_threads SET unread_count = 0 WHERE negotiation_hash = ?",
(negotiation_hash,),
)
def store_negotiation_rating(self, negotiation_hash: str, payload: dict[str, Any], raw_json: dict[str, Any] | None = None) -> None:
now = iso_now()
with self._connect() as db:
db.execute(
"""
INSERT INTO negotiation_ratings(
negotiation_hash,
deal_closed,
deal_value,
currency,
clarity_rating,
speed_rating,
respect_rating,
fairness_rating,
comment,
submitted_at,
raw_json
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(negotiation_hash) DO UPDATE SET
deal_closed = excluded.deal_closed,
deal_value = excluded.deal_value,
currency = excluded.currency,
clarity_rating = excluded.clarity_rating,
speed_rating = excluded.speed_rating,
respect_rating = excluded.respect_rating,
fairness_rating = excluded.fairness_rating,
comment = excluded.comment,
submitted_at = excluded.submitted_at,
raw_json = excluded.raw_json
""",
(
negotiation_hash,
1 if payload.get("deal_closed") else 0,
payload.get("deal_value"),
payload.get("currency"),
payload.get("clarity_rating"),
payload.get("speed_rating"),
payload.get("respect_rating"),
payload.get("fairness_rating"),
payload.get("comment"),
now,
json.dumps(raw_json or payload),
),
)
def list_negotiations(
self,
*,
status: str = "all",
unread_only: bool = False,
search: str = "",
limit: int = 50,
) -> list[dict[str, Any]]:
status_filter = str(status or "all").strip().casefold()
search_filter = f"%{search.strip().casefold()}%" if search.strip() else None
clauses = []
params: list[Any] = []
if status_filter not in {"", "all"}:
clauses.append("status = ?")
params.append(status_filter)
if unread_only:
clauses.append("unread_count > 0")
if search_filter:
clauses.append(
"""
(
lower(COALESCE(title, '')) LIKE ?
OR lower(COALESCE(counterparty_username, '')) LIKE ?
OR lower(COALESCE(listing_slug, '')) LIKE ?
)
"""
)
params.extend([search_filter, search_filter, search_filter])
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
with self._connect() as db:
rows = db.execute(
f"""
SELECT
negotiation_hash,
uex_negotiation_id,
listing_id,
listing_slug,
title,
counterparty_username,
status,
last_message_at,
last_synced_at,
last_notification_id,
last_notification_at,
unread_count,
closed_at,
metadata_json
FROM negotiation_threads
{where}
ORDER BY
unread_count DESC,
COALESCE(last_message_at, last_notification_at, last_synced_at) DESC
LIMIT ?
""",
(*params, max(1, min(limit, 500))),
).fetchall()
return [self._negotiation_thread_row(row) for row in rows]
def get_negotiation(self, negotiation_hash: str) -> dict[str, Any] | None:
with self._connect() as db:
thread = db.execute(
"""
SELECT
negotiation_hash,
uex_negotiation_id,
listing_id,
listing_slug,
title,
counterparty_username,
status,
last_message_at,
last_synced_at,
last_notification_id,
last_notification_at,
unread_count,
closed_at,
metadata_json
FROM negotiation_threads
WHERE negotiation_hash = ?
""",
(negotiation_hash,),
).fetchone()
if not thread:
return None
messages = db.execute(
"""
SELECT
uex_message_id,
author,
author_username,
is_me,
body,
sent_at,
source,
raw_json
FROM negotiation_messages
WHERE negotiation_hash = ?
ORDER BY COALESCE(sent_at, '') ASC, id ASC
""",
(negotiation_hash,),
).fetchall()
rating = db.execute(
"""
SELECT
deal_closed,
deal_value,
currency,
clarity_rating,
speed_rating,
respect_rating,
fairness_rating,
comment,
submitted_at,
raw_json
FROM negotiation_ratings
WHERE negotiation_hash = ?
""",
(negotiation_hash,),
).fetchone()
result = self._negotiation_thread_row(thread)
result["messages"] = [self._negotiation_message_row(row) for row in messages]
result["rating"] = self._negotiation_rating_row(rating) if rating else None
return result
def search_negotiation_messages(self, query: str, limit: int = 8) -> list[dict[str, Any]]:
q = query.strip()
if not q:
return []
with self._connect() as db:
rows = db.execute(
"""
SELECT
m.negotiation_hash,
t.title,
t.counterparty_username,
m.author_username,
m.is_me,
m.body,
m.sent_at
FROM negotiation_messages m
JOIN negotiation_threads t ON t.negotiation_hash = m.negotiation_hash
WHERE lower(m.body) LIKE ?
ORDER BY COALESCE(m.sent_at, '') DESC, m.id DESC
LIMIT ?
""",
(f"%{q.casefold()}%", max(1, min(limit, 50))),
).fetchall()
return [dict(row) for row in rows]
@staticmethod
def _normalize_negotiation_message(negotiation_hash: str, item: dict[str, Any]) -> dict[str, Any] | None:
if not isinstance(item, dict):
return None
body = str(item.get("body") or item.get("message") or item.get("content") or item.get("text") or item.get("event") or "").strip()
if not body:
return None
return {
"negotiation_hash": negotiation_hash,
"uex_message_id": MemoryStore._int_or_none(item.get("id") or item.get("id_message")),
"author": str(item.get("user_name") or item.get("author") or item.get("sender") or item.get("user_username") or "UEX"),
"author_username": str(item.get("user_username") or item.get("author_username") or item.get("username") or "").strip() or None,
"is_me": bool(item.get("is_me")),
"body": body,
"sent_at": unix_to_iso(item.get("date_added")) or str(item.get("sent_at") or "").strip() or None,
"source": str(item.get("api_name") or item.get("source") or "uex"),
"raw_json": item,
}
@staticmethod
def _negotiation_thread_row(row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]:
data = dict(row)
try:
data["metadata"] = json.loads(data.pop("metadata_json"))
except (KeyError, json.JSONDecodeError):
data["metadata"] = {}
data["hash"] = data.pop("negotiation_hash")
return data
@staticmethod
def _negotiation_message_row(row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]:
data = dict(row)
try:
data["raw_json"] = json.loads(data["raw_json"])
except (KeyError, json.JSONDecodeError):
data["raw_json"] = {}
data["is_me"] = bool(data.get("is_me"))
return data
@staticmethod
def _negotiation_rating_row(row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]:
data = dict(row)
try:
data["raw_json"] = json.loads(data["raw_json"])
except (KeyError, json.JSONDecodeError):
data["raw_json"] = {}
data["deal_closed"] = bool(data.get("deal_closed"))
return data
@staticmethod
def _int_or_none(value: Any) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
+248
View File
@@ -0,0 +1,248 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from urllib.parse import urlparse
from traderai.memory import MemoryStore, iso_now, unix_to_iso
from traderai.uex_client import UEXClient
UEX_NEGOTIATION_CLOSE_ENDPOINT = "marketplace_negotiations_close"
def extract_negotiation_hash(redir: str | None) -> str | None:
if not redir:
return None
parsed = urlparse(redir)
path = parsed.path or str(redir)
cleaned = path.strip("/")
parts = cleaned.split("/")
for index, part in enumerate(parts):
if part == "hash" and index + 1 < len(parts):
return parts[index + 1].strip() or None
if len(parts) >= 3 and parts[-3:-1] == ["marketplace", "negotiations"]:
return parts[-1].strip() or None
return None
@dataclass
class NegotiationRefreshResult:
hash: str
refreshed: bool
summary: dict[str, Any] | None = None
messages_count: int = 0
class NegotiationSyncService:
def __init__(self, memory: MemoryStore, uex: UEXClient) -> None:
self.memory = memory
self.uex = uex
self.recent_days = 30
async def startup_sync(self) -> dict[str, Any]:
return await self.refresh_negotiations(seed_open_messages=True)
async def refresh_negotiations(self, *, seed_open_messages: bool = False) -> dict[str, Any]:
response = await self.uex.list_negotiations()
negotiations = response.get("negotiations") or response.get("data") or []
kept_hashes: list[str] = []
refreshed = 0
for item in negotiations:
normalized = self._normalize_negotiation_summary(item)
if not normalized:
continue
cached = self.memory.get_negotiation(normalized["negotiation_hash"])
if not self._should_keep_thread(normalized, cached):
continue
kept_hashes.append(normalized["negotiation_hash"])
self.memory.upsert_negotiation(**normalized)
if seed_open_messages and (normalized["status"] == "open" or cached is None):
result = await self.refresh_negotiation(normalized["negotiation_hash"], mark_read=False, summary=normalized)
if result.refreshed:
refreshed += 1
self.memory.set_negotiation_sync_state("last_full_negotiation_sync_at", iso_now())
return {
"count": len(kept_hashes),
"refreshed_threads": refreshed,
"negotiations": self.memory.list_negotiations(limit=200),
}
async def refresh_negotiation(
self,
negotiation_hash: str,
*,
mark_read: bool = False,
summary: dict[str, Any] | None = None,
) -> NegotiationRefreshResult:
summary_data = summary or await self._fetch_summary_by_hash(negotiation_hash)
if summary_data:
self.memory.upsert_negotiation(**summary_data)
response = await self.uex.get_negotiation_messages(hash=negotiation_hash)
messages = response.get("messages") or response.get("data") or []
normalized_messages = [self._normalize_message(item) for item in messages if isinstance(item, dict)]
normalized_messages = [item for item in normalized_messages if item]
self.memory.replace_negotiation_messages(negotiation_hash, normalized_messages, mark_read=mark_read)
if mark_read:
self.memory.mark_negotiation_read(negotiation_hash)
return NegotiationRefreshResult(
hash=negotiation_hash,
refreshed=True,
summary=self.memory.get_negotiation(negotiation_hash),
messages_count=len(normalized_messages),
)
async def handle_notifications(self, notifications: list[dict[str, Any]]) -> list[dict[str, Any]]:
if not notifications:
return []
grouped: dict[str, list[dict[str, Any]]] = {}
passthrough: list[dict[str, Any]] = []
for item in notifications:
negotiation_hash = extract_negotiation_hash(item.get("redir"))
if not negotiation_hash:
passthrough.append(item)
continue
grouped.setdefault(negotiation_hash, []).append(item)
for negotiation_hash, items in grouped.items():
latest = max(items, key=lambda item: int(item.get("date_added") or 0))
await self.refresh_negotiation(negotiation_hash, mark_read=False)
self.memory.mark_negotiation_notified(
negotiation_hash,
notification_id=self._int_or_none(latest.get("id")),
notification_at=unix_to_iso(latest.get("date_added")) or iso_now(),
)
for item in passthrough:
self.memory.add_outbox(self._notification_text(item))
self.memory.set_negotiation_sync_state("last_notification_sync_at", iso_now())
self.memory.set_negotiation_sync_state(
"last_seen_notification_ids",
sorted(self._int_or_none(item.get("id")) for item in notifications if self._int_or_none(item.get("id")) is not None),
)
return notifications
async def manual_send_message(self, negotiation_hash: str, message: str) -> dict[str, Any]:
result = await self.uex.send_negotiation_message(hash=negotiation_hash, message=message, is_production=1)
await self.refresh_negotiation(negotiation_hash, mark_read=True)
return result
async def manual_close_negotiation(self, negotiation_hash: str, payload: dict[str, Any]) -> dict[str, Any]:
result = await self.uex.close_negotiation(hash=negotiation_hash, **payload)
await self.refresh_negotiation(negotiation_hash, mark_read=True)
self.memory.store_negotiation_rating(negotiation_hash, payload, raw_json=result)
return result
def list_negotiations(self, *, status: str = "all", unread_only: bool = False, search: str = "", limit: int = 50) -> list[dict[str, Any]]:
return self.memory.list_negotiations(status=status, unread_only=unread_only, search=search, limit=limit)
def unread_count(self) -> int:
return sum(int(item.get("unread_count") or 0) for item in self.memory.list_negotiations(unread_only=True, limit=500))
def get_negotiation(self, negotiation_hash: str, *, mark_read: bool = True) -> dict[str, Any] | None:
negotiation = self.memory.get_negotiation(negotiation_hash)
if negotiation and mark_read:
self.memory.mark_negotiation_read(negotiation_hash)
negotiation["unread_count"] = 0
return negotiation
def search_messages(self, query: str, limit: int = 8) -> list[dict[str, Any]]:
return self.memory.search_negotiation_messages(query, limit=limit)
async def _fetch_summary_by_hash(self, negotiation_hash: str) -> dict[str, Any] | None:
response = await self.uex.list_negotiations(hash=negotiation_hash)
negotiations = response.get("negotiations") or response.get("data") or []
for item in negotiations:
normalized = self._normalize_negotiation_summary(item)
if normalized and normalized["negotiation_hash"] == negotiation_hash:
return normalized
return None
def _normalize_negotiation_summary(self, item: dict[str, Any]) -> dict[str, Any] | None:
negotiation_hash = str(item.get("hash") or item.get("negotiation_hash") or "").strip()
if not negotiation_hash:
return None
user = self.memory.get_profile().get("uex_user") or {}
current_username = str(user.get("username") or user.get("user_username") or "").strip().casefold()
advertiser_username = str(item.get("advertiser_username") or "").strip()
client_username = str(item.get("client_username") or "").strip()
is_listing_advertiser = bool(item.get("is_listing_advertiser"))
if current_username:
if advertiser_username.casefold() == current_username:
counterparty = client_username
elif client_username.casefold() == current_username:
counterparty = advertiser_username
else:
counterparty = client_username if is_listing_advertiser else advertiser_username
else:
counterparty = client_username if is_listing_advertiser else advertiser_username
closed_at = unix_to_iso(item.get("date_closed") or item.get("date_closed_client"))
metadata = {
"advertiser_name": item.get("advertiser_name"),
"advertiser_username": advertiser_username or None,
"client_name": item.get("client_name"),
"client_username": client_username or None,
"deal_value": item.get("deal_value"),
"deal_value_currency": item.get("deal_value_currency"),
"price": item.get("price"),
"unit": item.get("unit"),
"currency": item.get("currency"),
"raw": item,
}
return {
"negotiation_hash": negotiation_hash,
"uex_negotiation_id": self._int_or_none(item.get("id") or item.get("id_negotiation")),
"listing_id": self._int_or_none(item.get("id_listing")),
"listing_slug": str(item.get("listing_slug") or "").strip() or None,
"title": str(item.get("listing_title") or item.get("title") or "").strip() or None,
"counterparty_username": counterparty or None,
"status": "closed" if closed_at else "open",
"last_message_at": unix_to_iso(item.get("date_modified") or item.get("date_added")),
"last_synced_at": iso_now(),
"closed_at": closed_at,
"metadata": metadata,
}
def _normalize_message(self, item: dict[str, Any]) -> dict[str, Any] | None:
negotiation_hash = str(item.get("negotiation_hash") or "").strip()
if not negotiation_hash:
return None
user = self.memory.get_profile().get("uex_user") or {}
current_username = str(user.get("username") or user.get("user_username") or "").strip().casefold()
username = str(item.get("user_username") or "").strip()
normalized = dict(item)
normalized["is_me"] = bool(current_username and username.casefold() == current_username)
normalized["author"] = item.get("user_name") or username or "UEX"
normalized["source"] = item.get("api_name") or "uex"
normalized["body"] = item.get("message") or item.get("event") or ""
return normalized
def _should_keep_thread(self, normalized: dict[str, Any], cached: dict[str, Any] | None) -> bool:
if cached:
return True
if normalized["status"] == "open":
return True
last_message_at = normalized.get("last_message_at")
if not last_message_at:
return False
try:
age_seconds = max(0.0, (datetime.now(timezone.utc) - datetime.fromisoformat(last_message_at)).total_seconds())
except ValueError:
return False
return age_seconds <= self.recent_days * 24 * 60 * 60
@staticmethod
def _notification_text(item: dict[str, Any]) -> str:
message = item.get("message") or "You have a pending UEX notification."
redir = item.get("redir")
return f"UEX notification: {message}" + (f" (path `{redir}`)" if redir else "")
@staticmethod
def _int_or_none(value: Any) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
+29 -5
View File
@@ -232,6 +232,17 @@ class ContinualPlanStore:
self.add_event(plan_id, status, f"Plan status changed to {status}.") self.add_event(plan_id, status, f"Plan status changed to {status}.")
return self.get_plan(plan_id) return self.get_plan(plan_id)
def delete_plan(self, plan_id: str) -> bool:
with self.memory._connect() as db:
deleted = db.execute("DELETE FROM continual_plans WHERE id = ?", (plan_id,)).rowcount
if not deleted:
return False
db.execute("DELETE FROM continual_plan_items WHERE plan_id = ?", (plan_id,))
db.execute("DELETE FROM continual_plan_candidates WHERE plan_id = ?", (plan_id,))
db.execute("DELETE FROM continual_plan_events WHERE plan_id = ?", (plan_id,))
db.execute("DELETE FROM continual_plan_negotiations WHERE plan_id = ?", (plan_id,))
return True
def add_event(self, plan_id: str, kind: str, message: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]: def add_event(self, plan_id: str, kind: str, message: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
now = iso_now() now = iso_now()
with self.memory._connect() as db: with self.memory._connect() as db:
@@ -517,12 +528,13 @@ class ContinualPlanRunner:
async def _draft_buying_message(self, plan: dict[str, Any], item: dict[str, Any], candidate: dict[str, Any]) -> dict[str, Any]: async def _draft_buying_message(self, plan: dict[str, Any], item: dict[str, Any], candidate: dict[str, Any]) -> dict[str, Any]:
tone = (plan.get("constraints") or {}).get("message_tone") or "polite and concise" tone = (plan.get("constraints") or {}).get("message_tone") or "polite and concise"
greeting = "Hi" if "professional" in str(tone).casefold() or "polite" in str(tone).casefold() else "Hey"
build_context = self._plan_build_context(plan["objective"])
message = ( message = (
f"Hi, I am interested in your {candidate.get('title') or item['item_name']} listing " f"{greeting}, is your {candidate.get('title') or item['item_name']} listing still available "
f"for {self._format_price(candidate.get('price'), candidate.get('currency'))}. " f"for {self._format_price(candidate.get('price'), candidate.get('currency'))}? "
f"Is it still available? I am trying to complete: {plan['objective']}. " f"{build_context}If you still have it, I can move quickly."
f"Tone note: {tone}." ).strip()
)
return await self.tools.draft_negotiation_message( return await self.tools.draft_negotiation_message(
message=message, message=message,
id_listing=self._int_or_none(candidate.get("listing_id")), id_listing=self._int_or_none(candidate.get("listing_id")),
@@ -532,6 +544,18 @@ class ContinualPlanRunner:
listing_slug=candidate.get("listing_slug"), listing_slug=candidate.get("listing_slug"),
) )
@staticmethod
def _plan_build_context(objective: str) -> str:
text = str(objective or "").strip().rstrip(".")
if not text:
return ""
lowered = text.casefold()
if "polaris" in lowered:
return "I'm putting together parts for a Polaris build. "
if "mission" in lowered:
return "I'm trying to wrap up a mission build. "
return "I'm sourcing parts for a build. "
@staticmethod @staticmethod
def _candidate_score(listing: dict[str, Any], item: dict[str, Any], preferred_locations: list[str]) -> float: def _candidate_score(listing: dict[str, Any], item: dict[str, Any], preferred_locations: list[str]) -> float:
price = float(listing.get("price") or 10**12) price = float(listing.get("price") or 10**12)
+7
View File
@@ -23,6 +23,7 @@ class WakeScheduler:
self.agent = None self.agent = None
self.uex = None self.uex = None
self.plan_runner = None self.plan_runner = None
self.negotiation_sync = None
self.notification_poll_seconds = 60 self.notification_poll_seconds = 60
def bind_agent(self, agent: Any) -> None: def bind_agent(self, agent: Any) -> None:
@@ -31,6 +32,9 @@ class WakeScheduler:
def bind_plan_runner(self, plan_runner: Any) -> None: def bind_plan_runner(self, plan_runner: Any) -> None:
self.plan_runner = plan_runner self.plan_runner = plan_runner
def bind_negotiation_sync(self, negotiation_sync: Any) -> None:
self.negotiation_sync = negotiation_sync
def bind_uex_notifications(self, uex: Any, poll_seconds: int = 60) -> None: def bind_uex_notifications(self, uex: Any, poll_seconds: int = 60) -> None:
self.uex = uex self.uex = uex
self.notification_poll_seconds = max(15, poll_seconds) self.notification_poll_seconds = max(15, poll_seconds)
@@ -197,6 +201,9 @@ class WakeScheduler:
new_pending = [item for item in pending if self._notification_key(item) not in seen] new_pending = [item for item in pending if self._notification_key(item) not in seen]
if new_pending: if new_pending:
if self.negotiation_sync is not None:
await self.negotiation_sync.handle_notifications(new_pending)
else:
for item in new_pending: for item in new_pending:
self.memory.add_outbox(self._notification_text(item)) self.memory.add_outbox(self._notification_text(item))
seen.update(self._notification_key(item) for item in new_pending) seen.update(self._notification_key(item) for item in new_pending)
+771 -22
View File
File diff suppressed because it is too large Load Diff
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
from typing import Any
from urllib.parse import quote
import httpx
class StarCitizenWikiError(RuntimeError):
pass
class StarCitizenWikiClient:
def __init__(
self,
base_url: str = "https://starcitizen.tools",
api_base_url: str = "https://api.star-citizen.wiki",
) -> None:
self.base_url = base_url.rstrip("/")
self.api_base_url = api_base_url.rstrip("/")
async def search_pages(self, query: str, limit: int = 5) -> list[dict[str, Any]]:
body = await self._get_json(
f"{self.base_url}/api.php",
params={
"action": "query",
"generator": "prefixsearch",
"gpssearch": query,
"gpslimit": max(1, min(limit, 10)),
"prop": "description|pageimages|extracts",
"exintro": 1,
"explaintext": 1,
"exchars": 320,
"piprop": "thumbnail",
"pithumbsize": 240,
"format": "json",
},
)
pages = body.get("query", {}).get("pages", {})
ordered = sorted(
(item for item in pages.values() if isinstance(item, dict)),
key=lambda item: int(item.get("index") or 0),
)
return [
{
"pageid": item.get("pageid"),
"title": item.get("title"),
"description": item.get("description"),
"extract": item.get("extract"),
"thumbnail": (item.get("thumbnail") or {}).get("source"),
"url": f"{self.base_url}/{quote(str(item.get('title') or '').replace(' ', '_'), safe=':/_')}",
}
for item in ordered
if item.get("title")
]
async def get_page_summary(self, title: str | None = None, pageid: int | None = None, chars: int = 700) -> dict[str, Any] | None:
params: dict[str, Any] = {
"action": "query",
"prop": "extracts|description|pageimages",
"exintro": 1,
"explaintext": 1,
"exchars": max(120, min(chars, 1200)),
"piprop": "thumbnail",
"pithumbsize": 320,
"format": "json",
}
if pageid is not None:
params["pageids"] = pageid
elif title:
params["titles"] = title
else:
raise StarCitizenWikiError("title or pageid is required")
body = await self._get_json(f"{self.base_url}/api.php", params=params)
pages = body.get("query", {}).get("pages", {})
for item in pages.values():
if isinstance(item, dict) and item.get("pageid") and item.get("title"):
return {
"pageid": item.get("pageid"),
"title": item.get("title"),
"description": item.get("description"),
"extract": item.get("extract"),
"thumbnail": (item.get("thumbnail") or {}).get("source"),
"url": f"{self.base_url}/{quote(str(item.get('title') or '').replace(' ', '_'), safe=':/_')}",
}
return None
async def search_verse(self, query: str) -> list[dict[str, Any]]:
body = await self._get_json(
f"{self.api_base_url}/api/search",
params={"filter[query]": query},
)
data = body.get("data")
return data if isinstance(data, list) else []
async def get_vehicle(self, slug: str) -> dict[str, Any]:
body = await self._get_json(f"{self.api_base_url}/api/vehicles/{slug.strip('/')}")
data = body.get("data")
if not isinstance(data, dict):
raise StarCitizenWikiError(f"Vehicle response for {slug} was not an object.")
return data
async def _get_json(self, url: str, params: dict[str, Any] | None = None) -> Any:
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
response = await client.get(url, params=params, headers={"Accept": "application/json"})
try:
body = response.json()
except ValueError as exc:
raise StarCitizenWikiError(f"Star Citizen Wiki returned non-JSON response: HTTP {response.status_code}") from exc
if response.status_code >= 400:
raise StarCitizenWikiError(f"Star Citizen Wiki HTTP {response.status_code}: {body}")
return body
+680 -7
View File
@@ -1,14 +1,19 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Awaitable, Callable from typing import Any, Awaitable, Callable
from traderai.cornerstone_client import CornerstoneClient, parse_cornerstone_item_page from traderai.cornerstone_client import CornerstoneClient, parse_cornerstone_item_page
from traderai.memory import MemoryStore from traderai.memory import MemoryStore
from traderai.negotiations import UEX_NEGOTIATION_CLOSE_ENDPOINT
from traderai.scheduler import WakeScheduler from traderai.scheduler import WakeScheduler
from traderai.scmdb_client import SCMDBClient from traderai.scmdb_client import SCMDBClient
from traderai.starcitizen_wiki_client import StarCitizenWikiClient
from traderai.uex_client import UEXClient from traderai.uex_client import UEXClient
from traderai.wikelo_projects_client import WikeloProjectsClient
ToolHandler = Callable[..., Awaitable[dict[str, Any]]] ToolHandler = Callable[..., Awaitable[dict[str, Any]]]
@@ -56,10 +61,14 @@ UEX_GET_RESOURCES: dict[str, dict[str, Any]] = {
"marketplace_averages": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "marketplace"}, "marketplace_averages": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "marketplace"},
"marketplace_averages_all": {"params": [], "auth": False, "group": "marketplace", "heavy": True}, "marketplace_averages_all": {"params": [], "auth": False, "group": "marketplace", "heavy": True},
"marketplace_favorites": {"params": ["id_listing"], "auth": True, "group": "marketplace"}, "marketplace_favorites": {"params": ["id_listing"], "auth": True, "group": "marketplace"},
"marketplace_listings": {"params": ["id", "slug", "username"], "auth": False, "group": "marketplace"}, "marketplace_listings": {"params": ["id", "slug", "username", "id_item", "operation"], "auth": False, "group": "marketplace"},
"marketplace_negotiations": {"params": ["id", "id_listing", "hash"], "auth": True, "group": "marketplace"}, "marketplace_negotiations": {"params": ["id", "id_listing", "hash"], "auth": True, "group": "marketplace"},
"marketplace_negotiations_messages": {"params": ["hash", "id_negotiation"], "auth": True, "group": "marketplace"}, "marketplace_negotiations_messages": {"params": ["hash", "id_negotiation"], "auth": True, "group": "marketplace"},
"marketplace_prices_averages": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "marketplace"}, "marketplace_prices_averages": {
"params": ["id_item", "item_name", "item_slug", "id_category", "currency", "quality_tier"],
"auth": False,
"group": "marketplace",
},
"marketplace_prices_averages_all": {"params": [], "auth": False, "group": "marketplace", "heavy": True}, "marketplace_prices_averages_all": {"params": [], "auth": False, "group": "marketplace", "heavy": True},
"marketplace_prices_history": { "marketplace_prices_history": {
"params": [ "params": [
@@ -81,7 +90,11 @@ UEX_GET_RESOURCES: dict[str, dict[str, Any]] = {
"group": "marketplace", "group": "marketplace",
"history": True, "history": True,
}, },
"marketplace_trends": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "marketplace"}, "marketplace_trends": {
"params": ["id_item", "item_name", "item_slug", "id_category", "currency", "quality_tier"],
"auth": False,
"group": "marketplace",
},
"moons": {"params": ["id", "id_planet", "id_star_system", "name", "slug"], "auth": False, "group": "locations"}, "moons": {"params": ["id", "id_planet", "id_star_system", "name", "slug"], "auth": False, "group": "locations"},
"orbits": {"params": ["id", "id_star_system", "name", "slug"], "auth": False, "group": "locations"}, "orbits": {"params": ["id", "id_star_system", "name", "slug"], "auth": False, "group": "locations"},
"orbits_distances": {"params": ["id_origin", "id_destination"], "auth": False, "group": "locations"}, "orbits_distances": {"params": ["id_origin", "id_destination"], "auth": False, "group": "locations"},
@@ -138,6 +151,7 @@ UEX_RESOURCE_DESCRIPTIONS = {
UEX_PRODUCTION_WRITE_RESOURCES = { UEX_PRODUCTION_WRITE_RESOURCES = {
"marketplace_advertise", "marketplace_advertise",
"marketplace_negotiations_messages", "marketplace_negotiations_messages",
UEX_NEGOTIATION_CLOSE_ENDPOINT,
} }
@@ -160,24 +174,37 @@ class ToolRegistry:
scheduler: WakeScheduler | None = None, scheduler: WakeScheduler | None = None,
scmdb: SCMDBClient | None = None, scmdb: SCMDBClient | None = None,
cornerstone: CornerstoneClient | None = None, cornerstone: CornerstoneClient | None = None,
scwiki: StarCitizenWikiClient | None = None,
wikelo: WikeloProjectsClient | None = None,
plan_store: Any | None = None, plan_store: Any | None = None,
plan_runner: Any | None = None, plan_runner: Any | None = None,
negotiation_sync: Any | None = None,
) -> None: ) -> None:
self.uex = uex self.uex = uex
self.scmdb = scmdb or SCMDBClient() self.scmdb = scmdb or SCMDBClient()
self.cornerstone = cornerstone or CornerstoneClient() self.cornerstone = cornerstone or CornerstoneClient()
self.scwiki = scwiki or StarCitizenWikiClient()
self.wikelo = wikelo or WikeloProjectsClient()
self.require_write_approval = require_write_approval self.require_write_approval = require_write_approval
self.memory = memory self.memory = memory
self.scheduler = scheduler self.scheduler = scheduler
self.plan_store = plan_store self.plan_store = plan_store
self.plan_runner = plan_runner self.plan_runner = plan_runner
self.negotiation_sync = negotiation_sync
self.pending_actions: dict[str, PendingAction] = {} self.pending_actions: dict[str, PendingAction] = {}
self._chat_images_var: ContextVar[list[dict[str, Any]]] = ContextVar("chat_images", default=[])
self.handlers: dict[str, ToolHandler] = { self.handlers: dict[str, ToolHandler] = {
"search_marketplace_listings": self.search_marketplace_listings, "search_marketplace_listings": self.search_marketplace_listings,
"get_marketplace_listing": self.get_marketplace_listing, "get_marketplace_listing": self.get_marketplace_listing,
"get_marketplace_trends": self.get_marketplace_trends,
"list_marketplace_negotiations": self.list_marketplace_negotiations, "list_marketplace_negotiations": self.list_marketplace_negotiations,
"get_negotiation_messages": self.get_negotiation_messages, "get_negotiation_messages": self.get_negotiation_messages,
"draft_negotiation_message": self.draft_negotiation_message, "draft_negotiation_message": self.draft_negotiation_message,
"list_local_negotiations": self.list_local_negotiations,
"get_local_negotiation": self.get_local_negotiation,
"search_local_negotiation_messages": self.search_local_negotiation_messages,
"draft_negotiation_close": self.draft_negotiation_close,
"draft_negotiation_rating": self.draft_negotiation_rating,
"draft_marketplace_listing": self.draft_marketplace_listing, "draft_marketplace_listing": self.draft_marketplace_listing,
"remember_user_fact": self.remember_user_fact, "remember_user_fact": self.remember_user_fact,
"recall_memory": self.recall_memory, "recall_memory": self.recall_memory,
@@ -189,11 +216,18 @@ class ToolRegistry:
"pause_continual_plan": self.pause_continual_plan, "pause_continual_plan": self.pause_continual_plan,
"resume_continual_plan": self.resume_continual_plan, "resume_continual_plan": self.resume_continual_plan,
"cancel_continual_plan": self.cancel_continual_plan, "cancel_continual_plan": self.cancel_continual_plan,
"delete_continual_plan": self.delete_continual_plan,
"run_continual_plan_now": self.run_continual_plan_now, "run_continual_plan_now": self.run_continual_plan_now,
"check_uex_notifications": self.check_uex_notifications, "check_uex_notifications": self.check_uex_notifications,
"list_scmdb_versions": self.list_scmdb_versions, "list_scmdb_versions": self.list_scmdb_versions,
"search_scmdb_missions": self.search_scmdb_missions, "search_scmdb_missions": self.search_scmdb_missions,
"get_scmdb_mission_rewards": self.get_scmdb_mission_rewards, "get_scmdb_mission_rewards": self.get_scmdb_mission_rewards,
"search_scwiki_pages": self.search_scwiki_pages,
"get_scwiki_page": self.get_scwiki_page,
"search_scwiki_vehicles": self.search_scwiki_vehicles,
"get_scwiki_vehicle": self.get_scwiki_vehicle,
"search_wikelo_ship_projects": self.search_wikelo_ship_projects,
"get_wikelo_ship_project": self.get_wikelo_ship_project,
"search_cornerstone_items": self.search_cornerstone_items, "search_cornerstone_items": self.search_cornerstone_items,
"get_cornerstone_item_locations": self.get_cornerstone_item_locations, "get_cornerstone_item_locations": self.get_cornerstone_item_locations,
"get_cornerstone_item_media": self.get_cornerstone_item_media, "get_cornerstone_item_media": self.get_cornerstone_item_media,
@@ -223,6 +257,8 @@ class ToolRegistry:
*self._uex_post_schemas(), *self._uex_post_schemas(),
*self._uex_delete_schemas(), *self._uex_delete_schemas(),
*self._scmdb_schemas(), *self._scmdb_schemas(),
*self._scwiki_schemas(),
*self._wikelo_schemas(),
*self._cornerstone_schemas(), *self._cornerstone_schemas(),
{ {
"type": "function", "type": "function",
@@ -258,6 +294,24 @@ class ToolRegistry:
}, },
}, },
}, },
{
"type": "function",
"function": {
"name": "get_marketplace_trends",
"description": "Fetch current UEX marketplace trend metrics for an item, including WTS and WTB averages plus negotiation counts.",
"parameters": {
"type": "object",
"properties": {
"id_item": {"type": "integer"},
"item_name": {"type": "string"},
"item_slug": {"type": "string"},
"id_category": {"type": "integer"},
"currency": {"type": "string", "description": "Optional currency filter such as UEC, WIF, or MGS."},
"quality_tier": {"type": "integer", "minimum": 0, "maximum": 7},
},
},
},
},
{ {
"type": "function", "type": "function",
"function": { "function": {
@@ -309,6 +363,97 @@ class ToolRegistry:
}, },
}, },
}, },
{
"type": "function",
"function": {
"name": "list_local_negotiations",
"description": "List locally synced UEX negotiations with unread and status details.",
"parameters": {
"type": "object",
"properties": {
"status": {"type": "string", "enum": ["all", "open", "closed"]},
"unread_only": {"type": "boolean"},
"search": {"type": "string"},
"limit": {"type": "integer", "minimum": 1, "maximum": 50},
},
},
},
},
{
"type": "function",
"function": {
"name": "get_local_negotiation",
"description": "Get a locally synced UEX negotiation with compact metadata and recent messages.",
"parameters": {
"type": "object",
"properties": {
"hash": {"type": "string"},
},
"required": ["hash"],
},
},
},
{
"type": "function",
"function": {
"name": "search_local_negotiation_messages",
"description": "Search locally cached negotiation message text so the assistant can reference prior UEX conversations without re-fetching them.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer", "minimum": 1, "maximum": 20},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "draft_negotiation_close",
"description": "Draft closing or rating a UEX negotiation. This creates a pending action that must be approved before sending.",
"parameters": {
"type": "object",
"properties": {
"hash": {"type": "string"},
"id_negotiation": {"type": "integer"},
"deal_closed": {"type": "boolean"},
"deal_value": {"type": "number"},
"currency": {"type": "string"},
"clarity_rating": {"type": "integer", "minimum": 1, "maximum": 5},
"speed_rating": {"type": "integer", "minimum": 1, "maximum": 5},
"respect_rating": {"type": "integer", "minimum": 1, "maximum": 5},
"fairness_rating": {"type": "integer", "minimum": 1, "maximum": 5},
"comment": {"type": "string"},
},
"required": ["deal_closed"],
},
},
},
{
"type": "function",
"function": {
"name": "draft_negotiation_rating",
"description": "Alias for drafting a UEX negotiation close/rating action.",
"parameters": {
"type": "object",
"properties": {
"hash": {"type": "string"},
"id_negotiation": {"type": "integer"},
"deal_closed": {"type": "boolean"},
"deal_value": {"type": "number"},
"currency": {"type": "string"},
"clarity_rating": {"type": "integer", "minimum": 1, "maximum": 5},
"speed_rating": {"type": "integer", "minimum": 1, "maximum": 5},
"respect_rating": {"type": "integer", "minimum": 1, "maximum": 5},
"fairness_rating": {"type": "integer", "minimum": 1, "maximum": 5},
"comment": {"type": "string"},
},
"required": ["deal_closed"],
},
},
},
{ {
"type": "function", "type": "function",
"function": { "function": {
@@ -337,6 +482,15 @@ class ToolRegistry:
"durability": {"type": "integer", "minimum": 0, "maximum": 100}, "durability": {"type": "integer", "minimum": 0, "maximum": 100},
"video_url": {"type": "string"}, "video_url": {"type": "string"},
"image_data": {"type": "string", "description": "Base64 JPG or PNG image data for UEX upload."}, "image_data": {"type": "string", "description": "Base64 JPG or PNG image data for UEX upload."},
"use_attached_image": {
"type": "boolean",
"description": "When true, reuse an image pasted into the current chat as the listing image_data.",
},
"attached_image_index": {
"type": "integer",
"minimum": 0,
"description": "Zero-based pasted image index to reuse when use_attached_image is true.",
},
"hours_expiration": {"type": "integer"}, "hours_expiration": {"type": "integer"},
"is_hidden": {"type": "integer", "enum": [0, 1]}, "is_hidden": {"type": "integer", "enum": [0, 1]},
"is_tv_allowed": {"type": "integer", "enum": [0, 1]}, "is_tv_allowed": {"type": "integer", "enum": [0, 1]},
@@ -468,6 +622,14 @@ class ToolRegistry:
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}}, "parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
}, },
}, },
{
"type": "function",
"function": {
"name": "delete_continual_plan",
"description": "Delete a continual plan and all of its stored checklist items, candidates, negotiations, and event history.",
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
},
},
{ {
"type": "function", "type": "function",
"function": { "function": {
@@ -495,6 +657,14 @@ class ToolRegistry:
except Exception as exc: except Exception as exc:
return {"error": str(exc)} return {"error": str(exc)}
@contextmanager
def chat_image_scope(self, images: list[dict[str, Any]] | None):
token = self._chat_images_var.set(self._normalize_chat_images(images))
try:
yield
finally:
self._chat_images_var.reset(token)
async def approve(self, action_id: str) -> dict[str, Any]: async def approve(self, action_id: str) -> dict[str, Any]:
action = self.pending_actions.pop(action_id, None) action = self.pending_actions.pop(action_id, None)
if not action: if not action:
@@ -945,6 +1115,101 @@ class ToolRegistry:
}, },
] ]
@classmethod
def _scwiki_schemas(cls) -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": "search_scwiki_pages",
"description": "Search Star Citizen Wiki pages on starcitizen.tools and return concise summaries for general game knowledge.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Page title or topic to search for."},
"limit": {"type": "integer", "minimum": 1, "maximum": 10, "default": 5},
},
},
},
},
{
"type": "function",
"function": {
"name": "get_scwiki_page",
"description": "Fetch one Star Citizen Wiki page summary by title or page id.",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string"},
"pageid": {"type": "integer"},
"chars": {"type": "integer", "minimum": 120, "maximum": 1200, "default": 700},
},
},
},
},
{
"type": "function",
"function": {
"name": "search_scwiki_vehicles",
"description": "Search Star Citizen Wiki structured vehicle data for ships and vehicles.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Ship or vehicle name to search for."},
"limit": {"type": "integer", "minimum": 1, "maximum": 10, "default": 5},
},
},
},
},
{
"type": "function",
"function": {
"name": "get_scwiki_vehicle",
"description": "Fetch one Star Citizen Wiki vehicle summary, including MSRP and in-game purchase locations when available.",
"parameters": {
"type": "object",
"properties": {
"slug": {"type": "string", "description": "Vehicle slug such as anvl-carrack."},
"query": {"type": "string", "description": "Vehicle name if the slug is not known."},
},
},
},
},
]
@classmethod
def _wikelo_schemas(cls) -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": "search_wikelo_ship_projects",
"description": "Search Wikelo ship projects and their required materials from wikelo-projects.com. Use this when the user asks for Wikelo ship requirements or build materials.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Ship or project name to search for, such as Polaris, Idris, Zeus, or Guardian."},
"limit": {"type": "integer", "minimum": 1, "maximum": 10, "default": 5},
},
},
},
},
{
"type": "function",
"function": {
"name": "get_wikelo_ship_project",
"description": "Fetch one Wikelo ship project with its required materials and contribution progress.",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Wikelo ship project id."},
"ship_name": {"type": "string", "description": "Ship or project name if the project id is not known."},
},
},
},
},
]
@classmethod @classmethod
def _cornerstone_schemas(cls) -> list[dict[str, Any]]: def _cornerstone_schemas(cls) -> list[dict[str, Any]]:
return [ return [
@@ -1024,6 +1289,16 @@ class ToolRegistry:
"in_stock": {"type": "integer"}, "in_stock": {"type": "integer"},
"durability": {"type": "integer", "minimum": 0, "maximum": 100}, "durability": {"type": "integer", "minimum": 0, "maximum": 100},
"video_url": {"type": "string"}, "video_url": {"type": "string"},
"image_data": {"type": "string", "description": "Base64 JPG or PNG image data for UEX upload."},
"use_attached_image": {
"type": "boolean",
"description": "When true, reuse an image pasted into the current chat as the listing image_data instead of sourcing from Cornerstone.",
},
"attached_image_index": {
"type": "integer",
"minimum": 0,
"description": "Zero-based pasted image index to reuse when use_attached_image is true.",
},
"hours_expiration": {"type": "integer"}, "hours_expiration": {"type": "integer"},
"is_hidden": {"type": "integer", "enum": [0, 1]}, "is_hidden": {"type": "integer", "enum": [0, 1]},
"is_tv_allowed": {"type": "integer", "enum": [0, 1]}, "is_tv_allowed": {"type": "integer", "enum": [0, 1]},
@@ -1183,6 +1458,49 @@ class ToolRegistry:
response = await self.uex.get("marketplace_listings", {"id": id, "slug": slug}) response = await self.uex.get("marketplace_listings", {"id": id, "slug": slug})
return {"listing": response.get("data")} return {"listing": response.get("data")}
async def get_marketplace_trends(
self,
id_item: int | None = None,
item_name: str | None = None,
item_slug: str | None = None,
id_category: int | None = None,
currency: str | None = None,
quality_tier: int | None = None,
) -> dict[str, Any]:
response = await self.uex.get(
"marketplace_trends",
{
"id_item": id_item,
"item_name": item_name,
"item_slug": item_slug,
"id_category": id_category,
"currency": currency,
"quality_tier": quality_tier,
},
)
trends = [
self._summarize_marketplace_trend(item)
for item in self._as_list(response.get("data"))
if isinstance(item, dict)
]
return {
"status": response.get("status"),
"count": len(trends),
"filters": {
key: value
for key, value in {
"id_item": id_item,
"item_name": item_name,
"item_slug": item_slug,
"id_category": id_category,
"currency": currency,
"quality_tier": quality_tier,
}.items()
if value is not None
},
"trends": trends,
}
async def list_marketplace_negotiations( async def list_marketplace_negotiations(
self, self,
id: int | None = None, id: int | None = None,
@@ -1200,6 +1518,37 @@ class ToolRegistry:
async def get_negotiation_messages(self, hash: str | None = None, id_negotiation: int | None = None) -> dict[str, Any]: async def get_negotiation_messages(self, hash: str | None = None, id_negotiation: int | None = None) -> dict[str, Any]:
return await self.uex.get("marketplace_negotiations_messages", {"hash": hash, "id_negotiation": id_negotiation}, authenticated=True) return await self.uex.get("marketplace_negotiations_messages", {"hash": hash, "id_negotiation": id_negotiation}, authenticated=True)
async def list_local_negotiations(
self,
status: str = "all",
unread_only: bool = False,
search: str = "",
limit: int = 10,
) -> dict[str, Any]:
if self.negotiation_sync is None:
return {"error": "Negotiation sync is not configured."}
negotiations = self.negotiation_sync.list_negotiations(
status=status,
unread_only=unread_only,
search=search,
limit=limit,
)
return {"count": len(negotiations), "negotiations": negotiations}
async def get_local_negotiation(self, hash: str) -> dict[str, Any]:
if self.negotiation_sync is None:
return {"error": "Negotiation sync is not configured."}
negotiation = self.negotiation_sync.get_negotiation(hash, mark_read=False)
if not negotiation:
return {"error": f"Negotiation not found: {hash}"}
return {"negotiation": negotiation}
async def search_local_negotiation_messages(self, query: str, limit: int = 8) -> dict[str, Any]:
if self.negotiation_sync is None:
return {"error": "Negotiation sync is not configured."}
matches = self.negotiation_sync.search_messages(query, limit=limit)
return {"count": len(matches), "matches": matches}
async def draft_negotiation_message( async def draft_negotiation_message(
self, self,
message: str, message: str,
@@ -1225,7 +1574,50 @@ class ToolRegistry:
return self._pending("Send negotiation message", "marketplace_negotiations_messages", payload, metadata=metadata) return self._pending("Send negotiation message", "marketplace_negotiations_messages", payload, metadata=metadata)
async def draft_marketplace_listing(self, **payload: Any) -> dict[str, Any]: async def draft_marketplace_listing(self, **payload: Any) -> dict[str, Any]:
return self._pending("Post marketplace listing", "marketplace_advertise", payload) attached_image = self._attach_chat_image(payload)
if attached_image.get("error"):
return {"error": attached_image["error"]}
return self._pending(
"Post marketplace listing",
"marketplace_advertise",
payload,
metadata=attached_image.get("metadata"),
)
async def draft_negotiation_close(
self,
deal_closed: bool,
hash: str | None = None,
id_negotiation: int | None = None,
deal_value: float | None = None,
currency: str | None = None,
clarity_rating: int | None = None,
speed_rating: int | None = None,
respect_rating: int | None = None,
fairness_rating: int | None = None,
comment: str | None = None,
) -> dict[str, Any]:
payload = {
"hash": hash,
"id_negotiation": id_negotiation,
"deal_closed": 1 if deal_closed else 0,
"deal_value": deal_value,
"currency": currency,
"clarity_rating": clarity_rating,
"speed_rating": speed_rating,
"respect_rating": respect_rating,
"fairness_rating": fairness_rating,
"comment": comment,
}
metadata = {
"hash": hash,
"id_negotiation": id_negotiation,
"kind": "negotiation_close",
}
return self._pending("Close negotiation", UEX_NEGOTIATION_CLOSE_ENDPOINT, payload, metadata=metadata)
async def draft_negotiation_rating(self, **payload: Any) -> dict[str, Any]:
return await self.draft_negotiation_close(**payload)
async def draft_marketplace_listing_with_cornerstone_image( async def draft_marketplace_listing_with_cornerstone_image(
self, self,
@@ -1234,6 +1626,9 @@ class ToolRegistry:
**payload: Any, **payload: Any,
) -> dict[str, Any]: ) -> dict[str, Any]:
require_image = bool(payload.pop("require_image", False)) require_image = bool(payload.pop("require_image", False))
attached_image = self._attach_chat_image(payload)
if attached_image.get("error"):
return {"error": attached_image["error"]}
item = await self._resolve_cornerstone_item(id=cornerstone_id, query=item_query) item = await self._resolve_cornerstone_item(id=cornerstone_id, query=item_query)
if not item: if not item:
return {"error": "No Cornerstone item matched. Provide cornerstone_id or a more specific item_query."} return {"error": "No Cornerstone item matched. Provide cornerstone_id or a more specific item_query."}
@@ -1250,9 +1645,9 @@ class ToolRegistry:
except Exception as exc: except Exception as exc:
image_error = str(exc) image_error = str(exc)
if image_result: if image_result and not payload.get("image_data"):
payload["image_data"] = image_result["image_data"] payload["image_data"] = image_result["image_data"]
elif require_image: elif require_image and not payload.get("image_data"):
return { return {
"error": "Cornerstone item matched, but no usable JPG/PNG image could be sourced.", "error": "Cornerstone item matched, but no usable JPG/PNG image could be sourced.",
"cornerstone": { "cornerstone": {
@@ -1271,9 +1666,11 @@ class ToolRegistry:
"cornerstone_image_url": image_result.get("url") if image_result else None, "cornerstone_image_url": image_result.get("url") if image_result else None,
"cornerstone_image_content_type": image_result.get("content_type") if image_result else None, "cornerstone_image_content_type": image_result.get("content_type") if image_result else None,
"cornerstone_image_size_bytes": image_result.get("size_bytes") if image_result else None, "cornerstone_image_size_bytes": image_result.get("size_bytes") if image_result else None,
"cornerstone_image_status": "included" if image_result else "not_found", "cornerstone_image_status": "user_attached" if attached_image.get("metadata") else ("included" if image_result else "not_found"),
"cornerstone_image_error": image_error or None, "cornerstone_image_error": image_error or None,
} }
if attached_image.get("metadata"):
metadata.update(attached_image["metadata"])
return self._pending("Post marketplace listing with Cornerstone image", "marketplace_advertise", payload, metadata=metadata) return self._pending("Post marketplace listing with Cornerstone image", "marketplace_advertise", payload, metadata=metadata)
async def remember_user_fact(self, content: str, kind: str = "note", importance: int = 3) -> dict[str, Any]: async def remember_user_fact(self, content: str, kind: str = "note", importance: int = 3) -> dict[str, Any]:
@@ -1362,6 +1759,19 @@ class ToolRegistry:
self.scheduler.unschedule_plan(plan_id) self.scheduler.unschedule_plan(plan_id)
return {"plan": self.plan_store.set_status(plan_id, "canceled")} return {"plan": self.plan_store.set_status(plan_id, "canceled")}
async def delete_continual_plan(self, plan_id: str) -> dict[str, Any]:
if self.plan_store is None:
return {"error": "Continual plan store is not configured."}
plan = self.plan_store.get_plan(plan_id)
if not plan:
return {"error": f"Plan not found: {plan_id}"}
if self.scheduler is not None:
self.scheduler.unschedule_plan(plan_id)
deleted = self.plan_store.delete_plan(plan_id)
if not deleted:
return {"error": f"Plan not found: {plan_id}"}
return {"deleted": True, "plan_id": plan_id, "summary": f"Deleted plan {plan.get('title') or plan_id}."}
async def run_continual_plan_now(self, plan_id: str) -> dict[str, Any]: async def run_continual_plan_now(self, plan_id: str) -> dict[str, Any]:
if self.plan_runner is None: if self.plan_runner is None:
return {"error": "Continual plan runner is not configured."} return {"error": "Continual plan runner is not configured."}
@@ -1492,6 +1902,94 @@ class ToolRegistry:
"mission": self._summarize_scmdb_mission(data, mission, source=source, detailed=True), "mission": self._summarize_scmdb_mission(data, mission, source=source, detailed=True),
} }
async def search_scwiki_pages(self, query: str, limit: int = 5) -> dict[str, Any]:
pages = await self.scwiki.search_pages(query, limit=limit)
return {"source": self.scwiki.base_url, "query": query, "matched": len(pages), "pages": pages}
async def get_scwiki_page(
self,
title: str | None = None,
pageid: int | None = None,
chars: int = 700,
) -> dict[str, Any]:
page = await self.scwiki.get_page_summary(title=title, pageid=pageid, chars=chars)
if not page:
return {"error": "No Star Citizen Wiki page matched."}
return {"source": self.scwiki.base_url, "page": page}
async def search_scwiki_vehicles(self, query: str, limit: int = 5) -> dict[str, Any]:
groups = await self.scwiki.search_verse(query)
vehicles_group = next((item for item in groups if item.get("type") == "vehicles"), None)
results = [
self._summarize_scwiki_vehicle_search(item)
for item in (vehicles_group or {}).get("results", [])[: max(1, min(limit, 10))]
if isinstance(item, dict)
]
return {"source": self.scwiki.api_base_url, "query": query, "matched": len(results), "vehicles": results}
async def get_scwiki_vehicle(self, slug: str | None = None, query: str | None = None) -> dict[str, Any]:
resolved_slug = slug
if not resolved_slug:
if not query:
return {"error": "Provide slug or query."}
groups = await self.scwiki.search_verse(query)
vehicles_group = next((item for item in groups if item.get("type") == "vehicles"), None)
candidates = [
item
for item in (vehicles_group or {}).get("results", [])
if isinstance(item, dict) and item.get("api_url")
]
if not candidates:
return {"error": "No Star Citizen Wiki vehicle matched."}
resolved_slug = str(candidates[0]["api_url"]).rstrip("/").rsplit("/", 1)[-1]
vehicle = await self.scwiki.get_vehicle(resolved_slug)
return {"source": self.scwiki.api_base_url, "vehicle": self._summarize_scwiki_vehicle(vehicle)}
async def search_wikelo_ship_projects(self, query: str, limit: int = 5) -> dict[str, Any]:
projects = await self.wikelo.list_ship_projects()
q = (query or "").casefold().strip()
matches = []
for project in projects:
score = self._wikelo_ship_match_score(q, project)
if q and score <= 0:
continue
matches.append((score, project))
matches.sort(
key=lambda match: (
-match[0],
str(match[1].get("ship_name") or "").casefold(),
str(match[1].get("id") or ""),
)
)
limit = max(1, min(limit, 10))
return {
"source": f"{self.wikelo.base_url}/Ships",
"query": query,
"matched": len(matches),
"projects": [self._summarize_wikelo_ship_project(item) for _, item in matches[:limit]],
}
async def get_wikelo_ship_project(self, project_id: str | None = None, ship_name: str | None = None) -> dict[str, Any]:
projects = await self.wikelo.list_ship_projects()
if project_id:
for project in projects:
if str(project.get("id") or "").strip() == str(project_id).strip():
return {"source": f"{self.wikelo.base_url}/Ships", "project": self._summarize_wikelo_ship_project(project, detailed=True)}
return {"error": "No Wikelo ship project matched that id."}
if not ship_name:
return {"error": "Provide project_id or ship_name."}
ranked = [
(self._wikelo_ship_match_score(ship_name.casefold().strip(), project), project)
for project in projects
]
ranked = [match for match in ranked if match[0] > 0]
ranked.sort(key=lambda match: (-match[0], str(match[1].get("ship_name") or "").casefold()))
if not ranked:
return {"error": "No Wikelo ship project matched."}
return {"source": f"{self.wikelo.base_url}/Ships", "project": self._summarize_wikelo_ship_project(ranked[0][1], detailed=True)}
async def search_cornerstone_items( async def search_cornerstone_items(
self, self,
query: str = "", query: str = "",
@@ -1625,6 +2123,48 @@ class ToolRegistry:
display["image_data"] = f"<base64 image data redacted; {len(image_data)} characters>" display["image_data"] = f"<base64 image data redacted; {len(image_data)} characters>"
return display return display
def _attach_chat_image(self, payload: dict[str, Any]) -> dict[str, Any]:
attached_index = payload.pop("attached_image_index", None)
use_attached_image = bool(payload.pop("use_attached_image", False) or attached_index is not None)
if payload.get("image_data") or not use_attached_image:
return {}
image = self._chat_image(attached_index or 0)
if not image:
return {"error": "No pasted chat image is available at the requested attached_image_index."}
payload["image_data"] = image["image_data"]
return {
"metadata": {
"attached_chat_image_name": image.get("name"),
"attached_chat_image_content_type": image.get("content_type"),
"attached_chat_image_index": attached_index or 0,
"attached_chat_image_status": "included",
}
}
def _chat_image(self, index: int) -> dict[str, Any] | None:
images = self._chat_images_var.get()
if 0 <= index < len(images):
return images[index]
return None
@staticmethod
def _normalize_chat_images(images: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
normalized: list[dict[str, Any]] = []
for image in images or []:
if not isinstance(image, dict):
continue
image_data = str(image.get("image_data") or "").strip()
if not image_data:
continue
normalized.append(
{
"name": str(image.get("name") or "").strip() or "pasted-image.png",
"content_type": str(image.get("content_type") or "image/png").strip() or "image/png",
"image_data": image_data,
}
)
return normalized
@staticmethod @staticmethod
def _int_or_none(value: Any) -> int | None: def _int_or_none(value: Any) -> int | None:
try: try:
@@ -2125,6 +2665,139 @@ class ToolRegistry:
"expires_at": listing.get("date_expiration"), "expires_at": listing.get("date_expiration"),
} }
@staticmethod
def _summarize_marketplace_trend(trend: dict[str, Any]) -> dict[str, Any]:
return {
"id_item": trend.get("id_item"),
"item_name": trend.get("item_name"),
"item_slug": trend.get("item_slug"),
"currency": trend.get("currency"),
"sell": {
"avg_price": trend.get("price_avg_sell"),
"avg_price_month": trend.get("price_avg_month_sell"),
"min_price": trend.get("price_min_sell"),
"max_price": trend.get("price_max_sell"),
"listings_count": trend.get("listings_count_sell"),
},
"buy": {
"avg_price": trend.get("price_avg_buy"),
"avg_price_month": trend.get("price_avg_month_buy"),
"min_price": trend.get("price_min_buy"),
"max_price": trend.get("price_max_buy"),
"listings_count": trend.get("listings_count_buy"),
},
"total_listings_count": trend.get("total_listings_count"),
"negotiations_count": trend.get("negotiations_count"),
"negotiations_open": trend.get("negotiations_open"),
"negotiations_success": trend.get("negotiations_success"),
"link_prices": trend.get("link_prices"),
"link_prices_history": trend.get("link_prices_history"),
}
@staticmethod
def _summarize_scwiki_vehicle_search(vehicle: dict[str, Any]) -> dict[str, Any]:
return {
"name": vehicle.get("name"),
"class_name": vehicle.get("class_name"),
"career": vehicle.get("extra_label"),
"api_url": vehicle.get("api_url"),
"web_url": vehicle.get("web_url"),
}
@staticmethod
def _summarize_scwiki_vehicle(vehicle: dict[str, Any]) -> dict[str, Any]:
purchases = []
for entry in ((vehicle.get("uex_prices") or {}).get("purchase") or []):
if not isinstance(entry, dict):
continue
location = entry.get("starmap_location") or {}
purchases.append(
{
"price_buy": entry.get("price_buy"),
"terminal_name": entry.get("terminal_name"),
"location": location.get("name"),
"parent_location": location.get("parent_name"),
"star_system": location.get("star_system_name"),
"game_version": entry.get("game_version"),
"date_updated": entry.get("date_updated"),
"uex_link": entry.get("uex_link"),
}
)
return {
"name": vehicle.get("name") or vehicle.get("game_name"),
"game_name": vehicle.get("game_name"),
"slug": vehicle.get("slug"),
"manufacturer": (vehicle.get("manufacturer") or {}).get("name"),
"career": vehicle.get("career"),
"role": vehicle.get("role"),
"size_class": vehicle.get("size_class"),
"cargo_capacity": vehicle.get("cargo_capacity"),
"crew": vehicle.get("crew"),
"msrp": vehicle.get("msrp"),
"pledge_url": vehicle.get("pledge_url"),
"purchase_locations": purchases,
"description": ((vehicle.get("description") or {}).get("en_EN") or (vehicle.get("game_description") or {}).get("en_EN")),
"web_url": vehicle.get("web_url"),
"updated_at": vehicle.get("updated_at"),
"version": vehicle.get("version"),
}
@staticmethod
def _wikelo_ship_match_score(query: str, project: dict[str, Any]) -> int:
if not query:
return 1
ship_name = str(project.get("ship_name") or "").casefold()
description = str(project.get("description") or "").casefold()
materials = " ".join(
str(item.get("material_name") or "").casefold()
for item in (project.get("required_materials") or [])
if isinstance(item, dict)
)
haystack = " ".join(part for part in [ship_name, description, materials] if part)
if ship_name == query:
return 10000
if query in ship_name:
return 9000 - ship_name.index(query)
if query in description:
return 7000 - description.index(query)
if query in materials:
return 5000 - materials.index(query)
tokens = [token for token in query.split() if token]
if tokens and all(token in haystack for token in tokens):
return 3000 - len(haystack)
return 0
@classmethod
def _summarize_wikelo_ship_project(cls, project: dict[str, Any], detailed: bool = False) -> dict[str, Any]:
materials = []
for item in (project.get("required_materials") or []):
if not isinstance(item, dict):
continue
quantity_needed = item.get("quantity_needed")
quantity_collected = item.get("quantity_collected")
materials.append(
{
"material_name": item.get("material_name"),
"quantity_needed": int(quantity_needed) if isinstance(quantity_needed, (int, float)) and float(quantity_needed).is_integer() else quantity_needed,
"quantity_collected": int(quantity_collected) if isinstance(quantity_collected, (int, float)) and float(quantity_collected).is_integer() else quantity_collected,
}
)
summary = {
"id": project.get("id"),
"ship_name": project.get("ship_name"),
"description": project.get("description"),
"status": project.get("status"),
"privacy": project.get("privacy"),
"owner_name": project.get("owner_name"),
"org_name": project.get("org_name"),
"home_port": project.get("home_port"),
"ship_image": project.get("ship_image"),
"materials_count": len(materials),
"required_materials": materials if detailed else materials[:12],
"source_url": f"https://wikelo-projects.com/Ships",
}
return {key: value for key, value in summary.items() if value not in (None, "", [], {})}
@classmethod @classmethod
def _summarize_negotiation(cls, negotiation: dict[str, Any]) -> dict[str, Any]: def _summarize_negotiation(cls, negotiation: dict[str, Any]) -> dict[str, Any]:
summary = cls._project_item(negotiation, mode="summary") summary = cls._project_item(negotiation, mode="summary")
+96 -1
View File
@@ -10,10 +10,17 @@ class UEXError(RuntimeError):
class UEXClient: class UEXClient:
def __init__(self, base_url: str, secret_key: str | None = None, bearer_token: str | None = None) -> None: def __init__(
self,
base_url: str,
secret_key: str | None = None,
bearer_token: str | None = None,
negotiation_close_endpoint: str = "marketplace_negotiations_close",
) -> None:
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
self.secret_key = secret_key self.secret_key = secret_key
self.bearer_token = bearer_token self.bearer_token = bearer_token
self.negotiation_close_endpoint = negotiation_close_endpoint.strip().strip("/") or "marketplace_negotiations_close"
def _headers(self, authenticated: bool = False) -> dict[str, str]: def _headers(self, authenticated: bool = False) -> dict[str, str]:
headers = {"Accept": "application/json"} headers = {"Accept": "application/json"}
@@ -49,6 +56,94 @@ class UEXClient:
data = [data] data = [data]
return {"status": body.get("status"), "notifications": data} return {"status": body.get("status"), "notifications": data}
async def list_negotiations(
self,
id: int | None = None,
id_listing: int | None = None,
hash: str | None = None,
) -> dict[str, Any]:
body = await self.get(
"marketplace_negotiations",
{"id": id, "id_listing": id_listing, "hash": hash},
authenticated=True,
)
data = body.get("data") or []
if isinstance(data, dict):
data = [data]
return {"status": body.get("status"), "negotiations": data}
async def get_negotiation_messages(self, hash: str | None = None, id_negotiation: int | None = None) -> dict[str, Any]:
body = await self.get(
"marketplace_negotiations_messages",
{"hash": hash, "id_negotiation": id_negotiation},
authenticated=True,
)
data = body.get("data") or []
if isinstance(data, dict):
data = [data]
return {"status": body.get("status"), "messages": data}
async def send_negotiation_message(
self,
*,
message: str,
hash: str | None = None,
id_negotiation: int | None = None,
is_production: int = 1,
) -> dict[str, Any]:
return await self.post(
"marketplace_negotiations_messages",
{
"hash": hash,
"id_negotiation": id_negotiation,
"message": message,
"is_production": is_production,
},
authenticated=True,
)
async def close_negotiation(
self,
*,
hash: str | None = None,
id_negotiation: int | None = None,
deal_closed: bool,
deal_value: float | None = None,
currency: str | None = None,
clarity_rating: int | None = None,
speed_rating: int | None = None,
respect_rating: int | None = None,
fairness_rating: int | None = None,
comment: str | None = None,
is_production: int = 1,
) -> dict[str, Any]:
payload = {
"hash": hash,
"id_negotiation": id_negotiation,
"deal_closed": 1 if deal_closed else 0,
"deal_value": deal_value,
"currency": currency,
"clarity_rating": clarity_rating,
"speed_rating": speed_rating,
"respect_rating": respect_rating,
"fairness_rating": fairness_rating,
"comment": comment,
"is_production": is_production,
}
try:
return await self.post(
self.negotiation_close_endpoint,
payload,
authenticated=True,
)
except UEXError as exc:
raise UEXError(
"UEX negotiation close failed via endpoint "
f"`{self.negotiation_close_endpoint}`. If UEX changed this route, set "
"`UEX_NEGOTIATION_CLOSE_ENDPOINT` to the correct endpoint and retry. "
f"Original error: {exc}"
) from exc
async def post(self, path: str, payload: dict[str, Any], authenticated: bool = True) -> dict[str, Any]: async def post(self, path: str, payload: dict[str, Any], authenticated: bool = True) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=30) as client: async with httpx.AsyncClient(timeout=30) as client:
response = await client.post( response = await client.post(
+7 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
__version__ = "0.0.4" __version__ = "0.0.9"
RELEASES_URL = "https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases" RELEASES_URL = "https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases"
RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingConglomerate/TraderAI/releases" RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingConglomerate/TraderAI/releases"
@@ -10,3 +10,9 @@ RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingCo
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from typing import Any
import httpx
class WikeloProjectsError(RuntimeError):
pass
class WikeloProjectsClient:
APP_ID = "695be2905c0b4866dfb21265"
def __init__(self, base_url: str = "https://wikelo-projects.com") -> None:
self.base_url = base_url.rstrip("/")
async def list_ship_projects(self) -> list[dict[str, Any]]:
body = await self._get_json(f"{self.base_url}/api/apps/{self.APP_ID}/entities/ShipProject")
if not isinstance(body, list):
raise WikeloProjectsError("Wikelo ship projects response was not a list.")
return [item for item in body if isinstance(item, dict)]
async def _get_json(self, url: str) -> Any:
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
response = await client.get(url, headers={"Accept": "application/json"})
try:
body = response.json()
except ValueError as exc:
raise WikeloProjectsError(f"Wikelo Projects returned non-JSON response: HTTP {response.status_code}") from exc
if response.status_code >= 400:
raise WikeloProjectsError(f"Wikelo Projects HTTP {response.status_code}: {body}")
return body
File diff suppressed because one or more lines are too long
Generated
+7 -1
View File
@@ -755,7 +755,7 @@ wheels = [
[[package]] [[package]]
name = "traderai" name = "traderai"
version = "0.0.4" version = "0.0.9"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "apscheduler" }, { name = "apscheduler" },
@@ -1050,3 +1050,9 @@ wheels = [
+889 -64
View File
File diff suppressed because it is too large Load Diff
+208 -50
View File
@@ -9,7 +9,7 @@
</head> </head>
<body> <body>
<main class="shell"> <main class="shell">
<nav class="chat-rail collapsed" id="chat-rail" aria-label="Chats and inbox"> <nav class="chat-rail collapsed" id="chat-rail" aria-label="Chats, plans, and inbox">
<div class="chat-rail-top"> <div class="chat-rail-top">
<button class="icon-button" id="chat-sidebar-toggle" type="button" title="Chats" aria-expanded="false"> <button class="icon-button" id="chat-sidebar-toggle" type="button" title="Chats" aria-expanded="false">
<i data-lucide="panel-left" aria-hidden="true"></i> <i data-lucide="panel-left" aria-hidden="true"></i>
@@ -25,6 +25,29 @@
<div class="rail-heading">Chats</div> <div class="rail-heading">Chats</div>
<div class="chat-list" id="chat-list"></div> <div class="chat-list" id="chat-list"></div>
</section> </section>
<section class="chat-nav-section">
<div class="rail-heading-row">
<div class="rail-heading">Negotiations</div>
<div class="rail-heading-actions">
<button class="rail-icon-button" id="negotiations-refresh-all" type="button" title="Refresh all negotiations">
<i data-lucide="refresh-cw" aria-hidden="true"></i>
</button>
<button class="rail-icon-button" id="negotiations-toggle" type="button" title="Negotiations" aria-expanded="false" aria-controls="negotiation-panel">
<i data-lucide="messages-square" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="plans-rail-list" id="negotiation-list"></div>
</section>
<section class="chat-nav-section">
<div class="rail-heading-row">
<div class="rail-heading">Plans</div>
<button class="rail-icon-button" id="plans-toggle" type="button" title="Plans" aria-expanded="false" aria-controls="plans-panel">
<i data-lucide="list-checks" aria-hidden="true"></i>
</button>
</div>
<div class="plans-rail-list" id="plans-rail-list"></div>
</section>
<section class="chat-nav-section"> <section class="chat-nav-section">
<div class="rail-heading">Inbox</div> <div class="rail-heading">Inbox</div>
<div class="inbox-list" id="inbox-list"></div> <div class="inbox-list" id="inbox-list"></div>
@@ -42,6 +65,7 @@
<h1>TraderAI</h1> <h1>TraderAI</h1>
<p>Institutional marketplace intelligence for UEX operations</p> <p>Institutional marketplace intelligence for UEX operations</p>
</div> </div>
<span class="brand-short" aria-hidden="true">LBC</span>
</div> </div>
<div class="status" id="status">Ready</div> <div class="status" id="status">Ready</div>
</header> </header>
@@ -49,7 +73,10 @@
<div class="messages" id="messages"></div> <div class="messages" id="messages"></div>
<div class="composer-wrap"> <div class="composer-wrap">
<form class="composer" id="chat-form"> <form class="composer" id="chat-form">
<div class="composer-main">
<textarea id="message-input" rows="2" placeholder="Search listings, draft a reply, prepare an offer..."></textarea> <textarea id="message-input" rows="2" placeholder="Search listings, draft a reply, prepare an offer..."></textarea>
<div class="composer-images" id="composer-images" hidden></div>
</div>
<button type="submit">Send</button> <button type="submit">Send</button>
</form> </form>
</div> </div>
@@ -60,25 +87,6 @@
<div id="pending-actions" class="pending-empty">No pending actions</div> <div id="pending-actions" class="pending-empty">No pending actions</div>
</section> </section>
<section class="side-section sidebar-tools"> <section class="side-section sidebar-tools">
<div class="sidebar-tool-buttons" role="tablist" aria-label="Sidebar panels">
<button class="sidebar-tool-button" id="settings-toggle" type="button" aria-expanded="false" aria-controls="settings-panel" title="Settings">
<i data-lucide="settings" aria-hidden="true"></i>
<span>Settings</span>
</button>
<button class="sidebar-tool-button" id="memory-toggle" type="button" aria-expanded="false" aria-controls="memory-panel" title="Memory">
<i data-lucide="brain" aria-hidden="true"></i>
<span>Memory</span>
</button>
<button class="sidebar-tool-button" id="plans-toggle" type="button" aria-expanded="false" aria-controls="plans-panel" title="Plans">
<i data-lucide="list-checks" aria-hidden="true"></i>
<span>Plans</span>
</button>
<button class="sidebar-tool-button" id="ollama-toggle" type="button" aria-expanded="false" aria-controls="ollama-panel" title="Ollama">
<img class="sidebar-tool-image" src="/static/art/ollama-icon.svg" alt="" onerror="this.remove();">
<i data-lucide="bot" aria-hidden="true"></i>
<span>Ollama</span>
</button>
</div>
<div class="sidebar-panel" id="settings-panel" hidden> <div class="sidebar-panel" id="settings-panel" hidden>
<div class="section-title-row"> <div class="section-title-row">
<h2>Config</h2> <h2>Config</h2>
@@ -88,6 +96,7 @@
<label>UEX API URL<input id="config-uex-base-url" name="uex_base_url" type="text"></label> <label>UEX API URL<input id="config-uex-base-url" name="uex_base_url" type="text"></label>
<label>UEX Secret Key<input id="config-uex-secret-key" name="uex_secret_key" type="password" autocomplete="off"></label> <label>UEX Secret Key<input id="config-uex-secret-key" name="uex_secret_key" type="password" autocomplete="off"></label>
<label>UEX Bearer Token<input id="config-uex-bearer-token" name="uex_bearer_token" type="password" autocomplete="off"></label> <label>UEX Bearer Token<input id="config-uex-bearer-token" name="uex_bearer_token" type="password" autocomplete="off"></label>
<label>UEX Close Endpoint<input id="config-uex-negotiation-close-endpoint" name="uex_negotiation_close_endpoint" type="text"></label>
<label>UEX Username<input id="config-traderai-user-name" name="traderai_user_name" type="text"></label> <label>UEX Username<input id="config-traderai-user-name" name="traderai_user_name" type="text"></label>
<label>Memory DB Path<input id="config-traderai-memory-path" name="traderai_memory_path" type="text"></label> <label>Memory DB Path<input id="config-traderai-memory-path" name="traderai_memory_path" type="text"></label>
<label>Notification Poll Seconds<input id="config-uex-notification-poll-seconds" name="uex_notification_poll_seconds" type="number" min="15" step="15"></label> <label>Notification Poll Seconds<input id="config-uex-notification-poll-seconds" name="uex_notification_poll_seconds" type="number" min="15" step="15"></label>
@@ -123,39 +132,28 @@
<button class="danger-button" id="memory-clear" type="button">Clear Selected</button> <button class="danger-button" id="memory-clear" type="button">Clear Selected</button>
<div id="memory-inspector" class="memory-inspector"></div> <div id="memory-inspector" class="memory-inspector"></div>
</div> </div>
<div class="sidebar-panel" id="plans-panel" hidden>
<div class="section-title-row">
<h2>Plans</h2>
<button class="secondary small-button" id="plans-refresh" type="button">Refresh</button>
</div>
<form class="config-form" id="plan-form">
<label>Title<input id="plan-title" type="text" placeholder="Wikelo Idris parts"></label>
<label>Objective<input id="plan-objective" type="text" placeholder="Find and draft deals for the parts I list"></label>
<label>Kind
<select id="plan-kind">
<option value="buying">Buying</option>
<option value="custom">Custom</option>
</select>
</label>
<label>Items<textarea id="plan-items" rows="4" placeholder="One item per line, optionally: name | quantity | max unit price"></textarea></label>
<label>Instructions<textarea id="plan-instructions" rows="3" placeholder="Extra guidance for custom or buying plans"></textarea></label>
<label>Cron Cadence<input id="plan-cadence" type="text" placeholder="0 */6 * * *"></label>
<label>Message Tone<input id="plan-tone" type="text" placeholder="polite and concise"></label>
<button type="submit">Create Plan</button>
<div class="config-status" id="plans-status"></div>
</form>
<div class="plans-dashboard" id="plans-dashboard"></div>
</div>
<div class="sidebar-panel" id="ollama-panel" hidden> <div class="sidebar-panel" id="ollama-panel" hidden>
<div class="section-title-row"> <div class="section-title-row">
<h2>Ollama</h2> <h2>Inference</h2>
<button class="secondary small-button" id="ollama-refresh" type="button">Refresh</button> <button class="secondary small-button" id="ollama-refresh" type="button">Refresh</button>
</div> </div>
<form class="config-form" id="ollama-config-form"> <form class="config-form" id="ollama-config-form">
<label>Ollama URL<input id="ollama-base-url" name="ollama_base_url" type="text"></label> <label>Provider
<label>Model<input id="ollama-model" name="ollama_model" type="text"></label> <select id="model-provider" name="model_provider">
<label>Context Tokens<input id="ollama-num-ctx" name="ollama_num_ctx" type="number" min="1024" step="1024"></label> <option value="deepseek">DeepSeek V4 (Recommended)</option>
<button type="submit">Save Ollama Config</button> <option value="ollama">Local Ollama</option>
</select>
</label>
<label data-provider-scope="deepseek">DeepSeek URL<input id="deepseek-base-url" name="deepseek_base_url" type="text"></label>
<label data-provider-scope="deepseek">DeepSeek API Key<input id="deepseek-api-key" name="deepseek_api_key" type="password" autocomplete="off"></label>
<label data-provider-scope="deepseek" data-manual-model="true">DeepSeek Model<input id="deepseek-model" name="deepseek_model" type="text" list="provider-models"></label>
<label data-provider-scope="ollama">Ollama URL<input id="ollama-base-url" name="ollama_base_url" type="text"></label>
<label data-provider-scope="ollama">Ollama Model<input id="ollama-model" name="ollama_model" type="text" list="provider-models"></label>
<label data-provider-scope="ollama">Context Tokens<input id="ollama-num-ctx" name="ollama_num_ctx" type="number" min="1024" step="1024"></label>
<label><span id="provider-model-label">Model</span><select id="provider-model-select"></select></label>
<label>Reasoning Effort<select id="model-reasoning-effort" name="model_reasoning_effort"></select></label>
<datalist id="provider-models"></datalist>
<button type="submit">Save Provider Config</button>
</form> </form>
<div class="ollama-status" id="ollama-status"></div> <div class="ollama-status" id="ollama-status"></div>
<div class="ollama-actions"> <div class="ollama-actions">
@@ -166,25 +164,185 @@
</div> </div>
<div class="config-status" id="ollama-message"></div> <div class="config-status" id="ollama-message"></div>
</div> </div>
<div class="sidebar-tool-buttons" role="tablist" aria-label="Sidebar panels">
<button class="sidebar-tool-button" id="settings-toggle" type="button" aria-expanded="false" aria-controls="settings-panel" title="Settings">
<i data-lucide="settings" aria-hidden="true"></i>
<span>Settings</span>
</button>
<button class="sidebar-tool-button" id="memory-toggle" type="button" aria-expanded="false" aria-controls="memory-panel" title="Memory">
<i data-lucide="brain" aria-hidden="true"></i>
<span>Memory</span>
</button>
<button class="sidebar-tool-button" id="ollama-toggle" type="button" aria-expanded="false" aria-controls="ollama-panel" title="Inference">
<img class="sidebar-tool-image" src="/static/art/ollama-icon.svg" alt="" onerror="this.remove();">
<i data-lucide="bot" aria-hidden="true"></i>
<span>Inference</span>
</button>
</div>
</section> </section>
</aside> </aside>
</main> </main>
<div class="floating-panel" id="negotiation-panel" hidden> <div class="floating-panel" id="negotiation-panel" hidden>
<div class="floating-panel-header"> <div class="floating-panel-header">
<div> <div>
<p class="eyebrow">UEX negotiation</p> <p class="eyebrow">UEX negotiations</p>
<h2 id="negotiation-title">Negotiation</h2> <h2 id="negotiation-title">Negotiation workspace</h2>
</div> </div>
<div class="floating-panel-actions">
<div class="negotiation-sync-pill" id="negotiation-sync-pill">Local sync</div>
<button class="icon-button light" id="negotiation-close" type="button" title="Close"> <button class="icon-button light" id="negotiation-close" type="button" title="Close">
<i data-lucide="x" aria-hidden="true"></i> <i data-lucide="x" aria-hidden="true"></i>
</button> </button>
</div> </div>
</div>
<div class="negotiation-workspace">
<aside class="negotiation-sidebar">
<div class="negotiation-sidebar-controls">
<input id="negotiation-search" type="text" placeholder="Search negotiations">
<select id="negotiation-filter">
<option value="open">Open</option>
<option value="all">All</option>
<option value="closed">Closed</option>
</select>
</div>
<div class="negotiation-list-panel" id="negotiation-panel-list"></div>
</aside>
<section class="negotiation-thread-shell">
<div class="negotiation-thread-header" id="negotiation-thread-header">
<div class="muted">Select a negotiation to load the local thread.</div>
</div>
<div class="negotiation-messages" id="negotiation-messages"></div> <div class="negotiation-messages" id="negotiation-messages"></div>
<form class="negotiation-composer" id="negotiation-form"> <form class="negotiation-composer" id="negotiation-form">
<textarea id="negotiation-input" rows="2" placeholder="Reply to the other party..."></textarea> <textarea id="negotiation-input" rows="2" placeholder="Reply to the other party..."></textarea>
<div class="negotiation-composer-actions">
<button class="secondary small-button" id="negotiation-draft-button" type="button">Ask AI to Draft</button>
<button type="submit">Send</button> <button type="submit">Send</button>
</div>
</form> </form>
<div class="config-status" id="negotiation-status"></div> <div class="config-status" id="negotiation-status"></div>
</section>
<aside class="negotiation-detail-rail">
<div class="negotiation-detail-card" id="negotiation-meta-card">
<h3>Deal</h3>
<div class="muted">No negotiation selected.</div>
</div>
<div class="negotiation-detail-card" id="negotiation-user-card">
<h3>User</h3>
<div class="muted">No negotiation selected.</div>
</div>
<div class="negotiation-detail-card">
<h3>Actions</h3>
<div class="negotiation-action-stack">
<button class="secondary small-button" id="negotiation-open-chat" type="button">Open in AI Chat</button>
<button class="small-button" id="negotiation-refresh-button" type="button">Refresh Thread</button>
<button class="danger-button small-button" id="negotiation-end-deal" type="button">End Deal</button>
</div>
</div>
</aside>
</div>
</div>
<div class="modal-backdrop" id="negotiation-close-modal" hidden>
<section class="update-modal-card negotiation-close-card">
<div class="section-title-row">
<h2>End Deal</h2>
<button class="icon-button light" id="negotiation-close-modal-close" type="button" title="Close">
<i data-lucide="x" aria-hidden="true"></i>
</button>
</div>
<form class="config-form" id="negotiation-close-form">
<label>Did you close a deal?
<select id="close-deal-closed">
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</label>
<div class="plan-form-split">
<label>Deal value
<input id="close-deal-value" type="number" min="0" step="1" placeholder="1000000">
</label>
<label>Currency
<input id="close-currency" type="text" value="UEC">
</label>
</div>
<label>Clear, timely, and honest?
<input id="close-clarity" type="number" min="1" max="5" step="1" value="5">
</label>
<label>Delivery or response time?
<input id="close-speed" type="number" min="1" max="5" step="1" value="5">
</label>
<label>Respectful and easy to deal with?
<input id="close-respect" type="number" min="1" max="5" step="1" value="5">
</label>
<label>Price or offer fairness?
<input id="close-fairness" type="number" min="1" max="5" step="1" value="5">
</label>
<label>Comments
<textarea id="close-comment" rows="3" placeholder="Optional note"></textarea>
</label>
<div class="plan-form-actions">
<button class="secondary" id="close-draft-button" type="button">Draft for Approval</button>
<button type="submit">Rate Deal</button>
</div>
<div class="config-status" id="negotiation-close-status"></div>
</form>
</section>
</div>
<div class="floating-panel plans-floating-panel" id="plans-panel" hidden>
<div class="floating-panel-header">
<div>
<p class="eyebrow">Continual work</p>
<h2>Plans</h2>
</div>
<div class="floating-panel-actions">
<button class="icon-button light" id="plans-refresh" type="button" title="Refresh plans">
<i data-lucide="refresh-cw" aria-hidden="true"></i>
</button>
<button class="icon-button light" id="plans-close" type="button" title="Close">
<i data-lucide="x" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="plans-panel-body">
<aside class="plan-creator-shell">
<div class="plan-creator-card">
<div class="plan-creator-copy">
<p class="eyebrow">New continual plan</p>
<h3>Set the watch once</h3>
<p>Spin up buying runs or custom follow-up work with a title, a goal, and just enough guardrails to keep it on track.</p>
</div>
<form class="config-form plan-form-grid" id="plan-form">
<label>Title<input id="plan-title" type="text" placeholder="Wikelo Idris parts"></label>
<label>Objective<input id="plan-objective" type="text" placeholder="Find and draft deals for the parts I list"></label>
<div class="plan-form-split">
<label>Kind
<select id="plan-kind">
<option value="buying">Buying</option>
<option value="custom">Custom</option>
</select>
</label>
<label>Message Tone<input id="plan-tone" type="text" placeholder="polite and concise"></label>
</div>
<label>Items<textarea id="plan-items" rows="5" placeholder="One item per line, optionally: name | quantity | max unit price"></textarea></label>
<label>Instructions<textarea id="plan-instructions" rows="4" placeholder="Extra guidance for custom or buying plans"></textarea></label>
<div class="plan-form-split">
<label>Cron Cadence<input id="plan-cadence" type="text" placeholder="0 */6 * * *"></label>
<div class="plan-form-hint">
<strong>Tip</strong>
<span>Buying plans work best with item lines. Custom plans can run with just instructions.</span>
</div>
</div>
<div class="plan-form-actions">
<button id="plan-autofill" type="button">AI Fill</button>
<button type="submit">Create Plan</button>
</div>
<div class="config-status" id="plans-status"></div>
</form>
</div>
</aside>
<section class="plans-dashboard-shell">
<div class="plans-dashboard" id="plans-dashboard"></div>
</section>
</div>
</div> </div>
<div class="modal-backdrop" id="update-modal" hidden> <div class="modal-backdrop" id="update-modal" hidden>
<section class="update-modal-card"> <section class="update-modal-card">
+859 -49
View File
File diff suppressed because it is too large Load Diff
+175
View File
@@ -0,0 +1,175 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<link href="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/695be2905c0b4866dfb21265/62b39a568_Wikapp3.webp" rel="icon" type="image/svg+xml"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<link href="/manifest.json" rel="manifest"/>
<title>
Ships | Wikelo Project Tracker
</title>
<script crossorigin="" src="/assets/index-DWqdqkK8.js" type="module">
</script>
<link crossorigin="" href="/assets/index-BzxCYXI2.css" rel="stylesheet"/>
<meta content="Ships on Wikelo Project Tracker. Track materials needed and contributed for building your Star Citizen ships." name="description"/>
<meta content="Ships | Wikelo Project Tracker" property="og:title"/>
<meta content="Ships on Wikelo Project Tracker. Track materials needed and contributed for building your Star Citizen ships." property="og:description"/>
<meta content="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/render/image/public/base44-prod/public/695be2905c0b4866dfb21265/62b39a568_Wikapp3.webp?width=1200&amp;height=630&amp;resize=contain" property="og:image"/>
<meta content="https://wikelo-projects.com/Ships" property="og:url"/>
<meta content="website" property="og:type"/>
<meta content="Wikelo Project Tracker" property="og:site_name"/>
<meta content="Ships | Wikelo Project Tracker" name="twitter:title"/>
<meta content="Ships on Wikelo Project Tracker. Track materials needed and contributed for building your Star Citizen ships." name="twitter:description"/>
<meta content="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/render/image/public/base44-prod/public/695be2905c0b4866dfb21265/62b39a568_Wikapp3.webp?width=1200&amp;height=630&amp;resize=contain" name="twitter:image"/>
<meta content="summary_large_image" name="twitter:card"/>
<meta content="https://wikelo-projects.com/Ships" name="twitter:url"/>
<meta content="yes" name="mobile-web-app-capable"/>
<meta content="black" name="apple-mobile-web-app-status-bar-style"/>
<meta content="Wikelo Project Tracker" name="apple-mobile-web-app-title"/>
<link href="https://wikelo-projects.com/Ships" rel="canonical"/>
<script data-seo-source="builder" type="application/ld+json">
{"name": "Wikelo Project Tracker", "@context": "https://schema.org", "@type": "WebSite", "url": "https://wikelo-projects.com"}
</script>
<script data-seo-source="builder" type="application/ld+json">
{"name": "Wikelo Project Tracker", "logo": "https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/695be2905c0b4866dfb21265/62b39a568_Wikapp3.webp", "@context": "https://schema.org", "@type": "Organization", "url": "https://wikelo-projects.com"}
</script>
<script data-seo-source="builder" type="application/ld+json">
{"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [{"@type": "ListItem", "position": 1, "name": "Home", "item": "https://wikelo-projects.com/"}, {"@type": "ListItem", "position": 2, "name": "Ships | Wikelo Project Tracker", "item": "https://wikelo-projects.com/Ships"}]}
</script>
</head>
<body>
<div id="root">
<div data-seo-source="builder" id="seo-snapshot" style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;">
<h1>
Ships | Wikelo Project Tracker
</h1>
<p>
Ships on Wikelo Project Tracker. Track materials needed and contributed for building your Star Citizen ships.
</p>
<nav aria-label="Pages">
<h2>
Pages
</h2>
<ul>
<li>
<a href="/AdminAds">
Admin Ads
</a>
</li>
<li>
<a href="/AdvertiseWithUs">
Advertise With Us
</a>
</li>
<li>
<a href="/ArmorProjectDetails">
Armor Project Details
</a>
</li>
<li>
<a href="/Armors">
Armors
</a>
</li>
<li>
<a href="/CleanupMaterials">
Cleanup Materials
</a>
</li>
<li>
<a href="/Guide">
Guide
</a>
</li>
<li>
<a href="/">
Home
</a>
</li>
<li>
<a href="/Inventory">
Inventory
</a>
</li>
<li>
<a href="/Messages">
Messages
</a>
</li>
<li>
<a href="/NotificationSettings">
Notification Settings
</a>
</li>
<li>
<a href="/Notifications">
Notifications
</a>
</li>
<li>
<a href="/OrganizationDetails">
Organization Details
</a>
</li>
<li>
<a href="/Organizations">
Organizations
</a>
</li>
<li>
<a href="/Profile">
Profile
</a>
</li>
<li>
<a href="/ProjectDetails">
Project Details
</a>
</li>
<li>
<a href="/RecalculateContributionReputation">
Recalculate Contribution Reputation
</a>
</li>
<li>
<a href="/RecalculateMaterials">
Recalculate Materials
</a>
</li>
<li>
<a href="/RecalculateReputation">
Recalculate Reputation
</a>
</li>
<li>
<a href="/Reputation">
Reputation
</a>
</li>
<li>
<a href="/UpdateInfo">
Update Info
</a>
</li>
<li>
<a href="/WeaponProjectDetails">
Weapon Project Details
</a>
</li>
<li>
<a href="/Weapons">
Weapons
</a>
</li>
<li>
<a href="/WikeloProjectDetails">
Wikelo Project Details
</a>
</li>
</ul>
</nav>
</div>
</div>
</body>
</html>