7 Commits

Author SHA1 Message Date
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
28 changed files with 7403 additions and 306 deletions
+13 -1
View File
@@ -1,12 +1,24 @@
MODEL_PROVIDER=ollama
OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=qwen3.5:9b OLLAMA_MODEL=qwen3.5:9b
OLLAMA_NUM_CTX=64512 OLLAMA_NUM_CTX=64512
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_MODEL=gpt-5.4-mini
OPENAI_API_KEY=
DEEPSEEK_BASE_URL=https://api.deepseek.com
DEEPSEEK_MODEL=deepseek-v4-flash
DEEPSEEK_API_KEY=
MODEL_REASONING_EFFORT=medium
CODEX_COMMAND=codex
CODEX_MODEL=gpt-5.4
UEX_BASE_URL=https://api.uexcorp.space/2.0 UEX_BASE_URL=https://api.uexcorp.space/2.0
SCMDB_BASE_URL=https://scmdb.net SCMDB_BASE_URL=https://scmdb.net
CORNERSTONE_BASE_URL=https://finder.cstone.space CORNERSTONE_BASE_URL=https://finder.cstone.space
SCWIKI_BASE_URL=https://starcitizen.tools
SCWIKI_API_BASE_URL=https://api.star-citizen.wiki
UEX_SECRET_KEY= UEX_SECRET_KEY=
UEX_BEARER_TOKEN= UEX_BEARER_TOKEN=
TRADERAI_USER_NAME= TRADERAI_USER_NAME=
TRADERAI_MEMORY_PATH= TRADERAI_MEMORY_PATH=
UEX_NOTIFICATION_POLL_SECONDS=60 UEX_NOTIFICATION_POLL_SECONDS=300
REQUIRE_WRITE_APPROVAL=true REQUIRE_WRITE_APPROVAL=true
+6 -2
View File
@@ -1,6 +1,6 @@
# TraderAI # TraderAI
Local Ollama-powered chat for UEX marketplace workflows. Local Ollama-, DeepSeek-, OpenAI-, or Codex-powered chat for UEX marketplace workflows.
## What It Does ## What It Does
@@ -25,6 +25,10 @@ Local Ollama-powered chat for UEX marketplace workflows.
``` ```
3. Create `.env` from `.env.example` and set `UEX_SECRET_KEY` and/or `UEX_BEARER_TOKEN` if you want authenticated actions. 3. Create `.env` from `.env.example` and set `UEX_SECRET_KEY` and/or `UEX_BEARER_TOKEN` if you want authenticated actions.
If you want the cheapest hosted default, set `MODEL_PROVIDER=deepseek`, set `DEEPSEEK_API_KEY`, and keep `DEEPSEEK_MODEL=deepseek-v4-flash` unless you specifically want `deepseek-v4-pro`.
If you want to use OpenAI instead of Ollama, set `MODEL_PROVIDER=openai`, set `OPENAI_API_KEY`, and optionally change `OPENAI_MODEL` from the default `gpt-5.4-mini`.
If you want to use Codex models with ChatGPT/Codex OAuth, install the Codex CLI, set `MODEL_PROVIDER=codex`, and optionally change `CODEX_MODEL` from the default `gpt-5.4`. TraderAI uses the local `codex app-server` JSON-RPC interface for both authentication and chat turns.
`MODEL_REASONING_EFFORT` controls reasoning depth for DeepSeek, OpenAI, and Codex and defaults to `medium`.
`SCMDB_BASE_URL` defaults to `https://scmdb.net`. `SCMDB_BASE_URL` defaults to `https://scmdb.net`.
`CORNERSTONE_BASE_URL` defaults to `https://finder.cstone.space`. `CORNERSTONE_BASE_URL` defaults to `https://finder.cstone.space`.
4. Install and run: 4. Install and run:
@@ -38,7 +42,7 @@ Local Ollama-powered chat for UEX marketplace workflows.
## Notes ## Notes
Ollama runs locally at `http://localhost:11434` by default. This app talks to Ollama's native chat API with tool schemas, then executes approved UEX calls in the FastAPI backend. `OLLAMA_NUM_CTX` controls the per-request Ollama context window; `64512` is the default because Ollama recommends at least 64k tokens for agent-style workflows when hardware allows it. Ollama runs locally at `http://localhost:11434` by default. This app can talk to Ollama's native chat API, DeepSeek's OpenAI-compatible Chat Completions API, OpenAI's Chat Completions API, or the local Codex App Server authenticated through ChatGPT/Codex OAuth, then executes approved UEX calls in the FastAPI backend. `OLLAMA_NUM_CTX` controls the per-request Ollama context window; `64512` is the default because Ollama recommends at least 64k tokens for agent-style workflows when hardware allows it. DeepSeek context caching is provider-side and automatic when repeated prompt prefixes line up.
## Releases And Updates ## Releases And Updates
+8 -2
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "traderai" name = "traderai"
version = "0.0.3" version = "0.0.8"
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,9 @@ include = ["traderai*"]
+175
View File
@@ -1,5 +1,6 @@
import pytest import pytest
import asyncio import asyncio
import itertools
from traderai.agent import OllamaAgent, SYSTEM_PROMPT from traderai.agent import OllamaAgent, SYSTEM_PROMPT
from traderai.memory import MemoryStore from traderai.memory import MemoryStore
@@ -64,6 +65,19 @@ class TitleAgent(OllamaAgent):
return {"message": {"role": "assistant", "content": "Done"}} return {"message": {"role": "assistant", "content": "Done"}}
class ImageCaptureAgent(OllamaAgent):
def __init__(self, memory):
super().__init__("http://127.0.0.1:1", "missing-model", EmptyTools(), memory=memory)
self.last_messages = None
async def ensure_available(self):
return None
async def _chat_once(self, query="", messages=None, **kwargs):
self.last_messages = messages
return {"message": {"role": "assistant", "content": "Seen"}}
class SlowToolTools(EmptyTools): class SlowToolTools(EmptyTools):
schemas = [ schemas = [
{ {
@@ -204,6 +218,150 @@ def test_ollama_options_include_num_ctx():
assert agent._ollama_options() == {"num_ctx": 64000} assert agent._ollama_options() == {"num_ctx": 64000}
def test_deepseek_tool_rounds_are_not_capped_at_ten():
agent = OllamaAgent("https://api.deepseek.com", "deepseek-v4-flash", EmptyTools(), provider="deepseek", api_key="test")
rounds = list(itertools.islice(agent._tool_rounds(), 12))
assert len(rounds) == 12
def test_plan_draft_normalization_extracts_json_and_defaults():
seed = {"title": "Wikelo Polaris", "objective": "Find parts", "kind": "buying", "constraints": {}, "items": []}
raw = 'draft:\n{"title":"Wikelo Polaris Parts","objective":"Find and draft deals for the parts below","kind":"buying","cadence":"0 */3 * * *","constraints":{"message_tone":"casual","instructions":"Prioritize cheap listings first."},"items":[{"item_name":"RCMBNT-RGL-1","desired_quantity":2}]}'
draft = OllamaAgent._normalize_plan_draft(raw, seed)
assert draft["title"] == "Wikelo Polaris Parts"
assert draft["cadence"] == "0 */3 * * *"
assert draft["constraints"]["message_tone"] == "casual"
assert draft["items"][0]["item_name"] == "RCMBNT-RGL-1"
assert draft["items"][0]["desired_quantity"] == 2
def test_plan_draft_heuristic_fills_in_basic_instructions():
seed = {"title": "Watch open negotiations", "objective": "", "kind": "custom", "constraints": {}, "items": []}
draft = OllamaAgent._heuristic_plan_draft(seed)
assert draft["kind"] == "custom"
assert draft["cadence"] == "0 */4 * * *"
assert "summarize" in draft["constraints"]["instructions"].casefold()
assert draft["constraints"]["message_tone"] == "friendly and direct"
def test_codex_prompt_mentions_tools_and_images(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
agent = OllamaAgent("codex", "gpt-5.3-codex", EmptyTools(), memory=memory, provider="codex")
prompt = agent._codex_cli_prompt(
"check listing",
[
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": "Look at this",
"images": ["ZmFrZQ=="],
"image_content_types": ["image/png"],
},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_123",
"type": "function",
"function": {"name": "search_marketplace_listings", "arguments": "{\"commodity\":\"gold\"}"},
}
],
},
{
"role": "tool",
"tool_name": "search_marketplace_listings",
"tool_call_id": "call_123",
"content": "{\"ok\":true}",
},
],
)
assert "Available tools" in prompt
assert "attached images: 1" in prompt
assert "search_marketplace_listings" in prompt
assert "tool search_marketplace_listings" in prompt
def test_deepseek_openai_messages_include_reasoning_content_for_tool_turns():
agent = OllamaAgent("https://api.deepseek.com", "deepseek-v4-flash", EmptyTools(), provider="deepseek", api_key="test")
messages = agent._openai_messages(
"check listing",
[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": "Check this listing"},
{
"role": "assistant",
"content": "",
"reasoning_content": "I should check the current listing first.",
"tool_calls": [
{
"id": "call_123",
"type": "function",
"function": {"name": "search_marketplace_listings", "arguments": "{\"query\":\"panel\"}"},
}
],
},
{"role": "tool", "tool_name": "search_marketplace_listings", "tool_call_id": "call_123", "content": "{\"ok\":true}"},
],
)
assistant_turn = next(message for message in messages if message["role"] == "assistant")
assert assistant_turn["reasoning_content"] == "I should check the current listing first."
def test_codex_structured_response_extracts_text_and_tool_calls():
agent = OllamaAgent("codex", "gpt-5.3-codex", EmptyTools(), provider="codex")
result = agent._codex_structured_response(
{
"kind": "tool_call",
"message": "",
"tool_name": "search_marketplace_listings",
"arguments_json": "{\"commodity\":\"gold\"}",
}
)
assert result["message"]["content"] == ""
assert result["message"]["tool_calls"] == [
{
"id": result["message"]["tool_calls"][0]["id"],
"type": "function",
"function": {
"name": "search_marketplace_listings",
"arguments": "{\"commodity\":\"gold\"}",
},
}
]
def test_parse_codex_exec_output_reads_final_json():
agent = OllamaAgent("codex", "gpt-5.3-codex", EmptyTools(), provider="codex")
result = agent._parse_codex_exec_output(
{
"returncode": 0,
"stdout": "",
"stderr": "",
"events": [
{"type": "thread.started", "thread_id": "abc"},
{"type": "item.completed", "item": {"type": "agent_message", "text": "{\"kind\":\"final\",\"message\":\"hello\",\"tool_name\":\"\",\"arguments_json\":\"{}\"}"}},
{"type": "turn.completed"},
],
}
)
assert result == {"kind": "final", "message": "hello", "tool_name": "", "arguments_json": "{}"}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_wake_response_executes_tool_calls(tmp_path): async def test_wake_response_executes_tool_calls(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3")) memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
@@ -229,6 +387,23 @@ async def test_first_chat_message_generates_thread_title(tmp_path):
assert memory.get_thread(thread["id"])["title"] == "UEX Market Check" assert memory.get_thread(thread["id"])["title"] == "UEX Market Check"
@pytest.mark.asyncio
async def test_chat_includes_pasted_images_and_memory_note(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
agent = ImageCaptureAgent(memory)
result = await agent.chat(
"",
images=[{"name": "listing.png", "content_type": "image/png", "image_data": "ZmFrZS1pbWFnZQ=="}],
)
assert result["message"] == "Seen"
user_message = next(message for message in reversed(agent.last_messages) if message.get("role") == "user")
assert user_message["images"] == ["ZmFrZS1pbWFnZQ=="]
assert user_message["content"] == "Please analyze the attached image."
assert "[Attached 1 pasted image]" in memory.recent_conversation()[-2]["content"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_chat_events_returns_fallback_after_slow_tool_and_empty_final_response(tmp_path): async def test_chat_events_returns_fallback_after_slow_tool_and_empty_final_response(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3")) memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
+43
View File
@@ -0,0 +1,43 @@
from traderai.config import Settings
def test_model_provider_codex_falls_back_to_ollama():
settings = Settings(model_provider="codex")
assert settings.model_provider == "ollama"
def test_model_provider_openai_falls_back_to_ollama():
settings = Settings(model_provider="openai")
assert settings.model_provider == "ollama"
def test_model_provider_accepts_deepseek():
settings = Settings(model_provider="deepseek")
assert settings.model_provider == "deepseek"
def test_model_provider_invalid_value_falls_back_to_ollama():
settings = Settings(model_provider="something-else")
assert settings.model_provider == "ollama"
def test_reasoning_effort_normalizes_invalid_values():
settings = Settings(model_reasoning_effort="whatever")
assert settings.model_reasoning_effort == "medium"
def test_reasoning_effort_accepts_supported_values():
settings = Settings(model_reasoning_effort="high")
assert settings.model_reasoning_effort == "high"
def test_reasoning_effort_accepts_max():
settings = Settings(model_reasoning_effort="max")
assert settings.model_reasoning_effort == "max"
+19
View File
@@ -55,3 +55,22 @@ def test_memory_store_renames_threads_and_deletes_outbox_items(tmp_path):
assert renamed["title"] == "Market Check" assert renamed["title"] == "Market Check"
assert deleted is True assert deleted is True
assert store.list_outbox() == [] assert store.list_outbox() == []
def test_memory_store_uses_absolute_path_across_working_directory_changes(tmp_path, monkeypatch):
original_cwd = tmp_path / "start"
original_cwd.mkdir()
monkeypatch.chdir(original_cwd)
store = MemoryStore("data/memory.sqlite3")
moved_cwd = tmp_path / "moved"
moved_cwd.mkdir()
monkeypatch.chdir(moved_cwd)
store.add_outbox("Notification survived cwd change")
snapshot = store.inspect()
assert store.path.is_absolute()
assert snapshot["outbox"][0]["content"] == "Notification survived cwd change"
+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
+257
View File
@@ -0,0 +1,257 @@
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,
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",
)
+366 -3
View File
@@ -10,8 +10,10 @@ from traderai.uex_client import UEXClient
class FakeUEX: class FakeUEX:
def __init__(self): def __init__(self):
self.posts = [] self.posts = []
self.get_calls = []
async def get(self, path, params=None, authenticated=False): async def get(self, path, params=None, authenticated=False):
self.get_calls.append({"path": path, "params": params, "authenticated": authenticated})
if path == "commodities_prices_history": if path == "commodities_prices_history":
return { return {
"status": "ok", "status": "ok",
@@ -80,6 +82,34 @@ class FakeUEX:
}, },
], ],
} }
if path == "marketplace_trends":
return {
"status": "ok",
"data": [
{
"id_item": 2791,
"item_name": "\"Quantanium\" Water Bottle",
"item_slug": "quantanium-water-bottle",
"currency": "UEC",
"price_avg_sell": "937500",
"price_avg_month_sell": "1072222",
"price_min_sell": "750000",
"price_max_sell": "1200000",
"listings_count_sell": 4,
"price_avg_buy": "500000",
"price_avg_month_buy": "525000",
"price_min_buy": "450000",
"price_max_buy": "550000",
"listings_count_buy": 2,
"total_listings_count": 6,
"negotiations_count": 18,
"negotiations_open": 7,
"negotiations_success": 9,
"link_prices": "https://uexcorp.space/marketplace/home/?id_item=2791&mode=list",
"link_prices_history": "https://uexcorp.space/marketplace/averages/?id_item=2791&quality_tier=q0&unit=unit",
}
],
}
assert path == "marketplace_listings" assert path == "marketplace_listings"
return { return {
"data": [ "data": [
@@ -230,7 +260,10 @@ class FakeCornerstone:
"url": f"{self.base_url}/ShipSalvageMods1/{item_id}", "url": f"{self.base_url}/ShipSalvageMods1/{item_id}",
"html": """ "html": """
<html> <html>
<head><title>Star Citizen - Salvage modifier - Abrade Scraper Module</title></head> <head>
<title>Star Citizen - Salvage modifier - Abrade Scraper Module</title>
<meta property="og:image" content="/images/abrade.png">
</head>
<body> <body>
<table> <table>
<tr><td>NAME</td><td>Abrade Scraper Module</td></tr> <tr><td>NAME</td><td>Abrade Scraper Module</td></tr>
@@ -246,6 +279,125 @@ class FakeCornerstone:
""", """,
} }
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():
@@ -321,6 +473,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())
@@ -356,6 +567,7 @@ def test_schemas_expose_specific_uex_tools_instead_of_generic_api_tool():
assert "get_uex_commodities_prices" in names assert "get_uex_commodities_prices" in names
assert "get_uex_vehicles" in names assert "get_uex_vehicles" in names
assert "get_marketplace_trends" in names
assert "draft_uex_marketplace_advertise" in names assert "draft_uex_marketplace_advertise" in names
assert "delete_uex_marketplace_listings" in names assert "delete_uex_marketplace_listings" in names
assert "uex_get" not in names assert "uex_get" not in names
@@ -379,6 +591,19 @@ def test_schemas_expose_cornerstone_item_tools():
assert "search_cornerstone_items" in names assert "search_cornerstone_items" in names
assert "get_cornerstone_item_locations" 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 @pytest.mark.asyncio
@@ -441,18 +666,156 @@ async def test_get_cornerstone_item_locations_parses_store_prices():
] ]
@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(): def test_parse_cornerstone_item_page_extracts_locations():
parsed = parse_cornerstone_item_page( parsed = parse_cornerstone_item_page(
""" """
<html><head><title>Star Citizen - Food - Whamburger</title></head> <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> <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> <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> <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["name"] == "Whamburger"
assert parsed["locations"][0]["base_price"] == 9 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 @pytest.mark.asyncio
+453
View File
File diff suppressed because one or more lines are too long
+1343 -56
View File
File diff suppressed because it is too large Load Diff
+39 -3
View File
@@ -11,12 +11,24 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
CONFIG_FIELDS: dict[str, dict[str, Any]] = { CONFIG_FIELDS: dict[str, dict[str, Any]] = {
"model_provider": {"env": "MODEL_PROVIDER", "type": "string", "secret": False},
"ollama_base_url": {"env": "OLLAMA_BASE_URL", "type": "string", "secret": False}, "ollama_base_url": {"env": "OLLAMA_BASE_URL", "type": "string", "secret": False},
"ollama_model": {"env": "OLLAMA_MODEL", "type": "string", "secret": False}, "ollama_model": {"env": "OLLAMA_MODEL", "type": "string", "secret": False},
"ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False}, "ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False},
"openai_base_url": {"env": "OPENAI_BASE_URL", "type": "string", "secret": False},
"openai_model": {"env": "OPENAI_MODEL", "type": "string", "secret": False},
"deepseek_base_url": {"env": "DEEPSEEK_BASE_URL", "type": "string", "secret": False},
"deepseek_model": {"env": "DEEPSEEK_MODEL", "type": "string", "secret": False},
"model_reasoning_effort": {"env": "MODEL_REASONING_EFFORT", "type": "string", "secret": False},
"codex_command": {"env": "CODEX_COMMAND", "type": "string", "secret": False},
"codex_model": {"env": "CODEX_MODEL", "type": "string", "secret": False},
"uex_base_url": {"env": "UEX_BASE_URL", "type": "string", "secret": False}, "uex_base_url": {"env": "UEX_BASE_URL", "type": "string", "secret": False},
"scmdb_base_url": {"env": "SCMDB_BASE_URL", "type": "string", "secret": False}, "scmdb_base_url": {"env": "SCMDB_BASE_URL", "type": "string", "secret": False},
"cornerstone_base_url": {"env": "CORNERSTONE_BASE_URL", "type": "string", "secret": False}, "cornerstone_base_url": {"env": "CORNERSTONE_BASE_URL", "type": "string", "secret": False},
"scwiki_base_url": {"env": "SCWIKI_BASE_URL", "type": "string", "secret": False},
"scwiki_api_base_url": {"env": "SCWIKI_API_BASE_URL", "type": "string", "secret": False},
"openai_api_key": {"env": "OPENAI_API_KEY", "type": "string", "secret": True},
"deepseek_api_key": {"env": "DEEPSEEK_API_KEY", "type": "string", "secret": True},
"uex_secret_key": {"env": "UEX_SECRET_KEY", "type": "string", "secret": True}, "uex_secret_key": {"env": "UEX_SECRET_KEY", "type": "string", "secret": True},
"uex_bearer_token": {"env": "UEX_BEARER_TOKEN", "type": "string", "secret": True}, "uex_bearer_token": {"env": "UEX_BEARER_TOKEN", "type": "string", "secret": True},
"traderai_user_name": {"env": "TRADERAI_USER_NAME", "type": "string", "secret": False}, "traderai_user_name": {"env": "TRADERAI_USER_NAME", "type": "string", "secret": False},
@@ -62,24 +74,48 @@ class Settings(BaseSettings):
env_file_encoding="utf-8", env_file_encoding="utf-8",
) )
model_provider: str = "ollama"
ollama_base_url: str = "http://localhost:11434" ollama_base_url: str = "http://localhost:11434"
ollama_model: str = "qwen3.5:9b" ollama_model: str = "qwen3.5:9b"
ollama_num_ctx: int = 64512 ollama_num_ctx: int = 64512
openai_base_url: str = "https://api.openai.com/v1"
openai_model: str = "gpt-5.4-mini"
deepseek_base_url: str = "https://api.deepseek.com"
deepseek_model: str = "deepseek-v4-flash"
model_reasoning_effort: str = "medium"
codex_command: str = "codex"
codex_model: str = "gpt-5.4"
uex_base_url: str = "https://api.uexcorp.space/2.0" uex_base_url: str = "https://api.uexcorp.space/2.0"
scmdb_base_url: str = "https://scmdb.net" scmdb_base_url: str = "https://scmdb.net"
cornerstone_base_url: str = "https://finder.cstone.space" cornerstone_base_url: str = "https://finder.cstone.space"
scwiki_base_url: str = "https://starcitizen.tools"
scwiki_api_base_url: str = "https://api.star-citizen.wiki"
openai_api_key: str | None = Field(default=None)
deepseek_api_key: str | None = Field(default=None)
uex_secret_key: str | None = Field(default=None) uex_secret_key: str | None = Field(default=None)
uex_bearer_token: str | None = Field(default=None) uex_bearer_token: str | None = Field(default=None)
traderai_user_name: str | None = Field(default=None) traderai_user_name: str | None = Field(default=None)
traderai_memory_path: str = Field(default_factory=lambda: str(default_memory_path())) traderai_memory_path: str = Field(default_factory=lambda: str(default_memory_path()))
uex_notification_poll_seconds: int = 60 uex_notification_poll_seconds: int = 300
require_write_approval: bool = True require_write_approval: bool = True
@field_validator("uex_secret_key", "uex_bearer_token", "traderai_user_name", mode="before") @field_validator("openai_api_key", "deepseek_api_key", "uex_secret_key", "uex_bearer_token", "traderai_user_name", mode="before")
@classmethod @classmethod
def _blank_optional(cls, value: Any) -> Any: def _blank_optional(cls, value: Any) -> Any:
return None if value == "" else value return None if value == "" else value
@field_validator("model_provider", mode="before")
@classmethod
def _normalize_model_provider(cls, value: Any) -> str:
text = str(value or "ollama").strip().casefold()
return text if text in {"ollama", "deepseek"} else "ollama"
@field_validator("model_reasoning_effort", mode="before")
@classmethod
def _normalize_reasoning_effort(cls, value: Any) -> str:
text = str(value or "medium").strip().casefold()
return text if text in {"none", "minimal", "low", "medium", "high", "xhigh", "max"} else "medium"
@field_validator("traderai_memory_path", mode="before") @field_validator("traderai_memory_path", mode="before")
@classmethod @classmethod
def _blank_memory_path(cls, value: Any) -> Any: def _blank_memory_path(cls, value: Any) -> Any:
@@ -137,7 +173,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":
+68 -1
View File
@@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
from html.parser import HTMLParser from html.parser import HTMLParser
import base64
import json import json
from typing import Any from typing import Any
from urllib.parse import urljoin
import httpx import httpx
@@ -41,6 +43,23 @@ class CornerstoneClient:
raise CornerstoneError(f"Cornerstone HTTP {response.status_code}: {response.text[:240]}") raise CornerstoneError(f"Cornerstone HTTP {response.status_code}: {response.text[:240]}")
return {"url": str(response.url), "html": response.text} 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 def _get_json(self, path: str) -> Any:
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client: 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"}) response = await client.get(f"{self.base_url}/{path.lstrip('/')}", headers={"Accept": "application/json"})
@@ -58,6 +77,7 @@ class CornerstonePageParser(HTMLParser):
super().__init__(convert_charrefs=True) super().__init__(convert_charrefs=True)
self.title = "" self.title = ""
self.tables: list[list[list[str]]] = [] self.tables: list[list[list[str]]] = []
self.images: list[dict[str, str]] = []
self._skip_depth = 0 self._skip_depth = 0
self._in_title = False self._in_title = False
self._current_table: list[list[str]] | None = None self._current_table: list[list[str]] | None = None
@@ -73,6 +93,29 @@ class CornerstonePageParser(HTMLParser):
return return
if tag == "title": if tag == "title":
self._in_title = True 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": elif tag == "table":
self._current_table = [] self._current_table = []
elif tag == "tr" and self._current_table is not None: elif tag == "tr" and self._current_table is not None:
@@ -110,8 +153,12 @@ class CornerstonePageParser(HTMLParser):
if self._current_cell is not None: if self._current_cell is not None:
self._current_cell.append(data) 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) -> dict[str, Any]:
def parse_cornerstone_item_page(html: str, page_url: str | None = None) -> dict[str, Any]:
parser = CornerstonePageParser() parser = CornerstonePageParser()
parser.feed(html) parser.feed(html)
info: dict[str, Any] = {"page_title": " ".join(parser.title.split())} info: dict[str, Any] = {"page_title": " ".join(parser.title.split())}
@@ -142,6 +189,9 @@ def parse_cornerstone_item_page(html: str) -> dict[str, Any]:
general[key] = value general[key] = value
info["name"] = general.get("name") or _name_from_title(info["page_title"]) 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: if general:
info["general"] = general info["general"] = general
info["locations"] = locations info["locations"] = locations
@@ -157,3 +207,20 @@ def _name_from_title(title: str) -> str | None:
if " - " not in title: if " - " not in title:
return title or None return title or None
return title.rsplit(" - ", 1)[-1].strip() 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
+28
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import os import os
from pathlib import Path from pathlib import Path
import shutil import shutil
@@ -25,6 +26,10 @@ def resource_path(*parts: str) -> Path:
def main() -> None: def main() -> None:
try: try:
_chdir_to_app_dir() _chdir_to_app_dir()
backend_port = _backend_port_from_args()
if backend_port is not None:
_run_server(backend_port)
return
_log("TraderAI desktop starting") _log("TraderAI desktop starting")
_log(f"cwd={Path.cwd()}") _log(f"cwd={Path.cwd()}")
_log(f"executable={sys.executable}") _log(f"executable={sys.executable}")
@@ -36,6 +41,10 @@ def main() -> None:
_log("existing TraderAI backend found; opening window") _log("existing TraderAI backend found; opening window")
_open_window(url) _open_window(url)
return return
if getattr(sys, "frozen", False):
backend_process = _start_backend_process(port)
_log(f"backend process started pid={backend_process.pid}")
else:
server_thread = threading.Thread(target=_run_server, args=(port,), daemon=True) server_thread = threading.Thread(target=_run_server, args=(port,), daemon=True)
server_thread.start() server_thread.start()
_log("backend thread started") _log("backend thread started")
@@ -62,6 +71,22 @@ def _select_port() -> int:
return _free_port() return _free_port()
def _backend_port_from_args() -> int | None:
args = sys.argv[1:]
if len(args) >= 2 and args[0] == "--backend-port":
return int(args[1])
return None
def _start_backend_process(port: int) -> subprocess.Popen:
command = [sys.executable, "--backend-port", str(port)]
_log(f"starting backend subprocess: {' '.join(command)}")
kwargs: dict[str, object] = {}
if sys.platform == "win32":
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
return subprocess.Popen(command, **kwargs)
def _port_available(port: int) -> bool: def _port_available(port: int) -> bool:
try: try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
@@ -88,6 +113,9 @@ def _existing_server_ready(url: str) -> bool:
def _run_server(port: int) -> NoReturn: def _run_server(port: int) -> NoReturn:
try: try:
_log(f"backend starting on port {port}") _log(f"backend starting on port {port}")
if sys.platform == "win32" and hasattr(asyncio, "WindowsProactorEventLoopPolicy"):
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
_log("set Windows Proactor event loop policy for subprocess-compatible backend")
from traderai.server import app from traderai.server import app
config = uvicorn.Config( config = uvicorn.Config(
+1 -1
View File
@@ -55,7 +55,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()
+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)}"
)
+73 -2
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,15 @@ 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.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_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 +41,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 +66,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"])
+712 -22
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import asyncio
import json import json
import shutil import shutil
import subprocess import subprocess
@@ -23,11 +24,14 @@ from traderai.config import save_settings, settings_payload
from traderai.config import get_settings from traderai.config import get_settings
from traderai.cornerstone_client import CornerstoneClient from traderai.cornerstone_client import CornerstoneClient
from traderai.memory import DEFAULT_THREAD_ID, MemoryStore from traderai.memory import DEFAULT_THREAD_ID, MemoryStore
from traderai.plans import ContinualPlanRunner, ContinualPlanStore
from traderai.scheduler import WakeScheduler from traderai.scheduler import WakeScheduler
from traderai.scmdb_client import SCMDBClient from traderai.scmdb_client import SCMDBClient
from traderai.starcitizen_wiki_client import StarCitizenWikiClient
from traderai.tools import ToolRegistry from traderai.tools import ToolRegistry
from traderai.uex_client import UEXClient from traderai.uex_client import UEXClient
from traderai.version import RELEASES_API_URL, RELEASES_URL, __version__ from traderai.version import RELEASES_API_URL, RELEASES_URL, __version__
from traderai.wikelo_projects_client import WikeloProjectsClient
def resource_path(*parts: str) -> Path: def resource_path(*parts: str) -> Path:
@@ -38,6 +42,13 @@ def resource_path(*parts: str) -> Path:
class ChatRequest(BaseModel): class ChatRequest(BaseModel):
message: str message: str
thread_id: str | None = DEFAULT_THREAD_ID thread_id: str | None = DEFAULT_THREAD_ID
images: list["ChatImageRequest"] = []
class ChatImageRequest(BaseModel):
name: str = "pasted-image.png"
content_type: str = "image/png"
image_data: str
class ChatThreadRequest(BaseModel): class ChatThreadRequest(BaseModel):
@@ -60,6 +71,35 @@ class ClearMemoryRequest(BaseModel):
include_outbox: bool = True include_outbox: bool = True
class ContinualPlanItemRequest(BaseModel):
item_name: str
desired_quantity: int = 1
max_unit_price: float | None = None
class ContinualPlanCreateRequest(BaseModel):
title: str
objective: str
kind: str = "buying"
cadence: str | None = None
constraints: dict[str, Any] = {}
items: list[ContinualPlanItemRequest] = []
class ContinualPlanDraftRequest(BaseModel):
title: str = ""
objective: str = ""
kind: str = "buying"
constraints: dict[str, Any] = {}
items: list[ContinualPlanItemRequest] = []
class ContinualPlanEventRequest(BaseModel):
kind: str = "note"
message: str
metadata: dict[str, Any] = {}
class ConfigUpdateRequest(BaseModel): class ConfigUpdateRequest(BaseModel):
values: dict values: dict
@@ -75,28 +115,56 @@ UPDATE_ASSET_NAME = "TraderAI.exe"
def create_app() -> FastAPI: def create_app() -> FastAPI:
settings = get_settings() settings = get_settings()
memory = MemoryStore(settings.traderai_memory_path) memory = MemoryStore(settings.traderai_memory_path)
plan_store = ContinualPlanStore(memory)
scheduler = WakeScheduler(memory) scheduler = WakeScheduler(memory)
uex = UEXClient(settings.uex_base_url, settings.uex_secret_key, settings.uex_bearer_token) runtime: dict[str, Any] = {}
scmdb = SCMDBClient(settings.scmdb_base_url)
cornerstone = CornerstoneClient(settings.cornerstone_base_url) def configure_runtime(current_settings: Any) -> None:
uex = UEXClient(current_settings.uex_base_url, current_settings.uex_secret_key, current_settings.uex_bearer_token)
scmdb = SCMDBClient(current_settings.scmdb_base_url)
cornerstone = CornerstoneClient(current_settings.cornerstone_base_url)
scwiki = StarCitizenWikiClient(current_settings.scwiki_base_url, current_settings.scwiki_api_base_url)
wikelo = WikeloProjectsClient()
tools = ToolRegistry( tools = ToolRegistry(
uex, uex,
settings.require_write_approval, current_settings.require_write_approval,
memory=memory, memory=memory,
scheduler=scheduler, scheduler=scheduler,
scmdb=scmdb, scmdb=scmdb,
cornerstone=cornerstone, cornerstone=cornerstone,
scwiki=scwiki,
wikelo=wikelo,
plan_store=plan_store,
) )
plan_runner = ContinualPlanRunner(plan_store, tools, memory)
tools.plan_runner = plan_runner
provider_base_url, provider_model, provider_api_key = provider_settings(current_settings)
agent = OllamaAgent( agent = OllamaAgent(
settings.ollama_base_url, provider_base_url,
settings.ollama_model, provider_model,
tools, tools,
memory=memory, memory=memory,
user_name=settings.traderai_user_name, user_name=current_settings.traderai_user_name,
num_ctx=settings.ollama_num_ctx, num_ctx=current_settings.ollama_num_ctx,
provider=current_settings.model_provider,
api_key=provider_api_key,
reasoning_effort=current_settings.model_reasoning_effort,
) )
plan_runner.bind_agent(agent)
scheduler.bind_agent(agent) scheduler.bind_agent(agent)
scheduler.bind_uex_notifications(uex, settings.uex_notification_poll_seconds) scheduler.bind_plan_runner(plan_runner)
scheduler.bind_uex_notifications(uex, current_settings.uex_notification_poll_seconds)
runtime.update(
{
"settings": current_settings,
"uex": uex,
"tools": tools,
"plan_runner": plan_runner,
"agent": agent,
}
)
configure_runtime(settings)
app = FastAPI(title="TraderAI") app = FastAPI(title="TraderAI")
static_dir = resource_path("web") static_dir = resource_path("web")
@@ -112,17 +180,20 @@ def create_app() -> FastAPI:
scheduler.shutdown() scheduler.shutdown()
async def refresh_user_profile() -> None: async def refresh_user_profile() -> None:
if settings.traderai_user_name: current_settings = get_settings()
memory.set_profile("configured_name", settings.traderai_user_name) agent = runtime["agent"]
agent.user_name = agent.user_name or settings.traderai_user_name uex = runtime["uex"]
if current_settings.traderai_user_name:
memory.set_profile("configured_name", current_settings.traderai_user_name)
agent.user_name = agent.user_name or current_settings.traderai_user_name
try: try:
response = await uex.get_user(authenticated=True) response = await uex.get_user(authenticated=True)
except Exception as exc: except Exception as exc:
memory.set_profile("uex_user_error", str(exc)) memory.set_profile("uex_user_error", str(exc))
if settings.traderai_user_name: if current_settings.traderai_user_name:
try: try:
response = await uex.get_user(username=settings.traderai_user_name) response = await uex.get_user(username=current_settings.traderai_user_name)
except Exception: except Exception:
return return
else: else:
@@ -141,8 +212,13 @@ def create_app() -> FastAPI:
@app.get("/api/health") @app.get("/api/health")
async def health() -> dict: async def health() -> dict:
agent = runtime["agent"]
current_settings = get_settings()
inference = await agent.health()
return { return {
"ollama": await agent.health(), "inference": inference,
"ollama": inference,
"model_provider": current_settings.model_provider,
"user": memory.get_profile(), "user": memory.get_profile(),
"jobs": scheduler.list_jobs(), "jobs": scheduler.list_jobs(),
"app_data_dir": settings_payload()["app_data_dir"], "app_data_dir": settings_payload()["app_data_dir"],
@@ -155,14 +231,61 @@ def create_app() -> FastAPI:
@app.post("/api/config") @app.post("/api/config")
async def update_config(request: ConfigUpdateRequest) -> dict: async def update_config(request: ConfigUpdateRequest) -> dict:
previous_settings = get_settings()
updated = save_settings(request.values) updated = save_settings(request.values)
updated["restart_required"] = True current_settings = get_settings()
updated["message"] = "Configuration saved. Restart TraderAI for all settings to take effect." configure_runtime(current_settings)
await refresh_user_profile()
restart_required = (
"traderai_memory_path" in request.values
and str(request.values.get("traderai_memory_path") or "").strip() != str(previous_settings.traderai_memory_path)
)
updated["restart_required"] = restart_required
updated["message"] = (
"Configuration saved. Restart TraderAI to switch memory databases."
if restart_required
else "Configuration saved and applied."
)
return updated return updated
@app.get("/api/ollama/status") @app.get("/api/ollama/status")
async def ollama_status() -> dict: async def ollama_status() -> dict:
return await inspect_ollama() return await inspect_model_provider()
@app.get("/api/provider/models")
async def provider_models(provider: str | None = None) -> dict:
status = await inspect_provider_models(provider)
return {
"provider": status.get("provider", "openai"),
"configured_model": status.get("configured_model"),
"models": status.get("models", []),
"reasoning_efforts": status.get("reasoning_efforts", reasoning_effort_options()),
"configured_reasoning_effort": status.get("configured_reasoning_effort", get_settings().model_reasoning_effort),
"message": status.get("message", ""),
"detail": status.get("detail", ""),
"online": status.get("online", False),
}
@app.post("/api/codex/login")
async def launch_codex_login() -> dict:
current_settings = get_settings()
command = find_codex_cli(current_settings.codex_command)
if not command:
raise HTTPException(status_code=404, detail="Codex CLI was not found on PATH.")
try:
login = await start_codex_browser_login(command)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Codex App Server login failed: {exception_detail(exc)}") from exc
return {
"installed": True,
"running": False,
"online": False,
"provider": "codex",
"login_id": login.get("loginId"),
"auth_url": login.get("authUrl"),
"base_url": str(command),
"message": "Opened Codex App Server sign-in in your browser. Finish the flow, then TraderAI will detect the new login.",
}
@app.post("/api/ollama/launch") @app.post("/api/ollama/launch")
async def launch_ollama() -> dict: async def launch_ollama() -> dict:
@@ -173,7 +296,7 @@ def create_app() -> FastAPI:
popen_hidden(command) popen_hidden(command)
except OSError as exc: except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not launch Ollama: {exc}") from exc raise HTTPException(status_code=500, detail=f"Could not launch Ollama: {exc}") from exc
status = await inspect_ollama() status = await inspect_model_provider()
status["message"] = "Ollama launch requested." status["message"] = "Ollama launch requested."
return status return status
@@ -190,7 +313,7 @@ def create_app() -> FastAPI:
popen_hidden([str(cli), "pull", model]) popen_hidden([str(cli), "pull", model])
except OSError as exc: except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not start model install: {exc}") from exc raise HTTPException(status_code=500, detail=f"Could not start model install: {exc}") from exc
status = await inspect_ollama() status = await inspect_model_provider()
status["message"] = f"Started installing model {model}." status["message"] = f"Started installing model {model}."
return status return status
@@ -269,15 +392,26 @@ def create_app() -> FastAPI:
@app.post("/api/chat") @app.post("/api/chat")
async def chat(request: ChatRequest) -> dict: async def chat(request: ChatRequest) -> dict:
agent = runtime["agent"]
try: try:
return await agent.chat(request.message, thread_id=request.thread_id) return await agent.chat(
request.message,
thread_id=request.thread_id,
images=[image.model_dump() for image in request.images],
)
except OllamaUnavailable as exc: except OllamaUnavailable as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc raise HTTPException(status_code=503, detail=str(exc)) from exc
@app.post("/api/chat/stream") @app.post("/api/chat/stream")
async def chat_stream(request: ChatRequest) -> StreamingResponse: async def chat_stream(request: ChatRequest) -> StreamingResponse:
agent = runtime["agent"]
async def events(): async def events():
async for event in agent.chat_events(request.message, thread_id=request.thread_id): async for event in agent.chat_events(
request.message,
thread_id=request.thread_id,
images=[image.model_dump() for image in request.images],
):
yield f"data: {json.dumps(event)}\n\n" yield f"data: {json.dumps(event)}\n\n"
return StreamingResponse(events(), media_type="text/event-stream") return StreamingResponse(events(), media_type="text/event-stream")
@@ -309,6 +443,7 @@ def create_app() -> FastAPI:
@app.get("/api/pending-actions") @app.get("/api/pending-actions")
async def pending_actions() -> dict: async def pending_actions() -> dict:
agent = runtime["agent"]
return {"pending_actions": agent._pending_payloads()} return {"pending_actions": agent._pending_payloads()}
@app.get("/api/notifications") @app.get("/api/notifications")
@@ -335,11 +470,13 @@ def create_app() -> FastAPI:
@app.get("/api/negotiations/{identifier}/messages") @app.get("/api/negotiations/{identifier}/messages")
async def negotiation_messages(identifier: str) -> dict: async def negotiation_messages(identifier: str) -> dict:
uex = runtime["uex"]
params = negotiation_identifier_params(identifier) params = negotiation_identifier_params(identifier)
return await uex.get("marketplace_negotiations_messages", params, authenticated=True) return await uex.get("marketplace_negotiations_messages", params, authenticated=True)
@app.post("/api/negotiations/{identifier}/messages") @app.post("/api/negotiations/{identifier}/messages")
async def send_negotiation_message(identifier: str, request: DirectNegotiationMessageRequest) -> dict: async def send_negotiation_message(identifier: str, request: DirectNegotiationMessageRequest) -> dict:
uex = runtime["uex"]
params = negotiation_identifier_params(identifier) params = negotiation_identifier_params(identifier)
payload = {**params, "message": request.message, "is_production": 1} payload = {**params, "message": request.message, "is_production": 1}
return await uex.post("marketplace_negotiations_messages", payload, authenticated=True) return await uex.post("marketplace_negotiations_messages", payload, authenticated=True)
@@ -348,6 +485,91 @@ def create_app() -> FastAPI:
async def wake_jobs() -> dict: async def wake_jobs() -> dict:
return {"scheduled_jobs": scheduler.list_jobs()} return {"scheduled_jobs": scheduler.list_jobs()}
@app.get("/api/plans")
async def continual_plans(include_inactive: bool = True) -> dict:
return {"plans": plan_store.list_plans(include_inactive=include_inactive)}
@app.post("/api/plans")
async def create_continual_plan(request: ContinualPlanCreateRequest) -> dict:
tools = runtime["tools"]
result = await tools.create_continual_plan(
title=request.title,
objective=request.objective,
kind=request.kind,
items=[item.model_dump() for item in request.items],
constraints=request.constraints,
cadence=request.cadence,
)
if result.get("error"):
raise HTTPException(status_code=400, detail=result["error"])
return result
@app.post("/api/plans/draft")
async def draft_continual_plan(request: ContinualPlanDraftRequest) -> dict:
agent = runtime["agent"]
draft = await agent.generate_plan_draft(
title=request.title,
objective=request.objective,
kind=request.kind,
constraints=request.constraints,
items=[item.model_dump() for item in request.items],
)
return {"draft": draft}
@app.get("/api/plans/{plan_id}")
async def continual_plan(plan_id: str) -> dict:
plan = plan_store.get_plan(plan_id)
if not plan:
raise HTTPException(status_code=404, detail="Plan not found.")
return {"plan": plan}
@app.post("/api/plans/{plan_id}/pause")
async def pause_continual_plan(plan_id: str) -> dict:
tools = runtime["tools"]
result = await tools.pause_continual_plan(plan_id)
if result.get("error"):
raise HTTPException(status_code=404, detail=result["error"])
return result
@app.post("/api/plans/{plan_id}/resume")
async def resume_continual_plan(plan_id: str) -> dict:
tools = runtime["tools"]
result = await tools.resume_continual_plan(plan_id)
if result.get("error"):
raise HTTPException(status_code=404, detail=result["error"])
return result
@app.post("/api/plans/{plan_id}/cancel")
async def cancel_continual_plan(plan_id: str) -> dict:
tools = runtime["tools"]
result = await tools.cancel_continual_plan(plan_id)
if result.get("error"):
raise HTTPException(status_code=404, detail=result["error"])
return result
@app.delete("/api/plans/{plan_id}")
async def delete_continual_plan(plan_id: str) -> dict:
tools = runtime["tools"]
result = await tools.delete_continual_plan(plan_id)
if result.get("error"):
raise HTTPException(status_code=404, detail=result["error"])
return result
@app.post("/api/plans/{plan_id}/run")
async def run_continual_plan(plan_id: str) -> dict:
tools = runtime["tools"]
result = await tools.run_continual_plan_now(plan_id)
if result.get("error"):
raise HTTPException(status_code=400, detail=result["error"])
return result
@app.post("/api/plans/{plan_id}/events")
async def add_continual_plan_event(plan_id: str, request: ContinualPlanEventRequest) -> dict:
if not plan_store.get_plan(plan_id):
raise HTTPException(status_code=404, detail="Plan not found.")
event = plan_store.add_event(plan_id, request.kind, request.message, request.metadata)
return {"event": event, "plan": plan_store.get_plan(plan_id)}
@app.get("/api/memory") @app.get("/api/memory")
async def inspect_memory(limit: int = 50) -> dict: async def inspect_memory(limit: int = 50) -> dict:
return memory.inspect(max(1, min(limit, 200))) return memory.inspect(max(1, min(limit, 200)))
@@ -369,10 +591,12 @@ def create_app() -> FastAPI:
@app.post("/api/approve/{action_id}") @app.post("/api/approve/{action_id}")
async def approve(action_id: str) -> dict: async def approve(action_id: str) -> dict:
tools = runtime["tools"]
return await tools.approve(action_id) return await tools.approve(action_id)
@app.post("/api/decline/{action_id}") @app.post("/api/decline/{action_id}")
async def decline(action_id: str) -> dict: async def decline(action_id: str) -> dict:
tools = runtime["tools"]
return await tools.decline(action_id) return await tools.decline(action_id)
return app return app
@@ -387,6 +611,141 @@ def negotiation_identifier_params(identifier: str) -> dict[str, Any]:
return {"hash": value} return {"hash": value}
async def inspect_model_provider() -> dict[str, Any]:
settings = get_settings()
if settings.model_provider == "openai":
return await inspect_openai()
if settings.model_provider == "deepseek":
return await inspect_deepseek()
if settings.model_provider == "codex":
return await inspect_codex()
return await inspect_ollama()
async def inspect_openai() -> dict[str, Any]:
settings = get_settings()
return await inspect_cloud_provider_config("openai", settings.openai_base_url, settings.openai_api_key, settings.openai_model)
async def inspect_deepseek() -> dict[str, Any]:
settings = get_settings()
return await inspect_cloud_provider_config(
"deepseek",
settings.deepseek_base_url,
settings.deepseek_api_key,
settings.deepseek_model,
)
async def inspect_codex() -> dict[str, Any]:
settings = get_settings()
command = find_codex_cli(settings.codex_command)
detail = ""
online = False
models: list[str] = []
effort_map: dict[str, list[str]] = {}
if command:
try:
account, models, effort_map = await inspect_codex_app_server(command)
online = bool(account)
detail = f"Logged in as {account.get('email')}" if isinstance(account, dict) and account.get("email") else ""
except (OSError, RuntimeError, asyncio.TimeoutError) as exc:
detail = str(exc)
configured_model = settings.codex_model
model_available = configured_model in models if models else bool(configured_model)
return {
"installed": bool(command),
"running": online,
"online": online,
"provider": "codex",
"model_available": model_available,
"configured_model": configured_model,
"configured_reasoning_effort": settings.model_reasoning_effort,
"reasoning_efforts": codex_reasoning_efforts(configured_model, effort_map),
"base_url": str(command) if command else settings.codex_command,
"models": models,
"message": codex_status_message(bool(command), online, model_available, configured_model),
"detail": detail,
}
async def inspect_cloud_provider() -> dict[str, Any]:
settings = get_settings()
if settings.model_provider == "codex":
return await inspect_codex()
if settings.model_provider == "deepseek":
return await inspect_deepseek()
return await inspect_openai()
async def inspect_provider_models(provider: str | None = None) -> dict[str, Any]:
normalized = str(provider or get_settings().model_provider).strip().casefold()
if normalized == "codex":
return await inspect_codex()
if normalized == "ollama":
return await inspect_ollama()
if normalized == "deepseek":
return await inspect_deepseek()
return await inspect_openai()
async def inspect_cloud_provider_config(
provider: str,
base_url: str,
api_key: str | None,
model: str,
) -> dict[str, Any]:
settings = get_settings()
models: list[str] = []
online = False
detail = ""
provider_name = provider_display_name(provider)
if not api_key:
return {
"installed": True,
"running": False,
"online": False,
"provider": provider,
"model_available": False,
"configured_model": model,
"configured_reasoning_effort": canonical_provider_reasoning_effort(provider, settings.model_reasoning_effort),
"reasoning_efforts": provider_reasoning_efforts(provider, model),
"base_url": base_url,
"models": [],
"message": f"{provider_name} is selected, but no API key is configured.",
"detail": "",
}
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(
f"{base_url.rstrip('/')}/models",
headers={"Authorization": f"Bearer {api_key}"},
)
response.raise_for_status()
body = response.json()
online = True
models = sorted(item.get("id") for item in body.get("data", []) if item.get("id"))
except (httpx.HTTPError, ValueError) as exc:
detail = str(exc)
model_available = model in models
return {
"installed": True,
"running": online,
"online": online,
"provider": provider,
"model_available": model_available,
"configured_model": model,
"configured_reasoning_effort": canonical_provider_reasoning_effort(provider, settings.model_reasoning_effort),
"reasoning_efforts": provider_reasoning_efforts(provider, model),
"base_url": base_url,
"models": models,
"message": cloud_status_message(provider, online, bool(api_key), model_available, model),
"detail": detail,
}
async def inspect_ollama() -> dict[str, Any]: async def inspect_ollama() -> dict[str, Any]:
settings = get_settings() settings = get_settings()
executable = find_ollama_executable() executable = find_ollama_executable()
@@ -412,8 +771,11 @@ async def inspect_ollama() -> dict[str, Any]:
"installed": installed, "installed": installed,
"running": online, "running": online,
"online": online, "online": online,
"provider": "ollama",
"model_available": model_available, "model_available": model_available,
"configured_model": settings.ollama_model, "configured_model": settings.ollama_model,
"configured_reasoning_effort": settings.model_reasoning_effort,
"reasoning_efforts": reasoning_effort_options(),
"base_url": settings.ollama_base_url, "base_url": settings.ollama_base_url,
"num_ctx": settings.ollama_num_ctx, "num_ctx": settings.ollama_num_ctx,
"models": models, "models": models,
@@ -426,6 +788,17 @@ async def inspect_ollama() -> dict[str, Any]:
} }
def cloud_status_message(provider: str, running: bool, configured: bool, model_available: bool, model: str) -> str:
provider_name = provider_display_name(provider)
if not configured:
return f"{provider_name} API key is not configured."
if not running:
return f"{provider_name} is not reachable with the configured key."
if not model_available:
return f'{provider_name} is reachable, but model "{model}" was not returned by the API.'
return f"{provider_name} is ready."
def ollama_status_message(installed: bool, running: bool, model_available: bool, model: str) -> str: def ollama_status_message(installed: bool, running: bool, model_available: bool, model: str) -> str:
if not installed: if not installed:
return "Ollama is not installed." return "Ollama is not installed."
@@ -436,6 +809,316 @@ def ollama_status_message(installed: bool, running: bool, model_available: bool,
return "Ollama is ready." return "Ollama is ready."
def codex_status_message(installed: bool, logged_in: bool, model_available: bool, model: str) -> str:
if not installed:
return "Codex CLI is not installed."
if not logged_in:
return "Codex CLI is installed, but the Codex App Server is not logged in with ChatGPT."
if not model_available:
return f'Codex App Server is logged in, but model "{model}" was not returned by the model list.'
return "Codex App Server is ready."
def provider_settings(settings: Any) -> tuple[str, str, str | None]:
if settings.model_provider == "openai":
return settings.openai_base_url, settings.openai_model, settings.openai_api_key
if settings.model_provider == "deepseek":
return settings.deepseek_base_url, settings.deepseek_model, settings.deepseek_api_key
if settings.model_provider == "codex":
return settings.codex_command, settings.codex_model, None
return settings.ollama_base_url, settings.ollama_model, None
def provider_display_name(provider: str) -> str:
return {"openai": "OpenAI", "deepseek": "DeepSeek", "codex": "Codex"}.get(provider, "Ollama")
def find_codex_cli(configured_command: str | None = None) -> Path | None:
candidates = [configured_command, shutil.which("codex"), os.path.join(os.environ.get("USERPROFILE", ""), ".codex", ".sandbox-bin", "codex.exe")]
for candidate in candidates:
if not candidate:
continue
resolved = shutil.which(candidate) if Path(candidate).name == candidate else candidate
if not resolved:
continue
path = Path(resolved)
if path.exists():
return path
return None
_codex_login_tasks: set[asyncio.Task] = set()
async def start_codex_browser_login(command: Path) -> dict[str, Any]:
process = await asyncio.create_subprocess_exec(
str(command),
"app-server",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
)
request_id = 1
async def write(payload: dict[str, Any]) -> None:
if process.stdin is None:
raise RuntimeError("Codex App Server stdin is unavailable.")
process.stdin.write((json.dumps(payload, ensure_ascii=True) + "\n").encode("utf-8"))
await process.stdin.drain()
async def read(timeout: int = 30) -> dict[str, Any]:
if process.stdout is None:
raise RuntimeError("Codex App Server stdout is unavailable.")
try:
line = await asyncio.wait_for(process.stdout.readline(), timeout=timeout)
except asyncio.TimeoutError as exc:
raise RuntimeError("Codex App Server timed out while starting browser login.") from exc
if not line:
stderr = ""
if process.stderr is not None:
try:
stderr = (await asyncio.wait_for(process.stderr.read(), timeout=1)).decode("utf-8", errors="replace").strip()
except asyncio.TimeoutError:
stderr = ""
raise RuntimeError(stderr or "Codex App Server exited before login completed.")
return json.loads(line.decode("utf-8", errors="replace"))
async def send(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
nonlocal request_id
current_id = request_id
request_id += 1
payload: dict[str, Any] = {"jsonrpc": "2.0", "id": current_id, "method": method}
if params is not None:
payload["params"] = params
await write(payload)
while True:
message = await read()
if message.get("id") == current_id:
if message.get("error"):
error = message["error"]
raise RuntimeError(error.get("message") or f"Codex App Server request failed: {error}")
return message.get("result") or {}
await answer_codex_login_server_request(write, message)
try:
await send(
"initialize",
{
"clientInfo": {"name": "TraderAI", "version": __version__},
"capabilities": {"experimentalApi": True},
},
)
await write({"jsonrpc": "2.0", "method": "initialized", "params": {}})
login = await send("account/login/start", {"type": "chatgpt"})
if login.get("type") != "chatgpt" or not login.get("authUrl"):
raise RuntimeError(f"Codex App Server did not return a browser login URL: {login!r}")
task = asyncio.create_task(watch_codex_browser_login(process, read, write, login.get("loginId")))
_codex_login_tasks.add(task)
task.add_done_callback(_codex_login_tasks.discard)
return login
except Exception:
await stop_process(process)
raise
async def answer_codex_login_server_request(write: Any, message: dict[str, Any]) -> None:
if "id" not in message or "method" not in message:
return
await write(
{
"jsonrpc": "2.0",
"id": message["id"],
"error": {"code": -32601, "message": "TraderAI login does not handle server requests."},
}
)
async def watch_codex_browser_login(process: asyncio.subprocess.Process, read: Any, write: Any, login_id: str | None) -> None:
try:
while True:
message = await read(timeout=300)
if message.get("method") == "account/login/completed":
params = message.get("params") or {}
if login_id is None or params.get("loginId") == login_id:
return
await answer_codex_login_server_request(write, message)
except Exception:
return
finally:
await stop_process(process)
async def stop_process(process: asyncio.subprocess.Process) -> None:
if process.returncode is not None:
return
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=3)
except asyncio.TimeoutError:
process.kill()
await process.wait()
async def inspect_codex_app_server(command: Path) -> tuple[dict[str, Any] | None, list[str], dict[str, list[str]]]:
process = await asyncio.create_subprocess_exec(
str(command),
"app-server",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
)
request_id = 1
async def write(payload: dict[str, Any]) -> None:
if process.stdin is None:
raise RuntimeError("Codex App Server stdin is unavailable.")
process.stdin.write((json.dumps(payload, ensure_ascii=True) + "\n").encode("utf-8"))
await process.stdin.drain()
async def read(timeout: int = 30) -> dict[str, Any]:
if process.stdout is None:
raise RuntimeError("Codex App Server stdout is unavailable.")
line = await asyncio.wait_for(process.stdout.readline(), timeout=timeout)
if not line:
stderr = ""
if process.stderr is not None:
try:
stderr = (await asyncio.wait_for(process.stderr.read(), timeout=1)).decode("utf-8", errors="replace").strip()
except asyncio.TimeoutError:
stderr = ""
raise RuntimeError(stderr or "Codex App Server exited without a response.")
return json.loads(line.decode("utf-8", errors="replace"))
async def send(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
nonlocal request_id
current_id = request_id
request_id += 1
payload: dict[str, Any] = {"jsonrpc": "2.0", "id": current_id, "method": method}
if params is not None:
payload["params"] = params
await write(payload)
while True:
message = await read()
if message.get("id") == current_id:
if message.get("error"):
error = message["error"]
raise RuntimeError(error.get("message") or f"Codex App Server request failed: {error}")
return message.get("result") or {}
if "id" in message and "method" in message:
await write(
{
"jsonrpc": "2.0",
"id": message["id"],
"error": {"code": -32601, "message": "TraderAI status checks do not handle server requests."},
}
)
try:
await send(
"initialize",
{
"clientInfo": {"name": "TraderAI", "version": __version__},
"capabilities": {"experimentalApi": True},
},
)
await write({"jsonrpc": "2.0", "method": "initialized", "params": {}})
account_result = await send("account/read", {"refreshToken": False})
models: list[str] = []
effort_map: dict[str, list[str]] = {}
cursor: str | None = None
for _ in range(20):
params: dict[str, Any] = {"limit": 50, "includeHidden": False}
if cursor:
params["cursor"] = cursor
page = await send("model/list", params)
for item in page.get("data") or []:
model = item.get("id") or item.get("model")
if not model:
continue
models.append(model)
efforts = [
effort.get("reasoningEffort")
for effort in item.get("supportedReasoningEfforts", [])
if effort.get("reasoningEffort")
]
if efforts:
effort_map[model] = efforts
cursor = page.get("nextCursor")
if not cursor:
break
return account_result.get("account"), sorted(set(models)), effort_map
finally:
if process.returncode is None:
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=3)
except asyncio.TimeoutError:
process.kill()
await process.wait()
def codex_models() -> list[str]:
cache_path = Path.home() / ".codex" / "models_cache.json"
if not cache_path.exists():
return []
try:
body = json.loads(cache_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return []
models = []
for item in body.get("models", []):
slug = item.get("slug")
if slug:
models.append(slug)
return sorted(set(models))
def codex_reasoning_efforts(model: str, effort_map: dict[str, list[str]] | None = None) -> list[str]:
if effort_map and effort_map.get(model):
return effort_map[model]
cache_path = Path.home() / ".codex" / "models_cache.json"
if not cache_path.exists():
return reasoning_effort_options()
try:
body = json.loads(cache_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return reasoning_effort_options()
for item in body.get("models", []):
if item.get("slug") != model:
continue
efforts = [entry.get("effort") for entry in item.get("supported_reasoning_levels", []) if entry.get("effort")]
return efforts or reasoning_effort_options()
return reasoning_effort_options()
def reasoning_effort_options() -> list[str]:
return ["none", "minimal", "low", "medium", "high", "xhigh"]
def deepseek_reasoning_efforts(model: str) -> list[str]:
supported_models = {"deepseek-v4-flash", "deepseek-v4-pro", "deepseek-chat", "deepseek-reasoner"}
return ["none", "high", "max"] if model in supported_models else ["none", "high"]
def provider_reasoning_efforts(provider: str, model: str) -> list[str]:
if provider == "deepseek":
return deepseek_reasoning_efforts(model)
return reasoning_effort_options()
def canonical_provider_reasoning_effort(provider: str, effort: str) -> str:
normalized = str(effort or "medium").strip().casefold()
if provider != "deepseek":
return normalized
if normalized in {"none", "minimal"}:
return "none"
if normalized in {"xhigh", "max"}:
return "max"
return "high"
def find_ollama_executable() -> Path | None: def find_ollama_executable() -> Path | None:
candidates = [ candidates = [
shutil.which("ollama"), shutil.which("ollama"),
@@ -488,6 +1171,13 @@ def popen_hidden(command: list[str]) -> subprocess.Popen:
return subprocess.Popen(command, **kwargs) return subprocess.Popen(command, **kwargs)
def exception_detail(exc: BaseException) -> str:
text = str(exc).strip()
if text:
return text
return f"{type(exc).__name__}: {exc!r}"
async def inspect_update() -> dict[str, Any]: async def inspect_update() -> dict[str, Any]:
try: try:
latest = await latest_release() latest = await latest_release()
+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
+873 -14
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
__version__ = "0.0.3" __version__ = "0.0.8"
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,9 @@ RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingCo
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from typing import Any
import httpx
class WikeloProjectsError(RuntimeError):
pass
class WikeloProjectsClient:
APP_ID = "695be2905c0b4866dfb21265"
def __init__(self, base_url: str = "https://wikelo-projects.com") -> None:
self.base_url = base_url.rstrip("/")
async def list_ship_projects(self) -> list[dict[str, Any]]:
body = await self._get_json(f"{self.base_url}/api/apps/{self.APP_ID}/entities/ShipProject")
if not isinstance(body, list):
raise WikeloProjectsError("Wikelo ship projects response was not a list.")
return [item for item in body if isinstance(item, dict)]
async def _get_json(self, url: str) -> Any:
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
response = await client.get(url, headers={"Accept": "application/json"})
try:
body = response.json()
except ValueError as exc:
raise WikeloProjectsError(f"Wikelo Projects returned non-JSON response: HTTP {response.status_code}") from exc
if response.status_code >= 400:
raise WikeloProjectsError(f"Wikelo Projects HTTP {response.status_code}: {body}")
return body
File diff suppressed because one or more lines are too long
Generated
+7 -1
View File
@@ -755,7 +755,7 @@ wheels = [
[[package]] [[package]]
name = "traderai" name = "traderai"
version = "0.0.3" version = "0.0.8"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "apscheduler" }, { name = "apscheduler" },
@@ -1049,3 +1049,9 @@ wheels = [
+750 -36
View File
File diff suppressed because it is too large Load Diff
+103 -21
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,15 @@
<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">Plans</div>
<button class="rail-icon-button" id="plans-toggle" type="button" title="Plans" aria-expanded="false" aria-controls="plans-panel">
<i data-lucide="list-checks" aria-hidden="true"></i>
</button>
</div>
<div class="plans-rail-list" id="plans-rail-list"></div>
</section>
<section class="chat-nav-section"> <section class="chat-nav-section">
<div class="rail-heading">Inbox</div> <div class="rail-heading">Inbox</div>
<div class="inbox-list" id="inbox-list"></div> <div class="inbox-list" id="inbox-list"></div>
@@ -42,6 +51,7 @@
<h1>TraderAI</h1> <h1>TraderAI</h1>
<p>Institutional marketplace intelligence for UEX operations</p> <p>Institutional marketplace intelligence for UEX operations</p>
</div> </div>
<span class="brand-short" aria-hidden="true">LBC</span>
</div> </div>
<div class="status" id="status">Ready</div> <div class="status" id="status">Ready</div>
</header> </header>
@@ -49,7 +59,10 @@
<div class="messages" id="messages"></div> <div class="messages" id="messages"></div>
<div class="composer-wrap"> <div class="composer-wrap">
<form class="composer" id="chat-form"> <form class="composer" id="chat-form">
<div class="composer-main">
<textarea id="message-input" rows="2" placeholder="Search listings, draft a reply, prepare an offer..."></textarea> <textarea id="message-input" rows="2" placeholder="Search listings, draft a reply, prepare an offer..."></textarea>
<div class="composer-images" id="composer-images" hidden></div>
</div>
<button type="submit">Send</button> <button type="submit">Send</button>
</form> </form>
</div> </div>
@@ -60,21 +73,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>
@@ -121,14 +119,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,6 +149,21 @@
</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>
@@ -159,6 +184,63 @@
</form> </form>
<div class="config-status" id="negotiation-status"></div> <div class="config-status" id="negotiation-status"></div>
</div> </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 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">
<div class="section-title-row"> <div class="section-title-row">
+762 -35
View File
@@ -105,7 +105,7 @@ body::before {
.chat-rail-content { .chat-rail-content {
display: grid; display: grid;
grid-template-rows: minmax(0, 1fr) minmax(140px, 34%); grid-template-rows: minmax(0, 1fr) minmax(92px, 20%) minmax(130px, 30%);
gap: 16px; gap: 16px;
min-height: 0; min-height: 0;
padding-top: 16px; padding-top: 16px;
@@ -131,8 +131,41 @@ body::before {
text-transform: uppercase; text-transform: uppercase;
} }
.rail-heading-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.rail-heading-row .rail-heading {
margin-bottom: 0;
}
.rail-icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
min-width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--line-strong);
border-radius: 8px;
background: #fff9e9;
color: var(--forest);
box-shadow: 0 8px 18px rgba(38, 58, 27, 0.08);
}
.rail-icon-button svg {
width: 15px;
height: 15px;
}
.chat-list, .chat-list,
.inbox-list { .inbox-list,
.plans-rail-list {
display: grid; display: grid;
gap: 8px; gap: 8px;
max-height: calc(100% - 26px); max-height: calc(100% - 26px);
@@ -140,7 +173,8 @@ body::before {
} }
.chat-item, .chat-item,
.inbox-item { .inbox-item,
.plan-rail-item {
display: grid; display: grid;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
@@ -159,13 +193,33 @@ body::before {
grid-template-columns: minmax(0, 1fr) auto auto; grid-template-columns: minmax(0, 1fr) auto auto;
} }
.plan-rail-item {
grid-template-columns: minmax(0, 1fr) auto;
width: 100%;
min-width: 0;
border: 1px solid var(--line);
background: rgba(255, 250, 240, 0.78);
color: var(--brown);
font-family: Inter, "Segoe UI", Arial, sans-serif;
text-align: left;
box-shadow: none;
cursor: pointer;
}
.plan-rail-item:hover {
background: #edf3df;
color: var(--brown);
box-shadow: none;
}
.chat-item.active { .chat-item.active {
border-color: rgba(52, 83, 38, 0.42); border-color: rgba(52, 83, 38, 0.42);
background: #edf3df; background: #edf3df;
} }
.chat-title, .chat-title,
.inbox-title { .inbox-title,
.plan-rail-title {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
color: var(--brown); color: var(--brown);
@@ -198,7 +252,25 @@ body::before {
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }
.plan-rail-title {
white-space: nowrap;
}
.plan-rail-status {
min-width: 0;
padding: 3px 6px;
border: 1px solid rgba(52, 83, 38, 0.2);
border-radius: 999px;
background: #edf3df;
color: var(--forest);
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
}
.actions { .actions {
display: flex;
flex-direction: column;
padding: 28px; padding: 28px;
overflow: auto; overflow: auto;
min-height: 0; min-height: 0;
@@ -230,6 +302,10 @@ body::before {
min-width: 0; min-width: 0;
} }
.brand-short {
display: none;
}
.logo-wrap { .logo-wrap {
position: relative; position: relative;
display: grid; display: grid;
@@ -481,6 +557,38 @@ h2 {
background: rgba(255, 250, 240, 0.96); background: rgba(255, 250, 240, 0.96);
} }
.message-images {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.message-image {
overflow: hidden;
border: 1px solid rgba(88, 66, 47, 0.18);
border-radius: 14px;
background: rgba(255, 255, 255, 0.78);
}
.message-image img {
display: block;
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
}
.message-image-label {
display: block;
padding: 8px 10px;
color: #6d5b4e;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.message.warning-message { .message.warning-message {
border-color: rgba(212, 175, 55, 0.6); border-color: rgba(212, 175, 55, 0.6);
background: #f5eac4; background: #f5eac4;
@@ -646,6 +754,60 @@ h2 {
padding: 20px; padding: 20px;
} }
.composer-main {
display: grid;
gap: 12px;
}
.composer-images {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
}
.composer-image {
position: relative;
overflow: hidden;
border: 1px solid rgba(88, 66, 47, 0.16);
border-radius: 14px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 12px 26px rgba(38, 58, 27, 0.08);
}
.composer-image img {
display: block;
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
}
.composer-image-name {
display: block;
padding: 8px 10px 10px;
color: #6d5b4e;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.composer-image-remove {
position: absolute;
top: 8px;
right: 8px;
width: 28px;
height: 28px;
min-height: 28px;
padding: 0;
border-radius: 999px;
border: 1px solid rgba(88, 66, 47, 0.18);
background: rgba(255, 250, 240, 0.92);
color: var(--brown);
font-size: 16px;
line-height: 1;
}
textarea { textarea {
width: 100%; width: 100%;
min-height: 58px; min-height: 58px;
@@ -678,7 +840,8 @@ textarea:disabled {
input[type="text"], input[type="text"],
input[type="password"], input[type="password"],
input[type="number"] { input[type="number"],
select {
width: 100%; width: 100%;
min-height: 38px; min-height: 38px;
padding: 9px 11px; padding: 9px 11px;
@@ -694,7 +857,8 @@ input[type="number"] {
input[type="text"]:focus, input[type="text"]:focus,
input[type="password"]:focus, input[type="password"]:focus,
input[type="number"]:focus { input[type="number"]:focus,
select:focus {
border-color: var(--gold); border-color: var(--gold);
box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.18); box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.18);
} }
@@ -867,6 +1031,26 @@ button {
line-height: 1.45; line-height: 1.45;
} }
.floating-panel-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.plans-floating-panel {
grid-template-rows: auto minmax(0, 1fr);
width: min(980px, calc(100vw - 28px));
}
.plans-panel-body {
display: grid;
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
gap: 20px;
min-height: 0;
padding: 20px;
overflow: auto;
}
button:hover { button:hover {
background: linear-gradient(180deg, #3d612c, #263e1b); background: linear-gradient(180deg, #3d612c, #263e1b);
box-shadow: 0 18px 34px rgba(31, 52, 22, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.16); box-shadow: 0 18px 34px rgba(31, 52, 22, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.16);
@@ -909,7 +1093,7 @@ button.secondary {
} }
.side-section { .side-section {
margin-bottom: 28px; margin-bottom: 0;
} }
.side-section + .side-section { .side-section + .side-section {
@@ -918,43 +1102,98 @@ button.secondary {
} }
.sidebar-tools { .sidebar-tools {
display: grid; display: flex;
flex-direction: column;
gap: 14px; gap: 14px;
margin-top: auto;
padding-top: 24px;
background: transparent;
} }
.sidebar-tool-buttons { .sidebar-tool-buttons {
display: grid; display: flex;
grid-template-columns: repeat(3, minmax(0, 1fr)); flex-wrap: nowrap;
gap: 10px; justify-content: flex-end;
width: 100%;
min-width: 0;
gap: 8px;
position: sticky;
bottom: 0;
z-index: 2;
padding-top: 14px;
padding-bottom: 2px;
background: linear-gradient(180deg, var(--ivory) 0%, var(--cream) 100%);
} }
.sidebar-tool-button { .sidebar-tool-button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; flex: 0 1 42px;
min-width: 0; gap: 0;
min-height: 46px; width: 42px;
padding: 10px 12px; min-width: 36px;
min-height: 42px;
padding: 9px;
overflow: hidden;
border: 1px solid var(--line-strong); border: 1px solid var(--line-strong);
border-radius: 14px; border-radius: 12px;
background: #fff9e9; background: #fff9e9;
color: var(--forest); color: var(--forest);
font-family: Inter, "Segoe UI", Arial, sans-serif; font-family: Inter, "Segoe UI", Arial, sans-serif;
font-size: 13px; font-size: 12px;
font-weight: 800; font-weight: 800;
white-space: nowrap;
box-shadow: 0 10px 22px rgba(38, 58, 27, 0.08); box-shadow: 0 10px 22px rgba(38, 58, 27, 0.08);
transition:
flex-basis 180ms ease,
width 180ms ease,
gap 180ms ease,
padding 180ms ease,
border-color 180ms ease,
background 180ms ease,
color 180ms ease,
box-shadow 180ms ease,
transform 180ms ease;
}
.sidebar-tool-button:hover,
.sidebar-tool-button:focus-visible {
flex-basis: 108px;
width: 108px;
gap: 7px;
padding-inline: 12px;
border-color: rgba(212, 175, 55, 0.72);
background: linear-gradient(180deg, #3d612c, #263e1b);
color: var(--ivory);
}
.sidebar-tool-button span {
max-width: 0;
overflow: hidden;
opacity: 0;
transition:
max-width 180ms ease,
opacity 140ms ease;
}
.sidebar-tool-button:hover span,
.sidebar-tool-button:focus-visible span {
max-width: 70px;
opacity: 1;
} }
.sidebar-tool-button svg { .sidebar-tool-button svg {
width: 18px; width: 18px;
height: 18px; height: 18px;
flex: 0 0 18px;
stroke-width: 2.3; stroke-width: 2.3;
} }
.sidebar-tool-image { .sidebar-tool-image {
width: 18px; width: 18px;
height: 18px; height: 18px;
flex: 0 0 18px;
object-fit: contain; object-fit: contain;
} }
@@ -969,8 +1208,13 @@ button.secondary {
} }
.sidebar-panel { .sidebar-panel {
padding-top: 12px; padding-bottom: 12px;
border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
}
.sidebar-panel .section-title-row {
position: relative;
z-index: 1;
} }
.config-form { .config-form {
@@ -1013,6 +1257,86 @@ button.secondary {
font-weight: 800; font-weight: 800;
} }
.plan-creator-shell,
.plans-dashboard-shell {
min-height: 0;
}
.plan-creator-card {
display: grid;
gap: 18px;
padding: 20px;
border: 1px solid rgba(212, 175, 55, 0.28);
border-radius: 22px;
background:
radial-gradient(circle at top right, rgba(240, 214, 129, 0.18), transparent 34%),
linear-gradient(180deg, rgba(255, 253, 247, 0.98), rgba(247, 241, 220, 0.94));
box-shadow: 0 20px 40px rgba(38, 58, 27, 0.08);
}
.plan-creator-copy {
display: grid;
gap: 8px;
}
.plan-creator-copy h3 {
margin: 0;
color: var(--forest);
font-family: "Playfair Display", Georgia, serif;
font-size: 28px;
line-height: 1.02;
}
.plan-creator-copy p:last-child {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.55;
}
.plan-form-grid {
gap: 12px;
}
.plan-form-grid textarea {
min-height: 96px;
}
.plan-form-actions {
display: flex;
gap: 10px;
}
.plan-form-actions button {
flex: 1;
}
.plan-form-split {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.plan-form-hint {
display: grid;
align-content: start;
gap: 4px;
padding: 12px 13px;
border: 1px dashed rgba(52, 83, 38, 0.24);
border-radius: 14px;
background: rgba(237, 243, 223, 0.68);
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.plan-form-hint strong {
color: var(--forest);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ollama-status { .ollama-status {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -1245,6 +1569,300 @@ pre {
padding: 11px; padding: 11px;
} }
.plans-dashboard {
display: grid;
gap: 14px;
min-height: 0;
}
.plans-overview {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
padding: 6px 2px 10px;
}
.plans-overview h3 {
margin: 4px 0 0;
color: var(--forest);
font-family: "Playfair Display", Georgia, serif;
font-size: 31px;
line-height: 1.04;
}
.plan-overview-copy {
max-width: 48ch;
margin: 8px 0 0;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.plan-overview-stats {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.plan-overview-stat {
display: grid;
gap: 2px;
min-width: 110px;
padding: 12px 14px;
border: 1px solid rgba(212, 175, 55, 0.28);
border-radius: 16px;
background: rgba(255, 250, 240, 0.78);
box-shadow: 0 12px 24px rgba(38, 58, 27, 0.06);
}
.plan-overview-stat-value {
color: var(--forest);
font-size: 24px;
font-weight: 800;
line-height: 1;
}
.plan-overview-stat-label {
color: var(--muted);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.plan-empty-state {
padding: 22px;
border: 1px dashed rgba(52, 83, 38, 0.24);
border-radius: 22px;
background: rgba(255, 253, 247, 0.72);
}
.plan-empty-state h4 {
margin: 0 0 6px;
color: var(--forest);
font-size: 18px;
}
.plan-empty-state p {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.plan-card {
display: grid;
gap: 12px;
padding: 16px;
border: 1px solid rgba(221, 206, 176, 0.92);
border-radius: 20px;
background:
linear-gradient(180deg, rgba(255, 252, 246, 0.96), rgba(251, 244, 223, 0.88));
box-shadow: 0 16px 30px rgba(38, 58, 27, 0.06);
}
.plan-card.active {
border-color: rgba(52, 83, 38, 0.32);
background:
radial-gradient(circle at top right, rgba(190, 212, 144, 0.22), transparent 26%),
linear-gradient(180deg, rgba(247, 250, 238, 0.98), rgba(237, 243, 223, 0.96));
}
.plan-card-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.plan-card h3 {
margin: 0;
color: var(--forest);
font-size: 19px;
line-height: 1.18;
}
.plan-status-badge {
flex: 0 0 auto;
padding: 6px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.plan-status-active {
border-color: rgba(52, 83, 38, 0.28);
background: rgba(237, 243, 223, 0.9);
color: var(--forest);
}
.plan-status-badge.plan-status-active {
border: 1px solid rgba(52, 83, 38, 0.24);
}
.plan-status-paused {
border-color: rgba(196, 170, 115, 0.42);
background: rgba(255, 246, 220, 0.86);
color: #7a5a18;
}
.plan-status-badge.plan-status-paused {
border: 1px solid rgba(196, 170, 115, 0.34);
}
.plan-status-needs-input {
border-color: rgba(159, 60, 50, 0.24);
background: rgba(255, 241, 237, 0.88);
color: var(--danger);
}
.plan-status-badge.plan-status-needs-input {
border: 1px solid rgba(159, 60, 50, 0.22);
}
.plan-status-canceled,
.plan-status-completed {
opacity: 0.84;
}
.plan-status-badge.plan-status-canceled,
.plan-status-badge.plan-status-completed {
border: 1px solid rgba(111, 91, 80, 0.18);
background: rgba(255, 250, 240, 0.82);
color: var(--muted);
}
.plan-meta,
.plan-line {
color: var(--muted);
font-size: 13px;
line-height: 1.55;
overflow-wrap: anywhere;
}
.plan-pill-row,
.plan-controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.plan-pill {
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 4px 10px;
border: 1px solid rgba(52, 83, 38, 0.14);
border-radius: 999px;
background: rgba(255, 250, 240, 0.88);
color: var(--forest);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.plan-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.plan-metric {
display: grid;
gap: 4px;
padding: 11px 12px;
border: 1px solid rgba(221, 206, 176, 0.78);
border-radius: 14px;
background: rgba(255, 253, 247, 0.76);
}
.plan-metric-label {
color: var(--muted);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.plan-metric-value {
color: var(--brown);
font-size: 13px;
font-weight: 700;
line-height: 1.35;
}
.plan-controls button {
flex: 1 1 80px;
min-width: 0;
}
.plan-detail {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
padding-top: 10px;
border-top: 1px solid rgba(221, 206, 176, 0.92);
}
.plan-detail-loading {
grid-template-columns: 1fr;
padding: 16px;
border: 1px dashed rgba(52, 83, 38, 0.2);
border-radius: 16px;
background: rgba(255, 253, 247, 0.72);
color: var(--muted);
font-size: 13px;
text-align: center;
}
.plan-section {
display: grid;
gap: 8px;
}
.plan-detail h4 {
margin: 0;
color: var(--forest);
font-size: 13px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.plan-list {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.plan-list li {
display: grid;
gap: 5px;
padding: 10px 11px;
border: 1px solid rgba(221, 206, 176, 0.72);
border-radius: 12px;
background: rgba(255, 253, 247, 0.8);
color: var(--brown);
font-size: 12px;
line-height: 1.4;
}
.plan-list-title {
color: var(--forest);
font-size: 12px;
font-weight: 800;
}
.plan-list-body {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.decline-button { .decline-button {
border: 1px solid var(--line-strong); border: 1px solid var(--line-strong);
background: #fff9e9; background: #fff9e9;
@@ -1277,8 +1895,17 @@ pre {
} }
@media (max-width: 620px) { @media (max-width: 620px) {
body {
background: var(--cream);
}
body::before {
display: none;
}
.shell { .shell {
gap: 14px; gap: 14px;
grid-template-rows: minmax(0, 1fr) minmax(220px, 34vh);
padding: 10px; padding: 10px;
} }
@@ -1287,40 +1914,104 @@ pre {
border-radius: 22px; border-radius: 22px;
} }
.chat-rail {
position: fixed;
inset: 10px auto auto 10px;
z-index: 10;
width: min(320px, calc(100vw - 20px));
height: calc(100vh - 20px);
max-height: calc(100vh - 20px);
}
.chat-rail.collapsed {
width: 48px;
height: 48px;
min-height: 48px;
max-height: 48px;
padding: 4px;
border: 0;
background: transparent;
box-shadow: none;
}
.chat-rail.collapsed .chat-rail-top {
display: block;
}
.chat-rail.collapsed #new-chat {
display: none;
}
.topbar { .topbar {
align-items: flex-start; display: flex;
grid-template-columns: 1fr; align-items: center;
padding: 22px; justify-content: center;
min-height: 68px;
padding: 10px 58px 10px 66px;
border-bottom-color: var(--line);
background: linear-gradient(180deg, var(--ivory) 0%, var(--cream) 100%);
} }
.brand-block { .brand-block {
align-items: flex-start; align-items: center;
justify-content: center;
gap: 9px;
min-width: 0;
} }
.logo-wrap { .logo-wrap {
width: 58px; width: 28px;
height: 58px; height: 28px;
flex-basis: 58px; flex: 0 0 28px;
border-radius: 18px; border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
color: var(--brown);
}
.logo-wrap::before {
content: "";
display: block;
width: 28px;
height: 28px;
background: currentColor;
-webkit-mask: url("/static/art/LBC_Logo.png") center / contain no-repeat;
mask: url("/static/art/LBC_Logo.png") center / contain no-repeat;
} }
.logo-wrap img { .logo-wrap img {
width: 45px; display: none;
height: 45px; }
.brand-copy {
display: contents;
}
.brand-copy p,
.status {
display: none;
} }
h1 { h1 {
font-size: 31px; color: var(--brown);
font-size: 22px;
line-height: 1;
text-shadow: none;
} }
.eyebrow { .brand-short {
font-size: 10px; display: inline-flex;
letter-spacing: 0.08em; align-items: center;
color: var(--brown);
font-family: "Playfair Display", Georgia, serif;
font-size: 18px;
font-weight: 800;
line-height: 1;
} }
.messages, .messages,
.actions, .actions {
.chat-rail {
padding: 22px; padding: 22px;
} }
@@ -1350,4 +2041,40 @@ pre {
.message-phase { .message-phase {
grid-column: 1; grid-column: 1;
} }
.plans-overview {
flex-direction: column;
align-items: flex-start;
}
.plan-detail {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.plan-form-split,
.plan-metrics {
grid-template-columns: 1fr;
}
.plan-card-heading {
flex-direction: column;
}
.plan-status-badge {
align-self: flex-start;
}
}
@media (max-width: 640px) {
.plans-floating-panel {
width: min(100vw - 18px, 980px);
right: 9px;
bottom: 9px;
}
.plans-panel-body {
grid-template-columns: 1fr;
}
} }
+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>