11 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
HRiggs 97c751c585 versioning: 0.0.4, feat: create listing, source image
Build Release EXE / build-windows-exe (release) Successful in 52s
2026-05-08 00:02:59 -04:00
HRiggs e2f87481d6 feat: plans - longrunning tasks 2026-05-07 23:54:58 -04:00
HRiggs d6c2d57fd9 feat: add cornerstone 2026-05-07 21:47:30 -04:00
HRiggs 71638fcaed feat: add smdb intergration 2026-05-07 21:20:43 -04:00
32 changed files with 10473 additions and 317 deletions
+16 -1
View File
@@ -1,10 +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
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
+12 -2
View File
@@ -1,10 +1,12 @@
# 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
- Searches active/current UEX marketplace listings through `GET /marketplace_listings/`. - Searches active/current UEX marketplace listings through `GET /marketplace_listings/`.
- Searches SCMDB mission data so the assistant can answer what Star Citizen missions pay or reward, including UEC, reputation, item rewards, blueprint rewards, partial payouts, and hauling cargo.
- Searches Cornerstone Universal Item Finder data so the assistant can find where in-game items are sold, including store/location, base price, and verified date.
- Reads authenticated marketplace negotiations and negotiation messages when `UEX_SECRET_KEY` or `UEX_BEARER_TOKEN` is set. - Reads authenticated marketplace negotiations and negotiation messages when `UEX_SECRET_KEY` or `UEX_BEARER_TOKEN` is set.
- Drafts negotiation messages and marketplace listings as pending actions. - Drafts negotiation messages and marketplace listings as pending actions.
- Requires browser approval before sending authenticated write requests to UEX. - Requires browser approval before sending authenticated write requests to UEX.
@@ -23,6 +25,12 @@ 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`.
`CORNERSTONE_BASE_URL` defaults to `https://finder.cstone.space`.
4. Install and run: 4. Install and run:
```powershell ```powershell
@@ -34,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
@@ -71,6 +79,8 @@ UEX notifications are checked every `UEX_NOTIFICATION_POLL_SECONDS` seconds by d
## Sources Used ## Sources Used
- UEX SwaggerHub OpenAPI v2.1: https://app.swaggerhub.com/apis-docs/dolejska-daniel/UEX-API/v2.1 - UEX SwaggerHub OpenAPI v2.1: https://app.swaggerhub.com/apis-docs/dolejska-daniel/UEX-API/v2.1
- SCMDB mission data: https://scmdb.net/
- Cornerstone Universal Item Finder: https://finder.cstone.space/
- UEX marketplace listings docs: https://uexcorp.space/api/documentation/id/get_marketplace_listings/?is_kiosk=1 - UEX marketplace listings docs: https://uexcorp.space/api/documentation/id/get_marketplace_listings/?is_kiosk=1
- UEX negotiation message docs: https://uexcorp.space/api/documentation/id/post_marketplace_negotiations_messages/?is_kiosk=1 - UEX negotiation message docs: https://uexcorp.space/api/documentation/id/post_marketplace_negotiations_messages/?is_kiosk=1
- Ollama tool calling docs: https://docs.ollama.com/capabilities/tool-calling - Ollama tool calling docs: https://docs.ollama.com/capabilities/tool-calling
+9 -2
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "traderai" name = "traderai"
version = "0.0.3" 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",
@@ -37,3 +37,10 @@ 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
+256
View File
@@ -0,0 +1,256 @@
import pytest
from datetime import timedelta
from traderai.memory import MemoryStore, utc_now
from traderai.plans import ContinualPlanRunner, ContinualPlanStore
from traderai.scheduler import WakeScheduler
from traderai.tools import ToolRegistry
class BuyingUEX:
def __init__(self):
self.posts = []
async def get(self, path, params=None, authenticated=False):
if path == "marketplace_listings":
return {
"data": [
{
"id": 501,
"slug": "wikelo-panel-good",
"title": "Wikelo Idris panel",
"operation": "sell",
"type": "item",
"price": 450_000,
"currency": "UEC",
"in_stock": 2,
"location": "Orison",
"user_username": "seller_a",
},
{
"id": 502,
"slug": "wikelo-panel-expensive",
"title": "Wikelo Idris panel premium",
"operation": "sell",
"type": "item",
"price": 900_000,
"currency": "UEC",
"in_stock": 1,
"location": "Area18",
"user_username": "seller_b",
},
],
}
return {"data": []}
async def post(self, path, payload, authenticated=True):
self.posts.append({"path": path, "payload": payload, "authenticated": authenticated})
return {"status": "ok", "posted": self.posts[-1]}
async def delete(self, path, params=None, authenticated=True):
return {"status": "ok"}
class FakePlanAgent:
def __init__(self):
self.prompts = []
async def generate_wake_response(self, wake_message):
self.prompts.append(wake_message)
return "Custom plan checked notifications and found no blockers."
def plan_stack(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
store = ContinualPlanStore(memory)
scheduler = WakeScheduler(memory)
tools = ToolRegistry(BuyingUEX(), memory=memory, scheduler=scheduler, plan_store=store)
runner = ContinualPlanRunner(store, tools, memory)
tools.plan_runner = runner
scheduler.bind_plan_runner(runner)
return memory, store, tools, runner, scheduler
def test_continual_plan_store_creates_needs_input_plan(tmp_path):
_, store, _, _, _ = plan_stack(tmp_path)
plan = store.create_plan("Wikelo Idris", objective="Get all parts", items=[])
assert plan["status"] == "needs_input"
assert plan["items"] == []
assert plan["events"][0]["kind"] == "needs_input"
def test_custom_plan_without_items_is_active(tmp_path):
_, store, _, _, _ = plan_stack(tmp_path)
plan = store.create_plan("Watch negotiations", kind="custom", objective="Check replies and summarize next steps", items=[])
assert plan["status"] == "active"
assert plan["items"] == []
def test_continual_plan_store_creates_buying_checklist(tmp_path):
_, store, _, _, _ = plan_stack(tmp_path)
plan = store.create_plan(
"Wikelo Idris",
objective="Get all listed parts",
items=[{"item_name": "Wikelo Idris panel", "desired_quantity": 2, "max_unit_price": 500_000}],
)
assert plan["status"] == "active"
assert plan["items"][0]["item_name"] == "Wikelo Idris panel"
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
async def test_buying_runner_tracks_candidates_and_drafts_only(tmp_path):
memory, store, tools, runner, _ = plan_stack(tmp_path)
plan = store.create_plan(
"Wikelo Idris",
objective="Get all listed parts",
items=[{"item_name": "Wikelo Idris panel", "desired_quantity": 1, "max_unit_price": 500_000}],
)
result = await runner.run_plan(plan["id"])
snapshot = store.get_plan(plan["id"])
assert result["drafted"] == 1
assert any(candidate["listing_id"] == "501" and candidate["status"] == "drafted" for candidate in snapshot["candidates"])
assert snapshot["negotiations"][0]["status"] == "drafted"
assert len(tools.pending_actions) == 1
assert not tools.uex.posts
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
async def test_plan_approval_logs_back_to_plan(tmp_path):
_, store, tools, runner, _ = plan_stack(tmp_path)
plan = store.create_plan(
"Wikelo Idris",
objective="Get all listed parts",
items=[{"item_name": "Wikelo Idris panel", "max_unit_price": 500_000}],
)
await runner.run_plan(plan["id"])
action_id = next(iter(tools.pending_actions))
approved = await tools.approve(action_id)
snapshot = store.get_plan(plan["id"])
assert approved["posted"]["path"] == "marketplace_negotiations_messages"
assert any(event["kind"] == "approved" for event in snapshot["events"])
assert any(negotiation["status"] == "approved" for negotiation in snapshot["negotiations"])
@pytest.mark.asyncio
async def test_custom_runner_continues_plan_through_agent(tmp_path):
memory, store, tools, runner, _ = plan_stack(tmp_path)
agent = FakePlanAgent()
runner.bind_agent(agent)
plan = store.create_plan(
"Watch open negotiations",
kind="custom",
objective="Check UEX replies and recommend next action",
constraints={"instructions": "Pay attention to stale buyer replies."},
items=[],
)
result = await runner.run_plan(plan["id"])
snapshot = store.get_plan(plan["id"])
assert result["status"] == "ok"
assert "Custom plan checked notifications" in result["summary"]
assert plan["id"] in agent.prompts[0]
assert any(event["kind"] == "run" for event in snapshot["events"])
assert "Custom plan checked notifications" in memory.list_outbox()[0]["content"]
@pytest.mark.asyncio
async def test_scheduler_plan_run_survives_runner_error(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
store = ContinualPlanStore(memory)
plan = store.create_plan(
"Broken plan",
objective="Test failure handling",
items=[{"item_name": "Wikelo Idris panel"}],
)
class FailingRunner:
def __init__(self, store):
self.store = store
async def run_plan(self, plan_id):
self.store.add_event(plan_id, "error", "boom")
memory.add_outbox("Broken plan: boom")
return {"error": "boom", "plan": self.store.get_plan(plan_id)}
scheduler = WakeScheduler(memory)
scheduler.bind_plan_runner(FailingRunner(store))
await scheduler._run_plan(plan["id"])
snapshot = store.get_plan(plan["id"])
assert snapshot["status"] == "active"
assert snapshot["events"][0]["kind"] == "error"
assert "boom" in memory.list_outbox()[0]["content"]
@pytest.mark.asyncio
async def test_scheduler_schedules_overdue_plan_catchup_on_start(tmp_path):
memory, store, _, runner, scheduler = plan_stack(tmp_path)
plan = store.create_plan(
"Overdue plan",
objective="Check after restart",
items=[{"item_name": "Wikelo Idris panel"}],
)
store.update_schedule(plan["id"], (utc_now() - timedelta(minutes=5)).isoformat())
scheduler.start()
try:
catchup = scheduler.scheduler.get_job(scheduler._plan_catchup_job_id(plan["id"]))
snapshot = store.get_plan(plan["id"])
finally:
scheduler.shutdown()
assert catchup is not None
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",
)
+607
View File
@@ -2,6 +2,7 @@ import pytest
import respx import respx
from httpx import Response from httpx import Response
from traderai.cornerstone_client import CornerstoneClient, parse_cornerstone_item_page
from traderai.tools import ToolRegistry from traderai.tools import ToolRegistry
from traderai.uex_client import UEXClient from traderai.uex_client import UEXClient
@@ -9,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",
@@ -79,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": [
@@ -121,6 +152,253 @@ class FakeUEX:
return {"status": "ok", "posted": self.posts[-1]} return {"status": "ok", "posted": self.posts[-1]}
class FakeSCMDB:
base_url = "https://scmdb.test"
async def list_versions(self):
return [
{"version": "4.8.0-ptu.1", "file": "merged-4.8.0-ptu.1.json"},
{"version": "4.7.2-live.1", "file": "merged-4.7.2-live.1.json"},
]
async def get_data(self, version=None, channel="live"):
return {
"version": version or "4.7.2-live.1",
"factions": {
"fac-haul": {"name": "Covalex"},
"fac-bounty": {"name": "Bounty Hunters Guild"},
},
"scopes": {
"scope-rep": {"scopeName": "FactionReputation"},
},
"factionRewardsPools": [
[{"factionGuid": "fac-haul", "scopeGuid": "scope-rep", "amount": 125}],
[{"factionGuid": "fac-bounty", "scopeGuid": "scope-rep", "amount": 250}],
],
"partialRewardPayoutPools": [
[],
[{"minPercentage": 50, "maxPercentage": 99, "currencyRewardMultiplier": 0.75, "reputationMultipliers": None}],
],
"resourcePools": {
"res-tungsten": {"name": "Tungsten"},
},
"blueprintPools": {
"bp-pool": {
"name": "Ship Salvage Rewards",
"blueprints": [{"name": "Abrade Scraper Module"}],
},
},
"locationPools": {
"loc-a18": {"name": "Area18"},
"loc-baijini": {"name": "Baijini Point"},
},
"contracts": [
{
"id": "mission-haul",
"debugName": "Haul_Tungsten_Test",
"title": "Move Tungsten",
"description": "Move Tungsten to Baijini Point.",
"missionType": "Hauling",
"category": "career",
"factionGuid": "fac-haul",
"rewardUEC": 50250,
"factionRewardsIndex": 0,
"partialRewardPayoutIndex": 1,
"haulingOrders": [{"resource": "res-tungsten", "minSCU": 6, "maxSCU": 6, "maxContainerSize": 1}],
"locations": ["loc-a18"],
"destinations": ["loc-baijini"],
"systems": ["Stanton"],
"illegal": False,
"canBeShared": False,
},
{
"id": "mission-bounty",
"debugName": "Bounty_Blueprint_Test",
"title": "Ambush Op",
"description": "Clean out targets.",
"missionType": "Bounty Hunter",
"factionGuid": "fac-bounty",
"rewardUEC": 120000,
"factionRewardsIndex": 1,
"partialRewardPayoutIndex": 0,
"itemRewards": [{"name": "Council Scrip", "amount": 5}],
"blueprintRewards": [{"blueprintPool": "bp-pool", "chance": 1, "trigger": "complete"}],
"systems": ["Pyro"],
"illegal": True,
"canBeShared": True,
},
],
"legacyContracts": [
{
"id": "legacy-delivery",
"debugName": "Legacy_Delivery_Test",
"title": "Old Box Run",
"missionType": "Delivery",
"factionGuid": "fac-haul",
"rewardUEC": 1000,
"factionRewardsIndex": 0,
"partialRewardPayoutIndex": 0,
"systems": ["Stanton"],
}
],
}
class FakeCornerstone:
base_url = "https://finder.cstone.test"
async def list_items(self):
return [
{"id": "item-abrade", "name": "Abrade Scraper Module", "sold": True},
{"id": "item-cinch", "name": "Cinch Scraper Module", "sold": True},
{"id": "item-poster", "name": "Zeus 2955 Ship Showdown Poster", "sold": False},
]
async def get_item_page(self, item_id):
assert item_id == "item-abrade"
return {
"url": f"{self.base_url}/ShipSalvageMods1/{item_id}",
"html": """
<html>
<head>
<title>Star Citizen - Salvage modifier - Abrade Scraper Module</title>
<meta property="og:image" content="/images/abrade.png">
</head>
<body>
<table>
<tr><td>NAME</td><td>Abrade Scraper Module</td></tr>
<tr><td>MANUFACTURER</td><td>Greycat Industrial</td></tr>
</table>
<table>
<tr><th>LOCATION</th><th>BASE PRICE</th><th>VERIFIED</th></tr>
<tr><td>Stanton - ArcCorp - Area18 - Dumper's Depot</td><td>21 250</td><td>2956-01-29</td></tr>
<tr><td>Stanton - microTech - Port Tressler - Platinum Bay</td><td>21 250</td><td>2956-01-04</td></tr>
</table>
</body>
</html>
""",
}
async def get_image_data(self, url, max_bytes=10_000_000):
assert url == f"{self.base_url}/images/abrade.png"
return {
"url": url,
"content_type": "image/png",
"size_bytes": 12,
"image_data": "ZmFrZS1pbWFnZQ==",
}
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())
@@ -174,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())
@@ -195,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())
@@ -230,12 +578,271 @@ 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
assert "uex_draft_post" not in names assert "uex_draft_post" not in names
def test_schemas_expose_scmdb_mission_tools():
registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB())
names = {schema["function"]["name"] for schema in registry.schemas}
assert "list_scmdb_versions" in names
assert "search_scmdb_missions" in names
assert "get_scmdb_mission_rewards" in names
def test_schemas_expose_cornerstone_item_tools():
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
names = {schema["function"]["name"] for schema in registry.schemas}
assert "search_cornerstone_items" in names
assert "get_cornerstone_item_locations" in names
assert "get_cornerstone_item_media" 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
async def test_search_scmdb_missions_returns_reward_summary():
registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB())
result = await registry.search_scmdb_missions(query="tungsten", mission_type="hauling")
assert result["version"] == "4.7.2-live.1"
assert result["matched"] == 1
mission = result["missions"][0]
assert mission["title"] == "Move Tungsten"
assert mission["rewards"]["uec"] == 50250
assert mission["rewards"]["reputation"] == [{"faction": "Covalex", "scope": "FactionReputation", "amount": 125}]
assert mission["rewards"]["hauling"] == [
{"resource": "Tungsten", "min_scu": 6, "max_scu": 6, "max_container_size_scu": 1}
]
@pytest.mark.asyncio
async def test_get_scmdb_mission_rewards_enriches_items_blueprints_and_locations():
registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB())
result = await registry.get_scmdb_mission_rewards(debug_name="Bounty_Blueprint_Test")
mission = result["mission"]
assert mission["title"] == "Ambush Op"
assert mission["faction"] == "Bounty Hunters Guild"
assert mission["rewards"]["items"] == [{"name": "Council Scrip", "amount": 5}]
assert mission["rewards"]["blueprints"][0]["blueprints"] == ["Abrade Scraper Module"]
@pytest.mark.asyncio
async def test_search_cornerstone_items_filters_sold_items():
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
result = await registry.search_cornerstone_items(query="scraper", sold_only=True)
assert result["matched"] == 2
assert {item["name"] for item in result["items"]} == {"Abrade Scraper Module", "Cinch Scraper Module"}
assert result["items"][0]["url"].startswith("https://finder.cstone.test/Search/item-")
@pytest.mark.asyncio
async def test_get_cornerstone_item_locations_parses_store_prices():
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
result = await registry.get_cornerstone_item_locations(query="abrade", location="Area18")
assert result["item"]["name"] == "Abrade Scraper Module"
assert result["item"]["general"]["manufacturer"] == "Greycat Industrial"
assert result["matched_locations"] == 1
assert result["locations"] == [
{
"location": "Stanton - ArcCorp - Area18 - Dumper's Depot",
"base_price": 21250,
"base_price_display": "21 250",
"verified": "2956-01-29",
}
]
@pytest.mark.asyncio
async def test_get_cornerstone_item_media_returns_absolute_image_urls():
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
result = await registry.get_cornerstone_item_media(query="abrade")
assert result["media"] == [
{
"url": "https://finder.cstone.test/images/abrade.png",
"source": "og:image",
}
]
@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
async def test_draft_marketplace_listing_with_cornerstone_image_adds_image_data_and_redacts_display():
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
result = await registry.draft_marketplace_listing_with_cornerstone_image(
item_query="abrade",
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",
source="purchased_in_game",
in_stock=1,
)
pending = result["pending_action"]
stored = registry.pending_actions[pending["id"]]
assert pending["endpoint"] == "marketplace_advertise"
assert pending["payload"]["image_data"].startswith("<base64 image data redacted")
assert stored.payload["image_data"] == "ZmFrZS1pbWFnZQ=="
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():
parsed = parse_cornerstone_item_page(
"""
<html><head><title>Star Citizen - Food - Whamburger</title><meta property="og:image" content="/img/wham.png"></head>
<body><table><tr><td>NAME</td><td>Whamburger</td></tr></table>
<img src="https://example.test/extra.png" alt="Whamburger">
<table><tr><th>LOCATION</th><th>BASE PRICE</th><th>VERIFIED</th></tr>
<tr><td>Stanton - Area18 - Cubby Blast</td><td>9</td><td>2956-01-01</td></tr></table></body></html>
""",
"https://finder.cstone.test/Search/item-wham",
)
assert parsed["name"] == "Whamburger"
assert parsed["locations"][0]["base_price"] == 9
assert parsed["media"][0]["url"] == "https://finder.cstone.test/img/wham.png"
assert parsed["media"][1]["url"] == "https://example.test/extra.png"
@pytest.mark.asyncio
@respx.mock
async def test_cornerstone_client_accepts_json_encoded_string_payload():
respx.get("https://finder.cstone.space/GetSearch").mock(
return_value=Response(
200,
json='[{"id":"item-1","name":"Abrade Scraper Module","Sold":1}]',
)
)
client = CornerstoneClient("https://finder.cstone.space")
assert await client.list_items() == [{"id": "item-1", "name": "Abrade Scraper Module", "sold": True}]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_search_uex_api_index_finds_history_tools(): async def test_search_uex_api_index_finds_history_tools():
registry = ToolRegistry(FakeUEX()) registry = ToolRegistry(FakeUEX())
+453
View File
File diff suppressed because one or more lines are too long
+1437 -136
View File
File diff suppressed because it is too large Load Diff
+45 -3
View File
@@ -11,12 +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},
"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},
@@ -60,22 +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"
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:
@@ -133,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":
+226
View File
@@ -0,0 +1,226 @@
from __future__ import annotations
from html.parser import HTMLParser
import base64
import json
from typing import Any
from urllib.parse import urljoin
import httpx
class CornerstoneError(RuntimeError):
pass
class CornerstoneClient:
def __init__(self, base_url: str = "https://finder.cstone.space") -> None:
self.base_url = base_url.rstrip("/")
self._items: list[dict[str, Any]] | None = None
async def list_items(self) -> list[dict[str, Any]]:
if self._items is not None:
return self._items
body = await self._get_json("GetSearch")
if isinstance(body, str):
body = json.loads(body)
if not isinstance(body, list):
raise CornerstoneError("Cornerstone search response was not a list.")
self._items = [
{"id": item.get("id"), "name": item.get("name"), "sold": bool(item.get("Sold"))}
for item in body
if isinstance(item, dict) and item.get("id") and item.get("name")
]
return self._items
async def get_item_page(self, item_id: str) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
response = await client.get(
f"{self.base_url}/Search/{item_id.strip()}",
headers={"Accept": "text/html,application/xhtml+xml"},
)
if response.status_code >= 400:
raise CornerstoneError(f"Cornerstone HTTP {response.status_code}: {response.text[:240]}")
return {"url": str(response.url), "html": response.text}
async def get_image_data(self, url: str, max_bytes: int = 10_000_000) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
response = await client.get(url, headers={"Accept": "image/png,image/jpeg,image/*"})
if response.status_code >= 400:
raise CornerstoneError(f"Cornerstone image HTTP {response.status_code}: {response.text[:240]}")
content_type = response.headers.get("content-type", "").split(";")[0].strip().casefold()
if content_type not in {"image/jpeg", "image/jpg", "image/png"}:
raise CornerstoneError(f"Cornerstone image was not JPG or PNG: {content_type or 'unknown content type'}")
if len(response.content) > max_bytes:
raise CornerstoneError(f"Cornerstone image is larger than {max_bytes} bytes.")
return {
"url": str(response.url),
"content_type": content_type,
"size_bytes": len(response.content),
"image_data": base64.b64encode(response.content).decode("ascii"),
}
async def _get_json(self, path: str) -> Any:
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
response = await client.get(f"{self.base_url}/{path.lstrip('/')}", headers={"Accept": "application/json"})
try:
body = response.json()
except ValueError as exc:
raise CornerstoneError(f"Cornerstone returned non-JSON response: HTTP {response.status_code}") from exc
if response.status_code >= 400:
raise CornerstoneError(f"Cornerstone HTTP {response.status_code}: {body}")
return body
class CornerstonePageParser(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.title = ""
self.tables: list[list[list[str]]] = []
self.images: list[dict[str, str]] = []
self._skip_depth = 0
self._in_title = False
self._current_table: list[list[str]] | None = None
self._current_row: list[str] | None = None
self._current_cell: list[str] | None = None
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
tag = tag.casefold()
if tag in {"script", "style"}:
self._skip_depth += 1
return
if self._skip_depth:
return
if tag == "title":
self._in_title = True
elif tag == "meta":
attr_map = self._attrs(attrs)
name = (attr_map.get("property") or attr_map.get("name") or "").casefold()
content = attr_map.get("content") or ""
if content and name in {"og:image", "twitter:image", "twitter:image:src"}:
self.images.append({"url": content, "source": name})
elif tag == "link":
attr_map = self._attrs(attrs)
rel = (attr_map.get("rel") or "").casefold()
href = attr_map.get("href") or ""
if href and "image_src" in rel:
self.images.append({"url": href, "source": "link:image_src"})
elif tag == "img":
attr_map = self._attrs(attrs)
url = attr_map.get("src") or attr_map.get("data-src") or attr_map.get("data-original") or ""
if url:
self.images.append(
{
"url": url,
"alt": attr_map.get("alt") or "",
"source": "img",
}
)
elif tag == "table":
self._current_table = []
elif tag == "tr" and self._current_table is not None:
self._current_row = []
elif tag in {"td", "th"} and self._current_row is not None:
self._current_cell = []
def handle_endtag(self, tag: str) -> None:
tag = tag.casefold()
if tag in {"script", "style"} and self._skip_depth:
self._skip_depth -= 1
return
if self._skip_depth:
return
if tag == "title":
self._in_title = False
elif tag in {"td", "th"} and self._current_cell is not None and self._current_row is not None:
text = " ".join("".join(self._current_cell).split())
self._current_row.append(text)
self._current_cell = None
elif tag == "tr" and self._current_row is not None and self._current_table is not None:
if any(cell for cell in self._current_row):
self._current_table.append(self._current_row)
self._current_row = None
elif tag == "table" and self._current_table is not None:
if self._current_table:
self.tables.append(self._current_table)
self._current_table = None
def handle_data(self, data: str) -> None:
if self._skip_depth:
return
if self._in_title:
self.title += data
if self._current_cell is not None:
self._current_cell.append(data)
@staticmethod
def _attrs(attrs: list[tuple[str, str | None]]) -> dict[str, str]:
return {key.casefold(): value or "" for key, value in attrs}
def parse_cornerstone_item_page(html: str, page_url: str | None = None) -> dict[str, Any]:
parser = CornerstonePageParser()
parser.feed(html)
info: dict[str, Any] = {"page_title": " ".join(parser.title.split())}
general: dict[str, str] = {}
locations = []
for table in parser.tables:
if not table:
continue
header = [cell.casefold() for cell in table[0]]
if len(header) >= 3 and "location" in header[0] and "price" in header[1] and "verified" in header[2]:
for row in table[1:]:
if len(row) < 3:
continue
locations.append(
{
"location": row[0],
"base_price": _parse_cornerstone_price(row[1]),
"base_price_display": row[1],
"verified": row[2],
}
)
elif all(len(row) >= 2 for row in table):
for row in table:
key = row[0].strip().lower().replace(" ", "_")
value = row[1].strip()
if key and value and key not in general:
general[key] = value
info["name"] = general.get("name") or _name_from_title(info["page_title"])
media = _dedupe_media(parser.images, page_url)
if media:
info["media"] = media
if general:
info["general"] = general
info["locations"] = locations
return info
def _parse_cornerstone_price(value: str) -> int | None:
digits = "".join(char for char in value if char.isdigit())
return int(digits) if digits else None
def _name_from_title(title: str) -> str | None:
if " - " not in title:
return title or None
return title.rsplit(" - ", 1)[-1].strip() or None
def _dedupe_media(images: list[dict[str, str]], page_url: str | None = None) -> list[dict[str, str]]:
media = []
seen = set()
for image in images:
raw_url = (image.get("url") or "").strip()
if not raw_url or raw_url.startswith("data:"):
continue
url = urljoin(page_url or "", raw_url)
if url in seen:
continue
seen.add(url)
item = dict(image)
item["url"] = url
media.append(item)
return media
+31 -3
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,9 +41,13 @@ 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
server_thread = threading.Thread(target=_run_server, args=(port,), daemon=True) if getattr(sys, "frozen", False):
server_thread.start() backend_process = _start_backend_process(port)
_log("backend thread started") _log(f"backend process started pid={backend_process.pid}")
else:
server_thread = threading.Thread(target=_run_server, args=(port,), daemon=True)
server_thread.start()
_log("backend thread started")
_wait_for_server(url) _wait_for_server(url)
_log("backend health check passed") _log("backend health check passed")
_open_window(url) _open_window(url)
@@ -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
+614
View File
@@ -0,0 +1,614 @@
from __future__ import annotations
import json
import uuid
from typing import Any
from traderai.memory import MemoryStore, iso_now
DEFAULT_PLAN_CADENCE = "0 */6 * * *"
class ContinualPlanStore:
def __init__(self, memory: MemoryStore) -> None:
self.memory = memory
self._init_db()
def _init_db(self) -> None:
with self.memory._connect() as db:
db.executescript(
"""
CREATE TABLE IF NOT EXISTS continual_plans (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
kind TEXT NOT NULL,
status TEXT NOT NULL,
objective TEXT NOT NULL,
constraints TEXT NOT NULL DEFAULT '{}',
cadence TEXT NOT NULL,
next_run_at TEXT,
last_run_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS continual_plan_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id TEXT NOT NULL,
item_name TEXT NOT NULL,
desired_quantity INTEGER NOT NULL DEFAULT 1,
max_unit_price REAL,
status TEXT NOT NULL DEFAULT 'active',
acquired_quantity INTEGER NOT NULL DEFAULT 0,
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS continual_plan_candidates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id TEXT NOT NULL,
plan_item_id INTEGER NOT NULL,
listing_id TEXT,
listing_slug TEXT,
title TEXT,
seller TEXT,
price REAL,
currency TEXT,
stock INTEGER,
location TEXT,
score REAL,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'current',
metadata TEXT NOT NULL DEFAULT '{}',
UNIQUE(plan_item_id, listing_id)
);
CREATE TABLE IF NOT EXISTS continual_plan_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id TEXT NOT NULL,
kind TEXT NOT NULL,
message TEXT NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS continual_plan_negotiations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id TEXT NOT NULL,
plan_item_id INTEGER,
candidate_id INTEGER,
listing_id TEXT,
listing_slug TEXT,
negotiation_id TEXT,
negotiation_hash TEXT,
status TEXT NOT NULL DEFAULT 'drafted',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
"""
)
def create_plan(
self,
title: str,
kind: str = "buying",
objective: str = "",
items: list[dict[str, Any]] | None = None,
constraints: dict[str, Any] | None = None,
cadence: str | None = None,
status: str | None = None,
) -> dict[str, Any]:
clean_items = [item for item in (items or []) if str(item.get("item_name") or item.get("name") or "").strip()]
plan_id = f"plan-{uuid.uuid4()}"
now = iso_now()
clean_kind = (kind.strip() or "buying").casefold()
resolved_status = status or ("needs_input" if clean_kind == "buying" and not clean_items else "active")
with self.memory._connect() as db:
db.execute(
"""
INSERT INTO continual_plans(id, title, kind, status, objective, constraints, cadence, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
plan_id,
title.strip() or "Continual plan",
clean_kind,
resolved_status,
objective.strip() or title.strip(),
json.dumps(constraints or {}),
(cadence or DEFAULT_PLAN_CADENCE).strip() or DEFAULT_PLAN_CADENCE,
now,
now,
),
)
for item in clean_items:
db.execute(
"""
INSERT INTO continual_plan_items(
plan_id, item_name, desired_quantity, max_unit_price, status,
acquired_quantity, metadata, created_at, updated_at
)
VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?)
""",
(
plan_id,
str(item.get("item_name") or item.get("name")).strip(),
max(1, int(item.get("desired_quantity") or item.get("quantity") or 1)),
item.get("max_unit_price"),
max(0, int(item.get("acquired_quantity") or 0)),
json.dumps(item.get("metadata") or {}),
now,
now,
),
)
if clean_kind == "buying" and not clean_items:
self.add_event(plan_id, "needs_input", "Created plan, but no item checklist was provided. Add the required parts before it can run.")
elif clean_items:
self.add_event(plan_id, "created", f"Created continual {clean_kind} plan with {len(clean_items)} checklist item(s).")
else:
self.add_event(plan_id, "created", f"Created continual {clean_kind} plan.")
return self.get_plan(plan_id) or {}
def list_plans(self, include_inactive: bool = True) -> list[dict[str, Any]]:
where = "" if include_inactive else "WHERE status = 'active'"
with self.memory._connect() as db:
rows = db.execute(
f"""
SELECT *
FROM continual_plans
{where}
ORDER BY
CASE status WHEN 'active' THEN 0 WHEN 'needs_input' THEN 1 WHEN 'paused' THEN 2 ELSE 3 END,
updated_at DESC
"""
).fetchall()
return [self._plan_row(row) for row in rows]
def get_plan(self, plan_id: str) -> dict[str, Any] | None:
with self.memory._connect() as db:
plan = db.execute("SELECT * FROM continual_plans WHERE id = ?", (plan_id,)).fetchone()
if not plan:
return None
data = self._plan_row(plan)
data["items"] = self.list_items(plan_id)
data["candidates"] = self.list_candidates(plan_id)
data["negotiations"] = self.list_negotiations(plan_id)
data["events"] = self.list_events(plan_id)
return data
def list_items(self, plan_id: str) -> list[dict[str, Any]]:
with self.memory._connect() as db:
rows = db.execute(
"SELECT * FROM continual_plan_items WHERE plan_id = ? ORDER BY id",
(plan_id,),
).fetchall()
return [self._json_row(row, "metadata") for row in rows]
def list_candidates(self, plan_id: str, limit: int = 100) -> list[dict[str, Any]]:
with self.memory._connect() as db:
rows = db.execute(
"""
SELECT *
FROM continual_plan_candidates
WHERE plan_id = ?
ORDER BY status = 'current' DESC, score DESC, last_seen_at DESC
LIMIT ?
""",
(plan_id, limit),
).fetchall()
return [self._json_row(row, "metadata") for row in rows]
def list_events(self, plan_id: str, limit: int = 50) -> list[dict[str, Any]]:
with self.memory._connect() as db:
rows = db.execute(
"""
SELECT *
FROM continual_plan_events
WHERE plan_id = ?
ORDER BY id DESC
LIMIT ?
""",
(plan_id, limit),
).fetchall()
return [self._json_row(row, "metadata") for row in rows]
def list_negotiations(self, plan_id: str) -> list[dict[str, Any]]:
with self.memory._connect() as db:
rows = db.execute(
"SELECT * FROM continual_plan_negotiations WHERE plan_id = ? ORDER BY updated_at DESC",
(plan_id,),
).fetchall()
return [dict(row) for row in rows]
def set_status(self, plan_id: str, status: str) -> dict[str, Any] | None:
with self.memory._connect() as db:
db.execute(
"UPDATE continual_plans SET status = ?, updated_at = ? WHERE id = ?",
(status, iso_now(), plan_id),
)
self.add_event(plan_id, status, f"Plan status changed to {status}.")
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]:
now = iso_now()
with self.memory._connect() as db:
cursor = db.execute(
"""
INSERT INTO continual_plan_events(plan_id, kind, message, metadata, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(plan_id, kind, message, json.dumps(metadata or {}), now),
)
return {"id": cursor.lastrowid, "plan_id": plan_id, "kind": kind, "message": message, "created_at": now}
def update_schedule(self, plan_id: str, next_run_at: str | None = None, last_run_at: str | None = None) -> None:
fields = ["next_run_at = ?", "updated_at = ?"]
values: list[Any] = [next_run_at, iso_now()]
if last_run_at is not None:
fields.insert(1, "last_run_at = ?")
values.insert(1, last_run_at)
values.append(plan_id)
with self.memory._connect() as db:
db.execute(f"UPDATE continual_plans SET {', '.join(fields)} WHERE id = ?", values)
def upsert_candidate(self, plan_id: str, plan_item_id: int, listing: dict[str, Any], score: float) -> dict[str, Any]:
now = iso_now()
listing_id = str(listing.get("id") or listing.get("listing_id") or listing.get("slug") or uuid.uuid4())
metadata = dict(listing)
with self.memory._connect() as db:
db.execute(
"""
INSERT INTO continual_plan_candidates(
plan_id, plan_item_id, listing_id, listing_slug, title, seller, price, currency,
stock, location, score, first_seen_at, last_seen_at, status, metadata
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'current', ?)
ON CONFLICT(plan_item_id, listing_id) DO UPDATE SET
listing_slug=excluded.listing_slug,
title=excluded.title,
seller=excluded.seller,
price=excluded.price,
currency=excluded.currency,
stock=excluded.stock,
location=excluded.location,
score=excluded.score,
last_seen_at=excluded.last_seen_at,
status='current',
metadata=excluded.metadata
""",
(
plan_id,
plan_item_id,
listing_id,
listing.get("slug"),
listing.get("title"),
listing.get("advertiser") or listing.get("user_username") or listing.get("seller"),
listing.get("price"),
listing.get("currency"),
listing.get("in_stock") or listing.get("stock"),
listing.get("location"),
score,
now,
now,
json.dumps(metadata),
),
)
row = db.execute(
"SELECT * FROM continual_plan_candidates WHERE plan_item_id = ? AND listing_id = ?",
(plan_item_id, listing_id),
).fetchone()
return self._json_row(row, "metadata")
def mark_stale_candidates(self, plan_item_id: int, seen_listing_ids: set[str]) -> int:
with self.memory._connect() as db:
rows = db.execute(
"SELECT id, listing_id FROM continual_plan_candidates WHERE plan_item_id = ? AND status = 'current'",
(plan_item_id,),
).fetchall()
stale_ids = [row["id"] for row in rows if str(row["listing_id"]) not in seen_listing_ids]
if stale_ids:
placeholders = ",".join("?" for _ in stale_ids)
db.execute(
f"UPDATE continual_plan_candidates SET status = 'stale', last_seen_at = ? WHERE id IN ({placeholders})",
(iso_now(), *stale_ids),
)
return len(stale_ids)
def mark_candidate_drafted(self, candidate_id: int) -> None:
with self.memory._connect() as db:
db.execute("UPDATE continual_plan_candidates SET status = 'drafted', last_seen_at = ? WHERE id = ?", (iso_now(), candidate_id))
def add_negotiation(self, plan_id: str, plan_item_id: int | None, candidate_id: int | None, metadata: dict[str, Any]) -> dict[str, Any]:
now = iso_now()
with self.memory._connect() as db:
cursor = db.execute(
"""
INSERT INTO continual_plan_negotiations(
plan_id, plan_item_id, candidate_id, listing_id, listing_slug,
negotiation_id, negotiation_hash, status, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
plan_id,
plan_item_id,
candidate_id,
metadata.get("listing_id"),
metadata.get("listing_slug"),
metadata.get("id_negotiation"),
metadata.get("hash"),
metadata.get("status") or "drafted",
now,
now,
),
)
row = db.execute("SELECT * FROM continual_plan_negotiations WHERE id = ?", (cursor.lastrowid,)).fetchone()
return dict(row)
def has_negotiation_for_candidate(self, plan_id: str, plan_item_id: int, candidate: dict[str, Any]) -> bool:
with self.memory._connect() as db:
row = db.execute(
"""
SELECT id
FROM continual_plan_negotiations
WHERE plan_id = ?
AND plan_item_id = ?
AND (
candidate_id = ?
OR (listing_id IS NOT NULL AND listing_id = ?)
OR (listing_slug IS NOT NULL AND listing_slug = ?)
)
LIMIT 1
""",
(
plan_id,
plan_item_id,
candidate.get("id"),
candidate.get("listing_id"),
candidate.get("listing_slug"),
),
).fetchone()
return row is not None
@staticmethod
def _json_row(row: Any, *json_fields: str) -> dict[str, Any]:
data = dict(row)
for field in json_fields:
try:
data[field] = json.loads(data.get(field) or "{}")
except (TypeError, json.JSONDecodeError):
data[field] = {}
return data
@classmethod
def _plan_row(cls, row: Any) -> dict[str, Any]:
return cls._json_row(row, "constraints")
class ContinualPlanRunner:
def __init__(self, store: ContinualPlanStore, tools: Any, memory: MemoryStore, agent: Any | None = None) -> None:
self.store = store
self.tools = tools
self.memory = memory
self.agent = agent
def bind_agent(self, agent: Any) -> None:
self.agent = agent
async def run_plan(self, plan_id: str) -> dict[str, Any]:
plan = self.store.get_plan(plan_id)
if not plan:
return {"error": f"Plan not found: {plan_id}"}
if plan["status"] != "active":
message = f"Skipped {plan['title']} because status is {plan['status']}."
self.store.add_event(plan_id, "skipped", message)
return {"status": "skipped", "summary": message, "plan": self.store.get_plan(plan_id)}
try:
if plan["kind"] == "buying":
result = await self._run_buying_plan(plan)
else:
result = await self._run_agent_plan(plan)
self.store.update_schedule(plan_id, plan.get("next_run_at"), last_run_at=iso_now())
self.memory.add_outbox(result["summary"])
return {**result, "plan": self.store.get_plan(plan_id)}
except Exception as exc:
message = f"Continual plan failed: {exc}"
self.store.add_event(plan_id, "error", message)
self.memory.add_outbox(f"{plan['title']}: {message}")
self.store.update_schedule(plan_id, plan.get("next_run_at"), last_run_at=iso_now())
return {"error": str(exc), "summary": message, "plan": self.store.get_plan(plan_id)}
async def _run_agent_plan(self, plan: dict[str, Any]) -> dict[str, Any]:
if self.agent is None:
raise RuntimeError("No agent is bound to run generic continual plans.")
prompt = self._agent_plan_prompt(plan)
response = await self.agent.generate_wake_response(prompt)
summary = f"{plan['title']}: {response}"
self.store.add_event(plan["id"], "run", "Ran generic continual plan through the agent.", {"response": response})
return {"status": "ok", "summary": summary, "checked": 0, "drafted": 0}
async def _run_buying_plan(self, plan: dict[str, Any]) -> dict[str, Any]:
items = [item for item in plan.get("items") or [] if item.get("status") != "acquired"]
if not items:
self.store.set_status(plan["id"], "completed")
summary = f"{plan['title']}: all checklist items are marked acquired."
return {"status": "completed", "summary": summary, "drafted": 0, "checked": 0}
checked = 0
drafted = 0
best_lines = []
constraints = plan.get("constraints") or {}
excluded_sellers = {str(value).casefold() for value in constraints.get("excluded_sellers") or []}
preferred_locations = [str(value).casefold() for value in constraints.get("preferred_locations") or []]
for item in items:
response = await self.tools.search_marketplace_listings(
query=item["item_name"],
operation="sell",
type="item",
limit=25,
)
listings = response.get("listings") or response.get("data") or []
seen: set[str] = set()
candidates = []
for listing in listings:
if not isinstance(listing, dict):
continue
listing_id = str(listing.get("id") or listing.get("slug") or "")
if listing_id:
seen.add(listing_id)
if str(listing.get("advertiser") or listing.get("seller") or "").casefold() in excluded_sellers:
continue
score = self._candidate_score(listing, item, preferred_locations)
candidate = self.store.upsert_candidate(plan["id"], int(item["id"]), listing, score)
candidates.append(candidate)
stale = self.store.mark_stale_candidates(int(item["id"]), seen)
checked += 1
current_candidates = [candidate for candidate in candidates if candidate.get("status") == "current"]
current_candidates.sort(key=lambda candidate: (-float(candidate.get("score") or 0), float(candidate.get("price") or 10**18)))
best = current_candidates[0] if current_candidates else None
if not best:
best_lines.append(f"{item['item_name']}: no active matching sell listings found.")
self.store.add_event(plan["id"], "search", f"{item['item_name']}: no active candidates found.", {"stale": stale})
continue
best_lines.append(
f"{item['item_name']}: best candidate is {best.get('title') or best.get('listing_slug')} "
f"at {self._format_price(best.get('price'), best.get('currency'))} from {best.get('seller') or 'unknown seller'}."
)
self.store.add_event(
plan["id"],
"search",
f"{item['item_name']}: found {len(current_candidates)} current candidate(s); {stale} stale candidate(s) marked.",
{"best_candidate_id": best.get("id")},
)
if self.store.has_negotiation_for_candidate(plan["id"], int(item["id"]), best) or not self._within_budget(best, item, constraints):
continue
draft = await self._draft_buying_message(plan, item, best)
if "pending_action" in draft:
drafted += 1
self.store.mark_candidate_drafted(int(best["id"]))
self.store.add_negotiation(
plan["id"],
int(item["id"]),
int(best["id"]),
{
"listing_id": best.get("listing_id"),
"listing_slug": best.get("listing_slug"),
"status": "drafted",
},
)
self.store.add_event(
plan["id"],
"draft",
f"Drafted negotiation opener for {item['item_name']} candidate {best.get('listing_id')}.",
{"pending_action_id": draft["pending_action"].get("id"), "candidate_id": best.get("id")},
)
summary = f"{plan['title']}: checked {checked} item(s). " + " ".join(best_lines[:4])
if drafted:
summary += f" Drafted {drafted} negotiation message(s) for approval."
self.store.add_event(plan["id"], "run", summary, {"checked": checked, "drafted": drafted})
return {"status": "ok", "summary": summary, "checked": checked, "drafted": drafted}
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"
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 = (
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"{build_context}If you still have it, I can move quickly."
).strip()
return await self.tools.draft_negotiation_message(
message=message,
id_listing=self._int_or_none(candidate.get("listing_id")),
plan_id=plan["id"],
plan_item_id=int(item["id"]),
candidate_id=int(candidate["id"]),
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
def _candidate_score(listing: dict[str, Any], item: dict[str, Any], preferred_locations: list[str]) -> float:
price = float(listing.get("price") or 10**12)
max_price = item.get("max_unit_price")
budget_bonus = 40.0 if max_price and price <= float(max_price) else 0.0
stock = float(listing.get("in_stock") or listing.get("stock") or 1)
location = str(listing.get("location") or "").casefold()
location_bonus = 8.0 if preferred_locations and any(place in location for place in preferred_locations) else 0.0
return round(max(0.0, 50.0 - (price / 10_000_000.0)) + min(stock, 20.0) + budget_bonus + location_bonus, 4)
@staticmethod
def _within_budget(candidate: dict[str, Any], item: dict[str, Any], constraints: dict[str, Any]) -> bool:
price = candidate.get("price")
if price is None:
return False
max_price = item.get("max_unit_price") or constraints.get("max_unit_price")
return max_price is None or float(price) <= float(max_price)
@staticmethod
def _format_price(price: Any, currency: Any) -> str:
if isinstance(price, (int, float)):
return f"{price:,.0f} {currency or 'UEC'}"
return f"unknown price {currency or 'UEC'}"
@staticmethod
def _int_or_none(value: Any) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
@staticmethod
def _agent_plan_prompt(plan: dict[str, Any]) -> str:
recent_events = [
{
"kind": event.get("kind"),
"message": event.get("message"),
"created_at": event.get("created_at"),
}
for event in (plan.get("events") or [])[:8]
]
payload = {
"plan_id": plan.get("id"),
"title": plan.get("title"),
"kind": plan.get("kind"),
"objective": plan.get("objective"),
"constraints": plan.get("constraints") or {},
"items": plan.get("items") or [],
"recent_events": recent_events,
}
return (
"Continual plan wake run. Continue this durable plan and write an Inbox-ready summary. "
"Use tools as needed. For any account-affecting marketplace write, only draft a pending action for approval. "
"Do not claim a message, offer, listing, or negotiation was sent unless an approved action result says it was sent. "
f"Plan JSON: {json.dumps(payload, ensure_ascii=True)}"
)
+82 -4
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime, timedelta
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
@@ -10,7 +10,7 @@ from apscheduler.triggers.date import DateTrigger
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
from tzlocal import get_localzone from tzlocal import get_localzone
from traderai.memory import MemoryStore, iso_now, time_since from traderai.memory import MemoryStore, iso_now, parse_iso, time_since, utc_now
UEX_NOTIFICATION_JOB_ID = "uex-notification-poll" UEX_NOTIFICATION_JOB_ID = "uex-notification-poll"
@@ -22,11 +22,19 @@ class WakeScheduler:
self.scheduler = AsyncIOScheduler(timezone=get_localzone()) self.scheduler = AsyncIOScheduler(timezone=get_localzone())
self.agent = None self.agent = None
self.uex = None self.uex = 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:
self.agent = agent self.agent = agent
def bind_plan_runner(self, plan_runner: Any) -> None:
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)
@@ -37,6 +45,9 @@ class WakeScheduler:
self._schedule_notification_poll() self._schedule_notification_poll()
for job in self.memory.list_jobs(): for job in self.memory.list_jobs():
self._schedule_existing(job) self._schedule_existing(job)
if self.plan_runner is not None:
for plan in self.plan_runner.store.list_plans(include_inactive=False):
self.schedule_plan(plan)
def shutdown(self) -> None: def shutdown(self) -> None:
if self.scheduler.running: if self.scheduler.running:
@@ -59,6 +70,70 @@ class WakeScheduler:
def list_jobs(self) -> list[dict[str, Any]]: def list_jobs(self) -> list[dict[str, Any]]:
return self.memory.list_jobs() return self.memory.list_jobs()
def schedule_plan(self, plan: dict[str, Any]) -> dict[str, Any]:
if self.plan_runner is None or plan.get("status") != "active":
return plan
job_id = self._plan_job_id(plan["id"])
previous_next_run = plan.get("next_run_at")
trigger = CronTrigger.from_crontab(plan.get("cadence") or "0 */6 * * *")
self.scheduler.add_job(self._run_plan, trigger=trigger, id=job_id, args=[plan["id"]], replace_existing=True)
job = self.scheduler.get_job(job_id)
next_run = job.next_run_time if job else None
self.plan_runner.store.update_schedule(plan["id"], next_run.isoformat() if next_run else None)
if self._plan_is_overdue(previous_next_run):
catchup_id = self._plan_catchup_job_id(plan["id"])
self.scheduler.add_job(
self._run_plan,
trigger=DateTrigger(run_date=datetime.now() + timedelta(seconds=5)),
id=catchup_id,
args=[plan["id"]],
replace_existing=True,
)
self.plan_runner.store.add_event(
plan["id"],
"catchup_scheduled",
"Plan was overdue while the app was closed, so a one-time catch-up run was scheduled after startup.",
{"previous_next_run_at": previous_next_run},
)
return self.plan_runner.store.get_plan(plan["id"]) or plan
def unschedule_plan(self, plan_id: str) -> None:
job_id = self._plan_job_id(plan_id)
if self.scheduler.get_job(job_id):
self.scheduler.remove_job(job_id)
catchup_id = self._plan_catchup_job_id(plan_id)
if self.scheduler.get_job(catchup_id):
self.scheduler.remove_job(catchup_id)
if self.plan_runner is not None:
self.plan_runner.store.update_schedule(plan_id, None)
async def _run_plan(self, plan_id: str) -> None:
if self.plan_runner is None:
return
result = await self.plan_runner.run_plan(plan_id)
plan = result.get("plan") or self.plan_runner.store.get_plan(plan_id)
if plan and plan.get("status") == "active":
job = self.scheduler.get_job(self._plan_job_id(plan_id))
next_run = job.next_run_time if job else None
self.plan_runner.store.update_schedule(plan_id, next_run.isoformat() if next_run else None)
@staticmethod
def _plan_job_id(plan_id: str) -> str:
return f"continual-{plan_id}"
@staticmethod
def _plan_catchup_job_id(plan_id: str) -> str:
return f"continual-catchup-{plan_id}"
@staticmethod
def _plan_is_overdue(next_run_at: str | None) -> bool:
if not next_run_at:
return False
try:
return parse_iso(next_run_at) <= utc_now()
except ValueError:
return False
def _schedule_existing(self, job: dict[str, Any]) -> None: def _schedule_existing(self, job: dict[str, Any]) -> None:
if job["trigger_type"] == "cron": if job["trigger_type"] == "cron":
trigger = CronTrigger.from_crontab(job["trigger_value"]) trigger = CronTrigger.from_crontab(job["trigger_value"])
@@ -126,8 +201,11 @@ 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:
for item in new_pending: if self.negotiation_sync is not None:
self.memory.add_outbox(self._notification_text(item)) await self.negotiation_sync.handle_notifications(new_pending)
else:
for item in new_pending:
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)
self.memory.set_profile("uex_seen_notification_keys", sorted(seen)) self.memory.set_profile("uex_seen_notification_keys", sorted(seen))
self.memory.set_profile("uex_last_notification_check", iso_now()) self.memory.set_profile("uex_last_notification_check", iso_now())
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
from typing import Any
import httpx
class SCMDBError(RuntimeError):
pass
class SCMDBClient:
def __init__(self, base_url: str = "https://scmdb.net") -> None:
self.base_url = base_url.rstrip("/")
self._versions: list[dict[str, Any]] | None = None
self._data_cache: dict[str, dict[str, Any]] = {}
async def list_versions(self) -> list[dict[str, Any]]:
if self._versions is not None:
return self._versions
body = await self._get_json("data/versions.json")
if not isinstance(body, list):
raise SCMDBError("SCMDB versions response was not a list.")
self._versions = [
item
for item in body
if isinstance(item, dict) and item.get("version") and item.get("file")
]
return self._versions
async def get_data(self, version: str | None = None, channel: str = "live") -> dict[str, Any]:
selected = await self.resolve_version(version=version, channel=channel)
cache_key = str(selected["version"])
if cache_key not in self._data_cache:
body = await self._get_json(f"data/{selected['file']}")
if not isinstance(body, dict):
raise SCMDBError(f"SCMDB data for {cache_key} was not an object.")
self._data_cache[cache_key] = body
return self._data_cache[cache_key]
async def resolve_version(self, version: str | None = None, channel: str = "live") -> dict[str, Any]:
versions = await self.list_versions()
if not versions:
raise SCMDBError("SCMDB did not return any data versions.")
if version:
needle = version.casefold().strip()
for item in versions:
item_version = str(item["version"])
if item_version.casefold() == needle or needle in item_version.casefold():
return item
raise SCMDBError(f"SCMDB version not found: {version}")
channel = (channel or "live").casefold().strip()
if channel in {"latest", "any", "all"}:
return versions[0]
if channel not in {"live", "ptu"}:
raise SCMDBError("SCMDB channel must be live, ptu, or latest.")
for item in versions:
if f"-{channel}." in str(item["version"]).casefold():
return item
return versions[0]
async def _get_json(self, path: str) -> Any:
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
response = await client.get(f"{self.base_url}/{path.lstrip('/')}", headers={"Accept": "application/json"})
try:
body = response.json()
except ValueError as exc:
raise SCMDBError(f"SCMDB returned non-JSON response: HTTP {response.status_code}") from exc
if response.status_code >= 400:
raise SCMDBError(f"SCMDB HTTP {response.status_code}: {body}")
return body
+873 -25
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
+1623 -14
View File
File diff suppressed because it is too large Load Diff
+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(
+8 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
__version__ = "0.0.3" __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"
@@ -9,3 +9,10 @@ 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
+8 -1
View File
@@ -755,7 +755,7 @@ wheels = [
[[package]] [[package]]
name = "traderai" name = "traderai"
version = "0.0.3" version = "0.0.9"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "apscheduler" }, { name = "apscheduler" },
@@ -1049,3 +1049,10 @@ wheels = [
+1058 -49
View File
File diff suppressed because it is too large Load Diff
+223 -38
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,17 +65,21 @@
<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>
<div class="warning" id="warning" hidden></div> <div class="warning" id="warning" hidden></div>
<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">
<textarea id="message-input" rows="2" placeholder="Search listings, draft a reply, prepare an offer..."></textarea> <div class="composer-main">
<button type="submit">Send</button> <textarea id="message-input" rows="2" placeholder="Search listings, draft a reply, prepare an offer..."></textarea>
</form> <div class="composer-images" id="composer-images" hidden></div>
</div> </div>
<button type="submit">Send</button>
</form>
</div>
</section> </section>
<aside class="actions"> <aside class="actions">
<section class="side-section"> <section class="side-section">
@@ -60,21 +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="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>
@@ -84,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>
@@ -121,14 +134,26 @@
</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">
@@ -139,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 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">
<i data-lucide="x" aria-hidden="true"></i>
</button>
</div> </div>
<button class="icon-button light" id="negotiation-close" type="button" title="Close">
<i data-lucide="x" aria-hidden="true"></i>
</button>
</div> </div>
<div class="negotiation-messages" id="negotiation-messages"></div> <div class="negotiation-workspace">
<form class="negotiation-composer" id="negotiation-form"> <aside class="negotiation-sidebar">
<textarea id="negotiation-input" rows="2" placeholder="Reply to the other party..."></textarea> <div class="negotiation-sidebar-controls">
<button type="submit">Send</button> <input id="negotiation-search" type="text" placeholder="Search negotiations">
</form> <select id="negotiation-filter">
<div class="config-status" id="negotiation-status"></div> <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>
<form class="negotiation-composer" id="negotiation-form">
<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>
</div>
</form>
<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">
+980 -36
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>