2 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
26 changed files with 4395 additions and 250 deletions
+10 -2
View File
@@ -3,14 +3,22 @@ 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_BASE_URL=https://api.openai.com/v1
OPENAI_MODEL=gpt-5.3-codex OPENAI_MODEL=gpt-5.4-mini
OPENAI_API_KEY= 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 -3
View File
@@ -1,6 +1,6 @@
# TraderAI # TraderAI
Local Ollama- or OpenAI-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,7 +25,10 @@ Local Ollama- or OpenAI-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 to use OpenAI instead of Ollama, set `MODEL_PROVIDER=openai`, set `OPENAI_API_KEY`, and optionally change `OPENAI_MODEL` from the default `gpt-5.3-codex`. 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:
@@ -39,7 +42,7 @@ Local Ollama- or OpenAI-powered chat for UEX marketplace workflows.
## Notes ## Notes
Ollama runs locally at `http://localhost:11434` by default. This app can talk to either Ollama's native chat API or OpenAI's Chat Completions 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
+5 -2
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "traderai" name = "traderai"
version = "0.0.6" 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",
@@ -40,3 +40,6 @@ include = ["traderai*"]
+145
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
@@ -217,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"))
+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"
+39
View File
@@ -104,6 +104,26 @@ def test_continual_plan_store_creates_buying_checklist(tmp_path):
assert plan["items"][0]["desired_quantity"] == 2 assert plan["items"][0]["desired_quantity"] == 2
def test_continual_plan_store_deletes_plan_and_related_records(tmp_path):
_, store, _, _, _ = plan_stack(tmp_path)
plan = store.create_plan(
"Delete me",
objective="Remove everything",
items=[{"item_name": "Wikelo Idris panel", "desired_quantity": 1}],
)
item_id = int(plan["items"][0]["id"])
candidate = store.upsert_candidate(plan["id"], item_id, {"id": "listing-1", "title": "Panel", "price": 10}, 0.9)
store.add_negotiation(plan["id"], item_id, int(candidate["id"]), {"listing_id": "listing-1", "listing_slug": "panel", "id_negotiation": "neg-1", "hash": "hash-1"})
assert store.delete_plan(plan["id"]) is True
assert store.get_plan(plan["id"]) is None
assert store.list_items(plan["id"]) == []
assert store.list_candidates(plan["id"]) == []
assert store.list_negotiations(plan["id"]) == []
assert store.list_events(plan["id"]) == []
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_buying_runner_tracks_candidates_and_drafts_only(tmp_path): async def test_buying_runner_tracks_candidates_and_drafts_only(tmp_path):
memory, store, tools, runner, _ = plan_stack(tmp_path) memory, store, tools, runner, _ = plan_stack(tmp_path)
@@ -122,6 +142,9 @@ async def test_buying_runner_tracks_candidates_and_drafts_only(tmp_path):
assert len(tools.pending_actions) == 1 assert len(tools.pending_actions) == 1
assert not tools.uex.posts assert not tools.uex.posts
assert "Drafted 1 negotiation" in memory.list_outbox()[0]["content"] assert "Drafted 1 negotiation" in memory.list_outbox()[0]["content"]
pending = next(iter(tools.pending_actions.values()))
assert "Tone note" not in pending.payload["message"]
assert "Polaris build" not in pending.payload["message"] or "putting together parts for a Polaris build" in pending.payload["message"]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -215,3 +238,19 @@ async def test_scheduler_schedules_overdue_plan_catchup_on_start(tmp_path):
assert catchup is not None assert catchup is not None
assert any(event["kind"] == "catchup_scheduled" for event in snapshot["events"]) assert any(event["kind"] == "catchup_scheduled" for event in snapshot["events"])
@pytest.mark.asyncio
async def test_tools_delete_continual_plan_removes_it(tmp_path):
_, store, tools, _, _ = plan_stack(tmp_path)
plan = store.create_plan(
"Delete through tools",
objective="Remove via registry",
items=[{"item_name": "Wikelo Idris panel"}],
)
result = await tools.delete_continual_plan(plan["id"])
assert result["deleted"] is True
assert result["plan_id"] == plan["id"]
assert store.get_plan(plan["id"]) is None
+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",
)
+277
View File
@@ -10,8 +10,10 @@ from traderai.uex_client import UEXClient
class FakeUEX: class FakeUEX:
def __init__(self): def __init__(self):
self.posts = [] self.posts = []
self.get_calls = []
async def get(self, path, params=None, authenticated=False): async def get(self, path, params=None, authenticated=False):
self.get_calls.append({"path": path, "params": params, "authenticated": authenticated})
if path == "commodities_prices_history": if path == "commodities_prices_history":
return { return {
"status": "ok", "status": "ok",
@@ -80,6 +82,34 @@ class FakeUEX:
}, },
], ],
} }
if path == "marketplace_trends":
return {
"status": "ok",
"data": [
{
"id_item": 2791,
"item_name": "\"Quantanium\" Water Bottle",
"item_slug": "quantanium-water-bottle",
"currency": "UEC",
"price_avg_sell": "937500",
"price_avg_month_sell": "1072222",
"price_min_sell": "750000",
"price_max_sell": "1200000",
"listings_count_sell": 4,
"price_avg_buy": "500000",
"price_avg_month_buy": "525000",
"price_min_buy": "450000",
"price_max_buy": "550000",
"listings_count_buy": 2,
"total_listings_count": 6,
"negotiations_count": 18,
"negotiations_open": 7,
"negotiations_success": 9,
"link_prices": "https://uexcorp.space/marketplace/home/?id_item=2791&mode=list",
"link_prices_history": "https://uexcorp.space/marketplace/averages/?id_item=2791&quality_tier=q0&unit=unit",
}
],
}
assert path == "marketplace_listings" assert path == "marketplace_listings"
return { return {
"data": [ "data": [
@@ -259,6 +289,116 @@ class FakeCornerstone:
} }
class FakeSCWiki:
base_url = "https://starcitizen.tools"
api_base_url = "https://api.star-citizen.wiki"
async def search_pages(self, query, limit=5):
assert query == "Carrack"
return [
{
"pageid": 415,
"title": "Carrack",
"description": "Deep-space multi-crew explorer manufactured by Anvil Aerospace",
"extract": "The Anvil Carrack is a multi-crew explorer.",
"thumbnail": "https://media.starcitizen.tools/carrack.webp",
"url": "https://starcitizen.tools/Carrack",
}
][:limit]
async def get_page_summary(self, title=None, pageid=None, chars=700):
assert title == "Carrack" or pageid == 415
return {
"pageid": 415,
"title": "Carrack",
"description": "Deep-space multi-crew explorer manufactured by Anvil Aerospace",
"extract": "The Anvil Carrack is a multi-crew explorer.",
"thumbnail": "https://media.starcitizen.tools/carrack.webp",
"url": "https://starcitizen.tools/Carrack",
}
async def search_verse(self, query):
assert query == "Carrack"
return [
{
"type": "vehicles",
"label": "Vehicles",
"results": [
{
"name": "Anvil Carrack",
"class_name": "ANVL_Carrack",
"extra_label": "Exploration",
"web_url": "https://api.star-citizen.wiki/vehicles/anvl-carrack",
"api_url": "https://api.star-citizen.wiki/api/vehicles/anvl-carrack",
}
],
}
]
async def get_vehicle(self, slug):
assert slug == "anvl-carrack"
return {
"name": "Carrack",
"game_name": "Anvil Carrack",
"slug": "anvl-carrack",
"manufacturer": {"name": "Anvil Aerospace"},
"career": "Exploration",
"role": "Expedition",
"size_class": 5,
"cargo_capacity": 456,
"crew": {"min": 6, "max": 6},
"msrp": 600,
"pledge_url": "https://robertsspaceindustries.com/pledge/ships/carrack/Carrack",
"uex_prices": {
"purchase": [
{
"price_buy": 34398000,
"terminal_name": "Astro Armada - Area 18",
"starmap_location": {"name": "Area18", "parent_name": "ArcCorp", "star_system_name": "Stanton"},
"game_version": "4.8.1-LIVE.11952564",
"date_updated": "2026-05-20T18:39:37-04:00",
"uex_link": "https://uexcorp.space/vehicles/home/list/in_game_sell/?id_terminal=148",
}
]
},
"description": {"en_EN": "The Anvil Carrack features reinforced fuel tanks for long-duration flight."},
"web_url": "https://api.star-citizen.wiki/vehicles/anvl-carrack",
"updated_at": "2026-06-08T00:34:00Z",
"version": "4.8.1-LIVE.11952564",
}
class FakeWikelo:
base_url = "https://wikelo-projects.test"
async def list_ship_projects(self):
return [
{
"id": "ship-1",
"ship_name": "Polaris Wikelo Special",
"description": "Now make Polaris. Short Time Deal",
"status": "planning",
"privacy": "public",
"owner_name": "Chimpanz33",
"required_materials": [
{"material_name": "Wikelo Favor", "quantity_needed": 50.0, "quantity_collected": 0.0},
{"material_name": "Polaris Bit", "quantity_needed": 15.0, "quantity_collected": 2.0},
],
},
{
"id": "ship-2",
"ship_name": "Guardian",
"description": "Guardian Fight Mod",
"status": "planning",
"privacy": "public",
"owner_name": "Chimpanz33",
"required_materials": [
{"material_name": "Wikelo Favor", "quantity_needed": 20.0, "quantity_collected": 0.0},
],
},
]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_search_marketplace_listings_filters_locally(): async def test_search_marketplace_listings_filters_locally():
registry = ToolRegistry(FakeUEX()) registry = ToolRegistry(FakeUEX())
@@ -333,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())
@@ -368,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
@@ -395,6 +595,17 @@ def test_schemas_expose_cornerstone_item_tools():
assert "draft_marketplace_listing_with_cornerstone_image" in names assert "draft_marketplace_listing_with_cornerstone_image" in names
def test_schemas_expose_scwiki_tools():
registry = ToolRegistry(FakeUEX(), scwiki=FakeSCWiki())
names = {schema["function"]["name"] for schema in registry.schemas}
assert "search_scwiki_pages" in names
assert "get_scwiki_page" in names
assert "search_scwiki_vehicles" in names
assert "get_scwiki_vehicle" in names
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_search_scmdb_missions_returns_reward_summary(): async def test_search_scmdb_missions_returns_reward_summary():
registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB()) registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB())
@@ -469,6 +680,72 @@ async def test_get_cornerstone_item_media_returns_absolute_image_urls():
] ]
@pytest.mark.asyncio
async def test_search_scwiki_pages_returns_general_knowledge_matches():
registry = ToolRegistry(FakeUEX(), scwiki=FakeSCWiki())
result = await registry.search_scwiki_pages(query="Carrack")
assert result["source"] == "https://starcitizen.tools"
assert result["matched"] == 1
assert result["pages"][0]["title"] == "Carrack"
assert result["pages"][0]["url"] == "https://starcitizen.tools/Carrack"
@pytest.mark.asyncio
async def test_get_scwiki_vehicle_returns_ship_prices_and_store_context():
registry = ToolRegistry(FakeUEX(), scwiki=FakeSCWiki())
result = await registry.get_scwiki_vehicle(query="Carrack")
assert result["source"] == "https://api.star-citizen.wiki"
vehicle = result["vehicle"]
assert vehicle["name"] == "Carrack"
assert vehicle["manufacturer"] == "Anvil Aerospace"
assert vehicle["msrp"] == 600
assert vehicle["purchase_locations"] == [
{
"price_buy": 34398000,
"terminal_name": "Astro Armada - Area 18",
"location": "Area18",
"parent_location": "ArcCorp",
"star_system": "Stanton",
"game_version": "4.8.1-LIVE.11952564",
"date_updated": "2026-05-20T18:39:37-04:00",
"uex_link": "https://uexcorp.space/vehicles/home/list/in_game_sell/?id_terminal=148",
}
]
@pytest.mark.asyncio
async def test_search_wikelo_ship_projects_returns_material_matches():
registry = ToolRegistry(FakeUEX(), wikelo=FakeWikelo())
result = await registry.search_wikelo_ship_projects(query="Polaris")
assert result["source"] == "https://wikelo-projects.test/Ships"
assert result["matched"] == 1
assert result["projects"][0]["ship_name"] == "Polaris Wikelo Special"
assert result["projects"][0]["required_materials"][0]["material_name"] == "Wikelo Favor"
@pytest.mark.asyncio
async def test_get_wikelo_ship_project_returns_full_requirements():
registry = ToolRegistry(FakeUEX(), wikelo=FakeWikelo())
result = await registry.get_wikelo_ship_project(ship_name="Guardian")
assert result["project"]["ship_name"] == "Guardian"
assert result["project"]["materials_count"] == 1
assert result["project"]["required_materials"] == [
{
"material_name": "Wikelo Favor",
"quantity_needed": 20,
"quantity_collected": 0,
}
]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_draft_marketplace_listing_with_cornerstone_image_adds_image_data_and_redacts_display(): async def test_draft_marketplace_listing_with_cornerstone_image_adds_image_data_and_redacts_display():
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone()) registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
+453
View File
File diff suppressed because one or more lines are too long
+974 -42
View File
File diff suppressed because it is too large Load Diff
+27 -5
View File
@@ -17,10 +17,18 @@ CONFIG_FIELDS: dict[str, dict[str, Any]] = {
"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_base_url": {"env": "OPENAI_BASE_URL", "type": "string", "secret": False},
"openai_model": {"env": "OPENAI_MODEL", "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}, "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},
@@ -71,19 +79,27 @@ class Settings(BaseSettings):
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_base_url: str = "https://api.openai.com/v1"
openai_model: str = "gpt-5.3-codex" 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) 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("openai_api_key", "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
@@ -92,7 +108,13 @@ class Settings(BaseSettings):
@classmethod @classmethod
def _normalize_model_provider(cls, value: Any) -> str: def _normalize_model_provider(cls, value: Any) -> str:
text = str(value or "ollama").strip().casefold() text = str(value or "ollama").strip().casefold()
return text if text in {"ollama", "openai"} else "ollama" 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
@@ -151,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 {"openai_api_key", "uex_secret_key", "uex_bearer_token", "traderai_user_name"} else "" return None if key in {"openai_api_key", "deepseek_api_key", "uex_secret_key", "uex_bearer_token", "traderai_user_name"} else ""
if field_type == "integer": if field_type == "integer":
return int(value) return int(value)
if field_type == "boolean": if field_type == "boolean":
+28
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import os import os
from pathlib import Path from pathlib import Path
import shutil import shutil
@@ -25,6 +26,10 @@ def resource_path(*parts: str) -> Path:
def main() -> None: def main() -> None:
try: try:
_chdir_to_app_dir() _chdir_to_app_dir()
backend_port = _backend_port_from_args()
if backend_port is not None:
_run_server(backend_port)
return
_log("TraderAI desktop starting") _log("TraderAI desktop starting")
_log(f"cwd={Path.cwd()}") _log(f"cwd={Path.cwd()}")
_log(f"executable={sys.executable}") _log(f"executable={sys.executable}")
@@ -36,6 +41,10 @@ def main() -> None:
_log("existing TraderAI backend found; opening window") _log("existing TraderAI backend found; opening window")
_open_window(url) _open_window(url)
return return
if getattr(sys, "frozen", False):
backend_process = _start_backend_process(port)
_log(f"backend process started pid={backend_process.pid}")
else:
server_thread = threading.Thread(target=_run_server, args=(port,), daemon=True) server_thread = threading.Thread(target=_run_server, args=(port,), daemon=True)
server_thread.start() server_thread.start()
_log("backend thread started") _log("backend thread started")
@@ -62,6 +71,22 @@ def _select_port() -> int:
return _free_port() return _free_port()
def _backend_port_from_args() -> int | None:
args = sys.argv[1:]
if len(args) >= 2 and args[0] == "--backend-port":
return int(args[1])
return None
def _start_backend_process(port: int) -> subprocess.Popen:
command = [sys.executable, "--backend-port", str(port)]
_log(f"starting backend subprocess: {' '.join(command)}")
kwargs: dict[str, object] = {}
if sys.platform == "win32":
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
return subprocess.Popen(command, **kwargs)
def _port_available(port: int) -> bool: def _port_available(port: int) -> bool:
try: try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
@@ -88,6 +113,9 @@ def _existing_server_ready(url: str) -> bool:
def _run_server(port: int) -> NoReturn: def _run_server(port: int) -> NoReturn:
try: try:
_log(f"backend starting on port {port}") _log(f"backend starting on port {port}")
if sys.platform == "win32" and hasattr(asyncio, "WindowsProactorEventLoopPolicy"):
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
_log("set Windows Proactor event loop policy for subprocess-compatible backend")
from traderai.server import app from traderai.server import app
config = uvicorn.Config( config = uvicorn.Config(
+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()
+29 -5
View File
@@ -232,6 +232,17 @@ class ContinualPlanStore:
self.add_event(plan_id, status, f"Plan status changed to {status}.") self.add_event(plan_id, status, f"Plan status changed to {status}.")
return self.get_plan(plan_id) return self.get_plan(plan_id)
def delete_plan(self, plan_id: str) -> bool:
with self.memory._connect() as db:
deleted = db.execute("DELETE FROM continual_plans WHERE id = ?", (plan_id,)).rowcount
if not deleted:
return False
db.execute("DELETE FROM continual_plan_items WHERE plan_id = ?", (plan_id,))
db.execute("DELETE FROM continual_plan_candidates WHERE plan_id = ?", (plan_id,))
db.execute("DELETE FROM continual_plan_events WHERE plan_id = ?", (plan_id,))
db.execute("DELETE FROM continual_plan_negotiations WHERE plan_id = ?", (plan_id,))
return True
def add_event(self, plan_id: str, kind: str, message: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]: def add_event(self, plan_id: str, kind: str, message: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
now = iso_now() now = iso_now()
with self.memory._connect() as db: with self.memory._connect() as db:
@@ -517,12 +528,13 @@ class ContinualPlanRunner:
async def _draft_buying_message(self, plan: dict[str, Any], item: dict[str, Any], candidate: dict[str, Any]) -> dict[str, Any]: async def _draft_buying_message(self, plan: dict[str, Any], item: dict[str, Any], candidate: dict[str, Any]) -> dict[str, Any]:
tone = (plan.get("constraints") or {}).get("message_tone") or "polite and concise" tone = (plan.get("constraints") or {}).get("message_tone") or "polite and concise"
greeting = "Hi" if "professional" in str(tone).casefold() or "polite" in str(tone).casefold() else "Hey"
build_context = self._plan_build_context(plan["objective"])
message = ( message = (
f"Hi, I am interested in your {candidate.get('title') or item['item_name']} listing " f"{greeting}, is your {candidate.get('title') or item['item_name']} listing still available "
f"for {self._format_price(candidate.get('price'), candidate.get('currency'))}. " f"for {self._format_price(candidate.get('price'), candidate.get('currency'))}? "
f"Is it still available? I am trying to complete: {plan['objective']}. " f"{build_context}If you still have it, I can move quickly."
f"Tone note: {tone}." ).strip()
)
return await self.tools.draft_negotiation_message( return await self.tools.draft_negotiation_message(
message=message, message=message,
id_listing=self._int_or_none(candidate.get("listing_id")), id_listing=self._int_or_none(candidate.get("listing_id")),
@@ -532,6 +544,18 @@ class ContinualPlanRunner:
listing_slug=candidate.get("listing_slug"), listing_slug=candidate.get("listing_slug"),
) )
@staticmethod
def _plan_build_context(objective: str) -> str:
text = str(objective or "").strip().rstrip(".")
if not text:
return ""
lowered = text.casefold()
if "polaris" in lowered:
return "I'm putting together parts for a Polaris build. "
if "mission" in lowered:
return "I'm trying to wrap up a mission build. "
return "I'm sourcing parts for a build. "
@staticmethod @staticmethod
def _candidate_score(listing: dict[str, Any], item: dict[str, Any], preferred_locations: list[str]) -> float: def _candidate_score(listing: dict[str, Any], item: dict[str, Any], preferred_locations: list[str]) -> float:
price = float(listing.get("price") or 10**12) price = float(listing.get("price") or 10**12)
+548 -41
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
@@ -26,9 +27,11 @@ from traderai.memory import DEFAULT_THREAD_ID, MemoryStore
from traderai.plans import ContinualPlanRunner, ContinualPlanStore 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:
@@ -83,6 +86,14 @@ class ContinualPlanCreateRequest(BaseModel):
items: list[ContinualPlanItemRequest] = [] items: list[ContinualPlanItemRequest] = []
class ContinualPlanDraftRequest(BaseModel):
title: str = ""
objective: str = ""
kind: str = "buying"
constraints: dict[str, Any] = {}
items: list[ContinualPlanItemRequest] = []
class ContinualPlanEventRequest(BaseModel): class ContinualPlanEventRequest(BaseModel):
kind: str = "note" kind: str = "note"
message: str message: str
@@ -106,34 +117,54 @@ def create_app() -> FastAPI:
memory = MemoryStore(settings.traderai_memory_path) memory = MemoryStore(settings.traderai_memory_path)
plan_store = ContinualPlanStore(memory) 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_store=plan_store,
) )
plan_runner = ContinualPlanRunner(plan_store, tools, memory) plan_runner = ContinualPlanRunner(plan_store, tools, memory)
tools.plan_runner = plan_runner tools.plan_runner = plan_runner
provider_base_url, provider_model, provider_api_key = provider_settings(current_settings)
agent = OllamaAgent( agent = OllamaAgent(
settings.openai_base_url if settings.model_provider == "openai" else settings.ollama_base_url, provider_base_url,
settings.openai_model if settings.model_provider == "openai" else 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=settings.model_provider, provider=current_settings.model_provider,
api_key=settings.openai_api_key, api_key=provider_api_key,
reasoning_effort=current_settings.model_reasoning_effort,
) )
plan_runner.bind_agent(agent) plan_runner.bind_agent(agent)
scheduler.bind_agent(agent) scheduler.bind_agent(agent)
scheduler.bind_plan_runner(plan_runner) scheduler.bind_plan_runner(plan_runner)
scheduler.bind_uex_notifications(uex, settings.uex_notification_poll_seconds) 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")
@@ -149,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:
@@ -178,9 +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,
"model_provider": settings.model_provider, "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"],
@@ -193,27 +231,62 @@ 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_model_provider() return await inspect_model_provider()
@app.get("/api/openai/models") @app.get("/api/provider/models")
async def openai_models() -> dict: async def provider_models(provider: str | None = None) -> dict:
status = await inspect_openai() status = await inspect_provider_models(provider)
return { return {
"provider": "openai", "provider": status.get("provider", "openai"),
"configured_model": status.get("configured_model"), "configured_model": status.get("configured_model"),
"models": status.get("models", []), "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", ""), "message": status.get("message", ""),
"detail": status.get("detail", ""), "detail": status.get("detail", ""),
"online": status.get("online", False), "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:
command = ollama_launch_command() command = ollama_launch_command()
@@ -319,6 +392,7 @@ 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( return await agent.chat(
request.message, request.message,
@@ -330,6 +404,8 @@ def create_app() -> FastAPI:
@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( async for event in agent.chat_events(
request.message, request.message,
@@ -367,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")
@@ -393,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)
@@ -412,6 +491,7 @@ def create_app() -> FastAPI:
@app.post("/api/plans") @app.post("/api/plans")
async def create_continual_plan(request: ContinualPlanCreateRequest) -> dict: async def create_continual_plan(request: ContinualPlanCreateRequest) -> dict:
tools = runtime["tools"]
result = await tools.create_continual_plan( result = await tools.create_continual_plan(
title=request.title, title=request.title,
objective=request.objective, objective=request.objective,
@@ -424,6 +504,18 @@ def create_app() -> FastAPI:
raise HTTPException(status_code=400, detail=result["error"]) raise HTTPException(status_code=400, detail=result["error"])
return result 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}") @app.get("/api/plans/{plan_id}")
async def continual_plan(plan_id: str) -> dict: async def continual_plan(plan_id: str) -> dict:
plan = plan_store.get_plan(plan_id) plan = plan_store.get_plan(plan_id)
@@ -433,6 +525,7 @@ def create_app() -> FastAPI:
@app.post("/api/plans/{plan_id}/pause") @app.post("/api/plans/{plan_id}/pause")
async def pause_continual_plan(plan_id: str) -> dict: async def pause_continual_plan(plan_id: str) -> dict:
tools = runtime["tools"]
result = await tools.pause_continual_plan(plan_id) result = await tools.pause_continual_plan(plan_id)
if result.get("error"): if result.get("error"):
raise HTTPException(status_code=404, detail=result["error"]) raise HTTPException(status_code=404, detail=result["error"])
@@ -440,6 +533,7 @@ def create_app() -> FastAPI:
@app.post("/api/plans/{plan_id}/resume") @app.post("/api/plans/{plan_id}/resume")
async def resume_continual_plan(plan_id: str) -> dict: async def resume_continual_plan(plan_id: str) -> dict:
tools = runtime["tools"]
result = await tools.resume_continual_plan(plan_id) result = await tools.resume_continual_plan(plan_id)
if result.get("error"): if result.get("error"):
raise HTTPException(status_code=404, detail=result["error"]) raise HTTPException(status_code=404, detail=result["error"])
@@ -447,13 +541,23 @@ def create_app() -> FastAPI:
@app.post("/api/plans/{plan_id}/cancel") @app.post("/api/plans/{plan_id}/cancel")
async def cancel_continual_plan(plan_id: str) -> dict: async def cancel_continual_plan(plan_id: str) -> dict:
tools = runtime["tools"]
result = await tools.cancel_continual_plan(plan_id) result = await tools.cancel_continual_plan(plan_id)
if result.get("error"): if result.get("error"):
raise HTTPException(status_code=404, detail=result["error"]) raise HTTPException(status_code=404, detail=result["error"])
return result 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") @app.post("/api/plans/{plan_id}/run")
async def run_continual_plan(plan_id: str) -> dict: async def run_continual_plan(plan_id: str) -> dict:
tools = runtime["tools"]
result = await tools.run_continual_plan_now(plan_id) result = await tools.run_continual_plan_now(plan_id)
if result.get("error"): if result.get("error"):
raise HTTPException(status_code=400, detail=result["error"]) raise HTTPException(status_code=400, detail=result["error"])
@@ -487,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
@@ -509,33 +615,112 @@ async def inspect_model_provider() -> dict[str, Any]:
settings = get_settings() settings = get_settings()
if settings.model_provider == "openai": if settings.model_provider == "openai":
return await inspect_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() return await inspect_ollama()
async def inspect_openai() -> dict[str, Any]: 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() settings = get_settings()
models: list[str] = [] models: list[str] = []
online = False online = False
detail = "" detail = ""
if not settings.openai_api_key: provider_name = provider_display_name(provider)
if not api_key:
return { return {
"installed": True, "installed": True,
"running": False, "running": False,
"online": False, "online": False,
"provider": "openai", "provider": provider,
"model_available": False, "model_available": False,
"configured_model": settings.openai_model, "configured_model": model,
"base_url": settings.openai_base_url, "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": "OpenAI is selected, but no API key is configured.", "message": f"{provider_name} is selected, but no API key is configured.",
"detail": "", "detail": "",
} }
try: try:
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=10) as client:
response = await client.get( response = await client.get(
f"{settings.openai_base_url.rstrip('/')}/models", f"{base_url.rstrip('/')}/models",
headers={"Authorization": f"Bearer {settings.openai_api_key}"}, headers={"Authorization": f"Bearer {api_key}"},
) )
response.raise_for_status() response.raise_for_status()
body = response.json() body = response.json()
@@ -544,17 +729,19 @@ async def inspect_openai() -> dict[str, Any]:
except (httpx.HTTPError, ValueError) as exc: except (httpx.HTTPError, ValueError) as exc:
detail = str(exc) detail = str(exc)
model_available = settings.openai_model in models model_available = model in models
return { return {
"installed": True, "installed": True,
"running": online, "running": online,
"online": online, "online": online,
"provider": "openai", "provider": provider,
"model_available": model_available, "model_available": model_available,
"configured_model": settings.openai_model, "configured_model": model,
"base_url": settings.openai_base_url, "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, "models": models,
"message": openai_status_message(online, bool(settings.openai_api_key), model_available, settings.openai_model), "message": cloud_status_message(provider, online, bool(api_key), model_available, model),
"detail": detail, "detail": detail,
} }
@@ -587,6 +774,8 @@ async def inspect_ollama() -> dict[str, Any]:
"provider": "ollama", "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,
@@ -599,14 +788,15 @@ async def inspect_ollama() -> dict[str, Any]:
} }
def openai_status_message(running: bool, configured: bool, model_available: bool, model: str) -> str: 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: if not configured:
return "OpenAI API key is not configured." return f"{provider_name} API key is not configured."
if not running: if not running:
return "OpenAI is not reachable with the configured key." return f"{provider_name} is not reachable with the configured key."
if not model_available: if not model_available:
return f'OpenAI is reachable, but model "{model}" was not returned by the API.' return f'{provider_name} is reachable, but model "{model}" was not returned by the API.'
return "OpenAI is ready." 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:
@@ -619,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"),
@@ -671,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
+425 -3
View File
@@ -10,7 +10,9 @@ from traderai.cornerstone_client import CornerstoneClient, parse_cornerstone_ite
from traderai.memory import MemoryStore from traderai.memory import MemoryStore
from traderai.scheduler import WakeScheduler from traderai.scheduler import WakeScheduler
from traderai.scmdb_client import SCMDBClient from traderai.scmdb_client import SCMDBClient
from traderai.starcitizen_wiki_client import StarCitizenWikiClient
from traderai.uex_client import UEXClient from traderai.uex_client import UEXClient
from traderai.wikelo_projects_client import WikeloProjectsClient
ToolHandler = Callable[..., Awaitable[dict[str, Any]]] ToolHandler = Callable[..., Awaitable[dict[str, Any]]]
@@ -58,10 +60,14 @@ UEX_GET_RESOURCES: dict[str, dict[str, Any]] = {
"marketplace_averages": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "marketplace"}, "marketplace_averages": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "marketplace"},
"marketplace_averages_all": {"params": [], "auth": False, "group": "marketplace", "heavy": True}, "marketplace_averages_all": {"params": [], "auth": False, "group": "marketplace", "heavy": True},
"marketplace_favorites": {"params": ["id_listing"], "auth": True, "group": "marketplace"}, "marketplace_favorites": {"params": ["id_listing"], "auth": True, "group": "marketplace"},
"marketplace_listings": {"params": ["id", "slug", "username"], "auth": False, "group": "marketplace"}, "marketplace_listings": {"params": ["id", "slug", "username", "id_item", "operation"], "auth": False, "group": "marketplace"},
"marketplace_negotiations": {"params": ["id", "id_listing", "hash"], "auth": True, "group": "marketplace"}, "marketplace_negotiations": {"params": ["id", "id_listing", "hash"], "auth": True, "group": "marketplace"},
"marketplace_negotiations_messages": {"params": ["hash", "id_negotiation"], "auth": True, "group": "marketplace"}, "marketplace_negotiations_messages": {"params": ["hash", "id_negotiation"], "auth": True, "group": "marketplace"},
"marketplace_prices_averages": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "marketplace"}, "marketplace_prices_averages": {
"params": ["id_item", "item_name", "item_slug", "id_category", "currency", "quality_tier"],
"auth": False,
"group": "marketplace",
},
"marketplace_prices_averages_all": {"params": [], "auth": False, "group": "marketplace", "heavy": True}, "marketplace_prices_averages_all": {"params": [], "auth": False, "group": "marketplace", "heavy": True},
"marketplace_prices_history": { "marketplace_prices_history": {
"params": [ "params": [
@@ -83,7 +89,11 @@ UEX_GET_RESOURCES: dict[str, dict[str, Any]] = {
"group": "marketplace", "group": "marketplace",
"history": True, "history": True,
}, },
"marketplace_trends": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "marketplace"}, "marketplace_trends": {
"params": ["id_item", "item_name", "item_slug", "id_category", "currency", "quality_tier"],
"auth": False,
"group": "marketplace",
},
"moons": {"params": ["id", "id_planet", "id_star_system", "name", "slug"], "auth": False, "group": "locations"}, "moons": {"params": ["id", "id_planet", "id_star_system", "name", "slug"], "auth": False, "group": "locations"},
"orbits": {"params": ["id", "id_star_system", "name", "slug"], "auth": False, "group": "locations"}, "orbits": {"params": ["id", "id_star_system", "name", "slug"], "auth": False, "group": "locations"},
"orbits_distances": {"params": ["id_origin", "id_destination"], "auth": False, "group": "locations"}, "orbits_distances": {"params": ["id_origin", "id_destination"], "auth": False, "group": "locations"},
@@ -162,12 +172,16 @@ class ToolRegistry:
scheduler: WakeScheduler | None = None, scheduler: WakeScheduler | None = None,
scmdb: SCMDBClient | None = None, scmdb: SCMDBClient | None = None,
cornerstone: CornerstoneClient | None = None, cornerstone: CornerstoneClient | None = None,
scwiki: StarCitizenWikiClient | None = None,
wikelo: WikeloProjectsClient | None = None,
plan_store: Any | None = None, plan_store: Any | None = None,
plan_runner: Any | None = None, plan_runner: Any | None = None,
) -> None: ) -> None:
self.uex = uex self.uex = uex
self.scmdb = scmdb or SCMDBClient() self.scmdb = scmdb or SCMDBClient()
self.cornerstone = cornerstone or CornerstoneClient() self.cornerstone = cornerstone or CornerstoneClient()
self.scwiki = scwiki or StarCitizenWikiClient()
self.wikelo = wikelo or WikeloProjectsClient()
self.require_write_approval = require_write_approval self.require_write_approval = require_write_approval
self.memory = memory self.memory = memory
self.scheduler = scheduler self.scheduler = scheduler
@@ -178,6 +192,7 @@ class ToolRegistry:
self.handlers: dict[str, ToolHandler] = { self.handlers: dict[str, ToolHandler] = {
"search_marketplace_listings": self.search_marketplace_listings, "search_marketplace_listings": self.search_marketplace_listings,
"get_marketplace_listing": self.get_marketplace_listing, "get_marketplace_listing": self.get_marketplace_listing,
"get_marketplace_trends": self.get_marketplace_trends,
"list_marketplace_negotiations": self.list_marketplace_negotiations, "list_marketplace_negotiations": self.list_marketplace_negotiations,
"get_negotiation_messages": self.get_negotiation_messages, "get_negotiation_messages": self.get_negotiation_messages,
"draft_negotiation_message": self.draft_negotiation_message, "draft_negotiation_message": self.draft_negotiation_message,
@@ -192,11 +207,18 @@ class ToolRegistry:
"pause_continual_plan": self.pause_continual_plan, "pause_continual_plan": self.pause_continual_plan,
"resume_continual_plan": self.resume_continual_plan, "resume_continual_plan": self.resume_continual_plan,
"cancel_continual_plan": self.cancel_continual_plan, "cancel_continual_plan": self.cancel_continual_plan,
"delete_continual_plan": self.delete_continual_plan,
"run_continual_plan_now": self.run_continual_plan_now, "run_continual_plan_now": self.run_continual_plan_now,
"check_uex_notifications": self.check_uex_notifications, "check_uex_notifications": self.check_uex_notifications,
"list_scmdb_versions": self.list_scmdb_versions, "list_scmdb_versions": self.list_scmdb_versions,
"search_scmdb_missions": self.search_scmdb_missions, "search_scmdb_missions": self.search_scmdb_missions,
"get_scmdb_mission_rewards": self.get_scmdb_mission_rewards, "get_scmdb_mission_rewards": self.get_scmdb_mission_rewards,
"search_scwiki_pages": self.search_scwiki_pages,
"get_scwiki_page": self.get_scwiki_page,
"search_scwiki_vehicles": self.search_scwiki_vehicles,
"get_scwiki_vehicle": self.get_scwiki_vehicle,
"search_wikelo_ship_projects": self.search_wikelo_ship_projects,
"get_wikelo_ship_project": self.get_wikelo_ship_project,
"search_cornerstone_items": self.search_cornerstone_items, "search_cornerstone_items": self.search_cornerstone_items,
"get_cornerstone_item_locations": self.get_cornerstone_item_locations, "get_cornerstone_item_locations": self.get_cornerstone_item_locations,
"get_cornerstone_item_media": self.get_cornerstone_item_media, "get_cornerstone_item_media": self.get_cornerstone_item_media,
@@ -226,6 +248,8 @@ class ToolRegistry:
*self._uex_post_schemas(), *self._uex_post_schemas(),
*self._uex_delete_schemas(), *self._uex_delete_schemas(),
*self._scmdb_schemas(), *self._scmdb_schemas(),
*self._scwiki_schemas(),
*self._wikelo_schemas(),
*self._cornerstone_schemas(), *self._cornerstone_schemas(),
{ {
"type": "function", "type": "function",
@@ -261,6 +285,24 @@ class ToolRegistry:
}, },
}, },
}, },
{
"type": "function",
"function": {
"name": "get_marketplace_trends",
"description": "Fetch current UEX marketplace trend metrics for an item, including WTS and WTB averages plus negotiation counts.",
"parameters": {
"type": "object",
"properties": {
"id_item": {"type": "integer"},
"item_name": {"type": "string"},
"item_slug": {"type": "string"},
"id_category": {"type": "integer"},
"currency": {"type": "string", "description": "Optional currency filter such as UEC, WIF, or MGS."},
"quality_tier": {"type": "integer", "minimum": 0, "maximum": 7},
},
},
},
},
{ {
"type": "function", "type": "function",
"function": { "function": {
@@ -480,6 +522,14 @@ class ToolRegistry:
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}}, "parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
}, },
}, },
{
"type": "function",
"function": {
"name": "delete_continual_plan",
"description": "Delete a continual plan and all of its stored checklist items, candidates, negotiations, and event history.",
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
},
},
{ {
"type": "function", "type": "function",
"function": { "function": {
@@ -965,6 +1015,101 @@ class ToolRegistry:
}, },
] ]
@classmethod
def _scwiki_schemas(cls) -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": "search_scwiki_pages",
"description": "Search Star Citizen Wiki pages on starcitizen.tools and return concise summaries for general game knowledge.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Page title or topic to search for."},
"limit": {"type": "integer", "minimum": 1, "maximum": 10, "default": 5},
},
},
},
},
{
"type": "function",
"function": {
"name": "get_scwiki_page",
"description": "Fetch one Star Citizen Wiki page summary by title or page id.",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string"},
"pageid": {"type": "integer"},
"chars": {"type": "integer", "minimum": 120, "maximum": 1200, "default": 700},
},
},
},
},
{
"type": "function",
"function": {
"name": "search_scwiki_vehicles",
"description": "Search Star Citizen Wiki structured vehicle data for ships and vehicles.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Ship or vehicle name to search for."},
"limit": {"type": "integer", "minimum": 1, "maximum": 10, "default": 5},
},
},
},
},
{
"type": "function",
"function": {
"name": "get_scwiki_vehicle",
"description": "Fetch one Star Citizen Wiki vehicle summary, including MSRP and in-game purchase locations when available.",
"parameters": {
"type": "object",
"properties": {
"slug": {"type": "string", "description": "Vehicle slug such as anvl-carrack."},
"query": {"type": "string", "description": "Vehicle name if the slug is not known."},
},
},
},
},
]
@classmethod
def _wikelo_schemas(cls) -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": "search_wikelo_ship_projects",
"description": "Search Wikelo ship projects and their required materials from wikelo-projects.com. Use this when the user asks for Wikelo ship requirements or build materials.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Ship or project name to search for, such as Polaris, Idris, Zeus, or Guardian."},
"limit": {"type": "integer", "minimum": 1, "maximum": 10, "default": 5},
},
},
},
},
{
"type": "function",
"function": {
"name": "get_wikelo_ship_project",
"description": "Fetch one Wikelo ship project with its required materials and contribution progress.",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Wikelo ship project id."},
"ship_name": {"type": "string", "description": "Ship or project name if the project id is not known."},
},
},
},
},
]
@classmethod @classmethod
def _cornerstone_schemas(cls) -> list[dict[str, Any]]: def _cornerstone_schemas(cls) -> list[dict[str, Any]]:
return [ return [
@@ -1213,6 +1358,49 @@ class ToolRegistry:
response = await self.uex.get("marketplace_listings", {"id": id, "slug": slug}) response = await self.uex.get("marketplace_listings", {"id": id, "slug": slug})
return {"listing": response.get("data")} return {"listing": response.get("data")}
async def get_marketplace_trends(
self,
id_item: int | None = None,
item_name: str | None = None,
item_slug: str | None = None,
id_category: int | None = None,
currency: str | None = None,
quality_tier: int | None = None,
) -> dict[str, Any]:
response = await self.uex.get(
"marketplace_trends",
{
"id_item": id_item,
"item_name": item_name,
"item_slug": item_slug,
"id_category": id_category,
"currency": currency,
"quality_tier": quality_tier,
},
)
trends = [
self._summarize_marketplace_trend(item)
for item in self._as_list(response.get("data"))
if isinstance(item, dict)
]
return {
"status": response.get("status"),
"count": len(trends),
"filters": {
key: value
for key, value in {
"id_item": id_item,
"item_name": item_name,
"item_slug": item_slug,
"id_category": id_category,
"currency": currency,
"quality_tier": quality_tier,
}.items()
if value is not None
},
"trends": trends,
}
async def list_marketplace_negotiations( async def list_marketplace_negotiations(
self, self,
id: int | None = None, id: int | None = None,
@@ -1405,6 +1593,19 @@ class ToolRegistry:
self.scheduler.unschedule_plan(plan_id) self.scheduler.unschedule_plan(plan_id)
return {"plan": self.plan_store.set_status(plan_id, "canceled")} return {"plan": self.plan_store.set_status(plan_id, "canceled")}
async def delete_continual_plan(self, plan_id: str) -> dict[str, Any]:
if self.plan_store is None:
return {"error": "Continual plan store is not configured."}
plan = self.plan_store.get_plan(plan_id)
if not plan:
return {"error": f"Plan not found: {plan_id}"}
if self.scheduler is not None:
self.scheduler.unschedule_plan(plan_id)
deleted = self.plan_store.delete_plan(plan_id)
if not deleted:
return {"error": f"Plan not found: {plan_id}"}
return {"deleted": True, "plan_id": plan_id, "summary": f"Deleted plan {plan.get('title') or plan_id}."}
async def run_continual_plan_now(self, plan_id: str) -> dict[str, Any]: async def run_continual_plan_now(self, plan_id: str) -> dict[str, Any]:
if self.plan_runner is None: if self.plan_runner is None:
return {"error": "Continual plan runner is not configured."} return {"error": "Continual plan runner is not configured."}
@@ -1535,6 +1736,94 @@ class ToolRegistry:
"mission": self._summarize_scmdb_mission(data, mission, source=source, detailed=True), "mission": self._summarize_scmdb_mission(data, mission, source=source, detailed=True),
} }
async def search_scwiki_pages(self, query: str, limit: int = 5) -> dict[str, Any]:
pages = await self.scwiki.search_pages(query, limit=limit)
return {"source": self.scwiki.base_url, "query": query, "matched": len(pages), "pages": pages}
async def get_scwiki_page(
self,
title: str | None = None,
pageid: int | None = None,
chars: int = 700,
) -> dict[str, Any]:
page = await self.scwiki.get_page_summary(title=title, pageid=pageid, chars=chars)
if not page:
return {"error": "No Star Citizen Wiki page matched."}
return {"source": self.scwiki.base_url, "page": page}
async def search_scwiki_vehicles(self, query: str, limit: int = 5) -> dict[str, Any]:
groups = await self.scwiki.search_verse(query)
vehicles_group = next((item for item in groups if item.get("type") == "vehicles"), None)
results = [
self._summarize_scwiki_vehicle_search(item)
for item in (vehicles_group or {}).get("results", [])[: max(1, min(limit, 10))]
if isinstance(item, dict)
]
return {"source": self.scwiki.api_base_url, "query": query, "matched": len(results), "vehicles": results}
async def get_scwiki_vehicle(self, slug: str | None = None, query: str | None = None) -> dict[str, Any]:
resolved_slug = slug
if not resolved_slug:
if not query:
return {"error": "Provide slug or query."}
groups = await self.scwiki.search_verse(query)
vehicles_group = next((item for item in groups if item.get("type") == "vehicles"), None)
candidates = [
item
for item in (vehicles_group or {}).get("results", [])
if isinstance(item, dict) and item.get("api_url")
]
if not candidates:
return {"error": "No Star Citizen Wiki vehicle matched."}
resolved_slug = str(candidates[0]["api_url"]).rstrip("/").rsplit("/", 1)[-1]
vehicle = await self.scwiki.get_vehicle(resolved_slug)
return {"source": self.scwiki.api_base_url, "vehicle": self._summarize_scwiki_vehicle(vehicle)}
async def search_wikelo_ship_projects(self, query: str, limit: int = 5) -> dict[str, Any]:
projects = await self.wikelo.list_ship_projects()
q = (query or "").casefold().strip()
matches = []
for project in projects:
score = self._wikelo_ship_match_score(q, project)
if q and score <= 0:
continue
matches.append((score, project))
matches.sort(
key=lambda match: (
-match[0],
str(match[1].get("ship_name") or "").casefold(),
str(match[1].get("id") or ""),
)
)
limit = max(1, min(limit, 10))
return {
"source": f"{self.wikelo.base_url}/Ships",
"query": query,
"matched": len(matches),
"projects": [self._summarize_wikelo_ship_project(item) for _, item in matches[:limit]],
}
async def get_wikelo_ship_project(self, project_id: str | None = None, ship_name: str | None = None) -> dict[str, Any]:
projects = await self.wikelo.list_ship_projects()
if project_id:
for project in projects:
if str(project.get("id") or "").strip() == str(project_id).strip():
return {"source": f"{self.wikelo.base_url}/Ships", "project": self._summarize_wikelo_ship_project(project, detailed=True)}
return {"error": "No Wikelo ship project matched that id."}
if not ship_name:
return {"error": "Provide project_id or ship_name."}
ranked = [
(self._wikelo_ship_match_score(ship_name.casefold().strip(), project), project)
for project in projects
]
ranked = [match for match in ranked if match[0] > 0]
ranked.sort(key=lambda match: (-match[0], str(match[1].get("ship_name") or "").casefold()))
if not ranked:
return {"error": "No Wikelo ship project matched."}
return {"source": f"{self.wikelo.base_url}/Ships", "project": self._summarize_wikelo_ship_project(ranked[0][1], detailed=True)}
async def search_cornerstone_items( async def search_cornerstone_items(
self, self,
query: str = "", query: str = "",
@@ -2210,6 +2499,139 @@ class ToolRegistry:
"expires_at": listing.get("date_expiration"), "expires_at": listing.get("date_expiration"),
} }
@staticmethod
def _summarize_marketplace_trend(trend: dict[str, Any]) -> dict[str, Any]:
return {
"id_item": trend.get("id_item"),
"item_name": trend.get("item_name"),
"item_slug": trend.get("item_slug"),
"currency": trend.get("currency"),
"sell": {
"avg_price": trend.get("price_avg_sell"),
"avg_price_month": trend.get("price_avg_month_sell"),
"min_price": trend.get("price_min_sell"),
"max_price": trend.get("price_max_sell"),
"listings_count": trend.get("listings_count_sell"),
},
"buy": {
"avg_price": trend.get("price_avg_buy"),
"avg_price_month": trend.get("price_avg_month_buy"),
"min_price": trend.get("price_min_buy"),
"max_price": trend.get("price_max_buy"),
"listings_count": trend.get("listings_count_buy"),
},
"total_listings_count": trend.get("total_listings_count"),
"negotiations_count": trend.get("negotiations_count"),
"negotiations_open": trend.get("negotiations_open"),
"negotiations_success": trend.get("negotiations_success"),
"link_prices": trend.get("link_prices"),
"link_prices_history": trend.get("link_prices_history"),
}
@staticmethod
def _summarize_scwiki_vehicle_search(vehicle: dict[str, Any]) -> dict[str, Any]:
return {
"name": vehicle.get("name"),
"class_name": vehicle.get("class_name"),
"career": vehicle.get("extra_label"),
"api_url": vehicle.get("api_url"),
"web_url": vehicle.get("web_url"),
}
@staticmethod
def _summarize_scwiki_vehicle(vehicle: dict[str, Any]) -> dict[str, Any]:
purchases = []
for entry in ((vehicle.get("uex_prices") or {}).get("purchase") or []):
if not isinstance(entry, dict):
continue
location = entry.get("starmap_location") or {}
purchases.append(
{
"price_buy": entry.get("price_buy"),
"terminal_name": entry.get("terminal_name"),
"location": location.get("name"),
"parent_location": location.get("parent_name"),
"star_system": location.get("star_system_name"),
"game_version": entry.get("game_version"),
"date_updated": entry.get("date_updated"),
"uex_link": entry.get("uex_link"),
}
)
return {
"name": vehicle.get("name") or vehicle.get("game_name"),
"game_name": vehicle.get("game_name"),
"slug": vehicle.get("slug"),
"manufacturer": (vehicle.get("manufacturer") or {}).get("name"),
"career": vehicle.get("career"),
"role": vehicle.get("role"),
"size_class": vehicle.get("size_class"),
"cargo_capacity": vehicle.get("cargo_capacity"),
"crew": vehicle.get("crew"),
"msrp": vehicle.get("msrp"),
"pledge_url": vehicle.get("pledge_url"),
"purchase_locations": purchases,
"description": ((vehicle.get("description") or {}).get("en_EN") or (vehicle.get("game_description") or {}).get("en_EN")),
"web_url": vehicle.get("web_url"),
"updated_at": vehicle.get("updated_at"),
"version": vehicle.get("version"),
}
@staticmethod
def _wikelo_ship_match_score(query: str, project: dict[str, Any]) -> int:
if not query:
return 1
ship_name = str(project.get("ship_name") or "").casefold()
description = str(project.get("description") or "").casefold()
materials = " ".join(
str(item.get("material_name") or "").casefold()
for item in (project.get("required_materials") or [])
if isinstance(item, dict)
)
haystack = " ".join(part for part in [ship_name, description, materials] if part)
if ship_name == query:
return 10000
if query in ship_name:
return 9000 - ship_name.index(query)
if query in description:
return 7000 - description.index(query)
if query in materials:
return 5000 - materials.index(query)
tokens = [token for token in query.split() if token]
if tokens and all(token in haystack for token in tokens):
return 3000 - len(haystack)
return 0
@classmethod
def _summarize_wikelo_ship_project(cls, project: dict[str, Any], detailed: bool = False) -> dict[str, Any]:
materials = []
for item in (project.get("required_materials") or []):
if not isinstance(item, dict):
continue
quantity_needed = item.get("quantity_needed")
quantity_collected = item.get("quantity_collected")
materials.append(
{
"material_name": item.get("material_name"),
"quantity_needed": int(quantity_needed) if isinstance(quantity_needed, (int, float)) and float(quantity_needed).is_integer() else quantity_needed,
"quantity_collected": int(quantity_collected) if isinstance(quantity_collected, (int, float)) and float(quantity_collected).is_integer() else quantity_collected,
}
)
summary = {
"id": project.get("id"),
"ship_name": project.get("ship_name"),
"description": project.get("description"),
"status": project.get("status"),
"privacy": project.get("privacy"),
"owner_name": project.get("owner_name"),
"org_name": project.get("org_name"),
"home_port": project.get("home_port"),
"ship_image": project.get("ship_image"),
"materials_count": len(materials),
"required_materials": materials if detailed else materials[:12],
"source_url": f"https://wikelo-projects.com/Ships",
}
return {key: value for key, value in summary.items() if value not in (None, "", [], {})}
@classmethod @classmethod
def _summarize_negotiation(cls, negotiation: dict[str, Any]) -> dict[str, Any]: def _summarize_negotiation(cls, negotiation: dict[str, Any]) -> dict[str, Any]:
summary = cls._project_item(negotiation, mode="summary") summary = cls._project_item(negotiation, mode="summary")
+4 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
__version__ = "0.0.6" __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"
@@ -12,3 +12,6 @@ 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
+4 -1
View File
@@ -755,7 +755,7 @@ wheels = [
[[package]] [[package]]
name = "traderai" name = "traderai"
version = "0.0.6" version = "0.0.8"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "apscheduler" }, { name = "apscheduler" },
@@ -1052,3 +1052,6 @@ wheels = [
+347 -58
View File
@@ -26,7 +26,9 @@ const ollamaDownloadButton = document.getElementById("ollama-download");
const ollamaInstallButton = document.getElementById("ollama-install"); const ollamaInstallButton = document.getElementById("ollama-install");
const ollamaLaunchButton = document.getElementById("ollama-launch"); const ollamaLaunchButton = document.getElementById("ollama-launch");
const ollamaPullButton = document.getElementById("ollama-pull"); const ollamaPullButton = document.getElementById("ollama-pull");
const openaiModelsRefreshButton = document.getElementById("openai-models-refresh"); const providerModelSelect = document.getElementById("provider-model-select");
const providerModelLabel = document.getElementById("provider-model-label");
const modelReasoningEffortSelect = document.getElementById("model-reasoning-effort");
const ollamaStatusEl = document.getElementById("ollama-status"); const ollamaStatusEl = document.getElementById("ollama-status");
const ollamaMessageEl = document.getElementById("ollama-message"); const ollamaMessageEl = document.getElementById("ollama-message");
const updateCheckButton = document.getElementById("update-check"); const updateCheckButton = document.getElementById("update-check");
@@ -54,9 +56,11 @@ const updateModalReleases = document.getElementById("update-modal-releases");
const plansRefreshButton = document.getElementById("plans-refresh"); const plansRefreshButton = document.getElementById("plans-refresh");
const plansCloseButton = document.getElementById("plans-close"); const plansCloseButton = document.getElementById("plans-close");
const planForm = document.getElementById("plan-form"); const planForm = document.getElementById("plan-form");
const planAutofillButton = document.getElementById("plan-autofill");
const plansStatusEl = document.getElementById("plans-status"); const plansStatusEl = document.getElementById("plans-status");
const plansDashboardEl = document.getElementById("plans-dashboard"); const plansDashboardEl = document.getElementById("plans-dashboard");
const plansRailListEl = document.getElementById("plans-rail-list"); const plansRailListEl = document.getElementById("plans-rail-list");
const providerScopedFields = Array.from(document.querySelectorAll("[data-provider-scope]"));
let ollamaOnline = true; let ollamaOnline = true;
let latestUpdate = null; let latestUpdate = null;
@@ -160,6 +164,8 @@ function appendThinkingText(node, text) {
steps.appendChild(item); steps.appendChild(item);
} }
item.textContent += text; item.textContent += text;
const thinking = node.querySelector(".thinking-log");
if (thinking && !thinking.open) thinking.open = true;
} }
function createThinkTagParser(node) { function createThinkTagParser(node) {
@@ -560,7 +566,8 @@ function renderComposerImages() {
function formatMetrics(event) { function formatMetrics(event) {
const read = formatTokenMetric(event.reading_tokens, event.reading_tokens_per_second); const read = formatTokenMetric(event.reading_tokens, event.reading_tokens_per_second);
const wrote = formatTokenMetric(event.writing_tokens, event.writing_tokens_per_second); const wrote = formatTokenMetric(event.writing_tokens, event.writing_tokens_per_second);
return [read && `read ${read}`, wrote && `wrote ${wrote}`].filter(Boolean).join(" | "); const cache = formatCacheMetric(event.cache_hit_tokens, event.cache_miss_tokens);
return [read && `read ${read}`, wrote && `wrote ${wrote}`, cache].filter(Boolean).join(" | ");
} }
function formatTokenMetric(tokens, speed) { function formatTokenMetric(tokens, speed) {
@@ -569,6 +576,13 @@ function formatTokenMetric(tokens, speed) {
return `${tokens} tok${speedText}`; return `${tokens} tok${speedText}`;
} }
function formatCacheMetric(hitTokens, missTokens) {
if (!hitTokens && !missTokens) return "";
const hit = Number(hitTokens || 0).toLocaleString();
const miss = Number(missTokens || 0).toLocaleString();
return `cache ${hit} hit / ${miss} miss`;
}
function setWarning(text) { function setWarning(text) {
warningEl.hidden = !text; warningEl.hidden = !text;
warningEl.textContent = text || ""; warningEl.textContent = text || "";
@@ -593,12 +607,13 @@ const configFieldIds = {
const ollamaFieldIds = { const ollamaFieldIds = {
model_provider: "model-provider", model_provider: "model-provider",
deepseek_base_url: "deepseek-base-url",
deepseek_api_key: "deepseek-api-key",
deepseek_model: "deepseek-model",
ollama_base_url: "ollama-base-url", ollama_base_url: "ollama-base-url",
ollama_model: "ollama-model", ollama_model: "ollama-model",
ollama_num_ctx: "ollama-num-ctx", ollama_num_ctx: "ollama-num-ctx",
openai_base_url: "openai-base-url", model_reasoning_effort: "model-reasoning-effort",
openai_api_key: "openai-api-key",
openai_model: "openai-model",
}; };
async function refreshConfig() { async function refreshConfig() {
@@ -636,6 +651,8 @@ function renderConfig(config) {
field.value = values[key] ?? ""; field.value = values[key] ?? "";
} }
} }
renderReasoningEffortOptions(["none", "minimal", "low", "medium", "high", "xhigh"], values.model_reasoning_effort || "medium");
updateProviderFieldVisibility(values.model_provider || "ollama");
configPathsEl.textContent = `App data: ${config.app_data_dir}\nConfig: ${config.config_path}\nLog: ${config.log_path}\nEdge profile: ${config.edge_profile_dir}`; configPathsEl.textContent = `App data: ${config.app_data_dir}\nConfig: ${config.config_path}\nLog: ${config.log_path}\nEdge profile: ${config.edge_profile_dir}`;
configStatusEl.textContent = ""; configStatusEl.textContent = "";
} }
@@ -658,7 +675,7 @@ async function saveConfig(event) {
const result = await response.json(); const result = await response.json();
renderConfig(result); renderConfig(result);
configStatusEl.textContent = result.message || "Saved"; configStatusEl.textContent = result.message || "Saved";
addMessage("assistant", "Config saved. Restart TraderAI for the new settings to fully apply."); addMessage("assistant", result.message || "Config saved.");
} catch (error) { } catch (error) {
configStatusEl.textContent = `Config save failed: ${fetchErrorMessage(error)}`; configStatusEl.textContent = `Config save failed: ${fetchErrorMessage(error)}`;
} }
@@ -703,9 +720,12 @@ async function refreshOllamaStatus() {
function renderOllamaStatus(status) { function renderOllamaStatus(status) {
if (!ollamaStatusEl) return; if (!ollamaStatusEl) return;
latestOllamaStatus = status; latestOllamaStatus = status;
const provider = status.provider === "openai" ? "OpenAI" : "Ollama"; updateProviderFieldVisibility(status.provider || "ollama");
const provider = providerDisplayName(status.provider);
const models = status.models?.length ? status.models.join(", ") : "None detected"; const models = status.models?.length ? status.models.join(", ") : "None detected";
const ready = status.provider === "openai" const isDeepSeekProvider = status.provider === "deepseek";
const isCloudProvider = isDeepSeekProvider;
const ready = isCloudProvider
? Boolean(status.online && status.model_available) ? Boolean(status.online && status.model_available)
: Boolean(status.installed && status.running && status.model_available); : Boolean(status.installed && status.running && status.model_available);
const pillClass = ready ? "status-pill" : "status-pill warning"; const pillClass = ready ? "status-pill" : "status-pill warning";
@@ -714,7 +734,7 @@ function renderOllamaStatus(status) {
ollamaStatusItem("Model", status.configured_model || ""), ollamaStatusItem("Model", status.configured_model || ""),
ollamaStatusItem("URL", status.base_url || ""), ollamaStatusItem("URL", status.base_url || ""),
]; ];
if (status.provider !== "openai") { if (!isCloudProvider) {
detailItems.splice(1, 0, ollamaStatusItem("Installed", status.installed ? "Yes" : "No")); detailItems.splice(1, 0, ollamaStatusItem("Installed", status.installed ? "Yes" : "No"));
detailItems.splice(2, 0, ollamaStatusItem("Running", status.running ? "Yes" : "No")); detailItems.splice(2, 0, ollamaStatusItem("Running", status.running ? "Yes" : "No"));
detailItems.push(ollamaStatusItem("Pulled", status.model_available ? "Yes" : "No")); detailItems.push(ollamaStatusItem("Pulled", status.model_available ? "Yes" : "No"));
@@ -728,27 +748,24 @@ function renderOllamaStatus(status) {
<div class="ollama-status-grid"> <div class="ollama-status-grid">
${detailItems.join("")} ${detailItems.join("")}
</div> </div>
${ollamaStatusItem(status.provider === "openai" ? "Available Models" : "Installed Models", models)} ${ollamaStatusItem(isCloudProvider ? "Available Models" : "Installed Models", models)}
${status.detail ? ollamaStatusItem("Detail", status.detail) : ""} ${status.detail ? ollamaStatusItem("Detail", status.detail) : ""}
`; `;
if (ollamaDownloadButton) ollamaDownloadButton.hidden = status.provider === "openai"; if (ollamaDownloadButton) ollamaDownloadButton.hidden = isCloudProvider;
if (ollamaInstallButton) { if (ollamaInstallButton) {
ollamaInstallButton.hidden = status.provider === "openai" || !status.can_auto_install; ollamaInstallButton.hidden = isCloudProvider || !status.can_auto_install;
ollamaInstallButton.disabled = Boolean(status.installed) || !status.can_auto_install; ollamaInstallButton.disabled = Boolean(status.installed) || !status.can_auto_install;
} }
if (ollamaLaunchButton) { if (ollamaLaunchButton) {
ollamaLaunchButton.hidden = status.provider === "openai"; ollamaLaunchButton.hidden = isCloudProvider;
ollamaLaunchButton.disabled = !status.installed || Boolean(status.running); ollamaLaunchButton.disabled = !status.installed || Boolean(status.running);
} }
if (ollamaPullButton) { if (ollamaPullButton) {
ollamaPullButton.hidden = status.provider === "openai"; ollamaPullButton.hidden = isCloudProvider;
ollamaPullButton.disabled = !status.running || Boolean(status.model_available); ollamaPullButton.disabled = !status.running || Boolean(status.model_available);
} }
if (openaiModelsRefreshButton) { renderProviderModelOptions(status.models || [], status);
openaiModelsRefreshButton.hidden = status.provider !== "openai"; renderReasoningEffortOptions(status.reasoning_efforts || [], status.configured_reasoning_effort || "medium");
openaiModelsRefreshButton.disabled = false;
}
renderProviderModelOptions(status.models || []);
updateOllamaAttention(status); updateOllamaAttention(status);
} }
@@ -791,15 +808,16 @@ function setOllamaButtonAttention(button, action, active) {
function updateOllamaAttention(status = null) { function updateOllamaAttention(status = null) {
const currentStatus = status || latestOllamaStatus; const currentStatus = status || latestOllamaStatus;
if (!currentStatus) return; if (!currentStatus) return;
const ready = currentStatus.provider === "openai" const isDeepSeekProvider = currentStatus.provider === "deepseek";
const isCloudProvider = isDeepSeekProvider;
const ready = isCloudProvider
? Boolean(currentStatus.online && currentStatus.model_available) ? Boolean(currentStatus.online && currentStatus.model_available)
: Boolean(currentStatus.installed && currentStatus.running && currentStatus.model_available); : Boolean(currentStatus.installed && currentStatus.running && currentStatus.model_available);
ollamaToggle?.classList.toggle("attention-pulse", !ready); ollamaToggle?.classList.toggle("attention-pulse", !ready);
setOllamaButtonAttention(ollamaDownloadButton, "download", currentStatus.provider !== "openai" && !currentStatus.installed); setOllamaButtonAttention(ollamaDownloadButton, "download", !isCloudProvider && !currentStatus.installed);
setOllamaButtonAttention(ollamaInstallButton, "install", currentStatus.provider !== "openai" && !currentStatus.installed && currentStatus.can_auto_install); setOllamaButtonAttention(ollamaInstallButton, "install", !isCloudProvider && !currentStatus.installed && currentStatus.can_auto_install);
setOllamaButtonAttention(ollamaLaunchButton, "launch", currentStatus.provider !== "openai" && currentStatus.installed && !currentStatus.running); setOllamaButtonAttention(ollamaLaunchButton, "launch", !isCloudProvider && currentStatus.installed && !currentStatus.running);
setOllamaButtonAttention(ollamaPullButton, "pull", currentStatus.provider !== "openai" && currentStatus.running && !currentStatus.model_available); setOllamaButtonAttention(ollamaPullButton, "pull", !isCloudProvider && currentStatus.running && !currentStatus.model_available);
setOllamaButtonAttention(openaiModelsRefreshButton, "openai-models", currentStatus.provider === "openai" && !currentStatus.model_available);
if (ready) clickedOllamaActions.clear(); if (ready) clickedOllamaActions.clear();
} }
@@ -807,29 +825,84 @@ function configuredOllamaModel() {
return document.getElementById("ollama-model")?.value || ""; return document.getElementById("ollama-model")?.value || "";
} }
function renderProviderModelOptions(models) { function updateProviderFieldVisibility(provider) {
for (const field of providerScopedFields) {
const scope = field.dataset.providerScope;
const hiddenManualModel = field.dataset.manualModel === "true" && provider !== "ollama";
field.hidden = scope !== provider || hiddenManualModel;
}
if (providerModelLabel) {
providerModelLabel.textContent = provider === "ollama" ? "Available Models" : "Model";
}
}
function renderProviderModelOptions(models, status = latestOllamaStatus) {
const datalist = document.getElementById("provider-models"); const datalist = document.getElementById("provider-models");
if (!datalist) return; if (datalist) datalist.innerHTML = "";
datalist.innerHTML = "";
for (const model of models) { for (const model of models) {
if (datalist) {
const option = document.createElement("option"); const option = document.createElement("option");
option.value = model; option.value = model;
datalist.appendChild(option); datalist.appendChild(option);
} }
} }
if (!providerModelSelect) return;
async function refreshOpenAIModels() { const provider = status?.provider || document.getElementById("model-provider")?.value || "ollama";
setOllamaMessage("Loading OpenAI models"); const configuredModel = configuredProviderModel(provider);
try { providerModelSelect.innerHTML = "";
const response = await fetch("/api/openai/models"); const allModels = [...new Set([configuredModel, ...models].filter(Boolean))];
const result = await response.json(); if (!allModels.length) {
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`); const option = document.createElement("option");
renderProviderModelOptions(result.models || []); option.value = "";
setOllamaMessage(result.message || "Loaded OpenAI models"); option.textContent = "No models detected";
await refreshOllamaStatus(); providerModelSelect.appendChild(option);
} catch (error) { providerModelSelect.disabled = true;
setOllamaMessage(`OpenAI models failed: ${fetchErrorMessage(error)}`); return;
} }
providerModelSelect.disabled = false;
for (const model of allModels) {
const option = document.createElement("option");
option.value = model;
option.textContent = model;
if (model === configuredModel) option.selected = true;
providerModelSelect.appendChild(option);
}
}
function renderReasoningEffortOptions(efforts, configured) {
if (!modelReasoningEffortSelect) return;
const options = [...new Set([...(efforts || []), configured || "medium"].filter(Boolean))];
modelReasoningEffortSelect.innerHTML = "";
for (const effort of options) {
const option = document.createElement("option");
option.value = effort;
option.textContent = effort;
if (effort === configured) option.selected = true;
modelReasoningEffortSelect.appendChild(option);
}
}
function configuredProviderModel(provider) {
if (provider === "deepseek") return document.getElementById("deepseek-model")?.value || "";
return document.getElementById("ollama-model")?.value || "";
}
function syncSelectedProviderModel() {
const provider = document.getElementById("model-provider")?.value || "ollama";
const selectedModel = providerModelSelect?.value || "";
if (!selectedModel) return;
if (provider === "deepseek") {
const field = document.getElementById("deepseek-model");
if (field) field.value = selectedModel;
return;
}
const field = document.getElementById("ollama-model");
if (field) field.value = selectedModel;
}
function providerDisplayName(provider) {
if (provider === "deepseek") return "DeepSeek";
return "Local Ollama";
} }
async function checkForUpdate(promptUser = false) { async function checkForUpdate(promptUser = false) {
@@ -1197,6 +1270,72 @@ function parsePlanItems(text) {
}); });
} }
function formatPlanItems(items) {
return (items || [])
.map((item) => {
const name = String(item.item_name || item.name || "").trim();
if (!name) return "";
const quantity = Number(item.desired_quantity || item.quantity || 1);
const maxUnitPrice = item.max_unit_price ?? item.max_price;
const parts = [name];
if (Number.isFinite(quantity) && quantity > 1) parts.push(String(quantity));
else if (maxUnitPrice !== null && maxUnitPrice !== undefined && maxUnitPrice !== "") parts.push("1");
if (maxUnitPrice !== null && maxUnitPrice !== undefined && maxUnitPrice !== "") parts.push(String(maxUnitPrice));
return parts.join(" | ");
})
.filter(Boolean)
.join("\n");
}
function applyPlanDraft(draft) {
if (!draft) return;
document.getElementById("plan-title").value = draft.title || "";
document.getElementById("plan-objective").value = draft.objective || "";
document.getElementById("plan-kind").value = draft.kind || "buying";
document.getElementById("plan-tone").value = draft.constraints?.message_tone || "";
document.getElementById("plan-instructions").value = draft.constraints?.instructions || "";
document.getElementById("plan-cadence").value = draft.cadence || "";
document.getElementById("plan-items").value = formatPlanItems(draft.items || []);
}
async function autofillPlanDraft() {
const title = document.getElementById("plan-title").value.trim();
const objective = document.getElementById("plan-objective").value.trim();
if (!title && !objective) {
plansStatusEl.textContent = "Add at least a title or objective first";
return;
}
const tone = document.getElementById("plan-tone").value.trim();
const instructions = document.getElementById("plan-instructions").value.trim();
const constraints = {};
if (tone) constraints.message_tone = tone;
if (instructions) constraints.instructions = instructions;
const payload = {
title,
objective,
kind: document.getElementById("plan-kind").value || "buying",
constraints,
items: parsePlanItems(document.getElementById("plan-items").value || ""),
};
plansStatusEl.textContent = "Drafting plan";
if (planAutofillButton) planAutofillButton.disabled = true;
try {
const response = await fetch("/api/plans/draft", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
applyPlanDraft(result.draft || {});
plansStatusEl.textContent = "Draft filled in. Review and edit anything you want.";
} catch (error) {
plansStatusEl.textContent = `Plan draft failed: ${fetchErrorMessage(error)}`;
} finally {
if (planAutofillButton) planAutofillButton.disabled = false;
}
}
async function createPlan(event) { async function createPlan(event) {
event.preventDefault(); event.preventDefault();
const title = document.getElementById("plan-title").value.trim(); const title = document.getElementById("plan-title").value.trim();
@@ -1282,34 +1421,81 @@ function renderPlansRail(plans) {
async function renderPlans(plans, openPlanId = null) { async function renderPlans(plans, openPlanId = null) {
plansDashboardEl.innerHTML = ""; plansDashboardEl.innerHTML = "";
if (!plans.length) { if (!plans.length) {
plansDashboardEl.innerHTML = '<div class="pending-empty">No continual plans</div>'; plansDashboardEl.innerHTML = `
<section class="plans-overview">
<div>
<p class="eyebrow">Plan board</p>
<h3>No plans yet</h3>
<p class="plan-overview-copy">Create a buying watchlist or a custom follow-up routine to start tracking work over time.</p>
</div>
</section>
<div class="plan-empty-state">
<h4>Nothing is running</h4>
<p>Your continual plans will appear here with status, timing, and recent activity.</p>
</div>
`;
return; return;
} }
const activeCount = plans.filter((plan) => plan.status === "active").length;
const attentionCount = plans.filter((plan) => plan.status === "needs_input" || plan.status === "paused").length;
const overview = document.createElement("section");
overview.className = "plans-overview";
overview.innerHTML = `
<div>
<p class="eyebrow">Plan board</p>
<h3>${plans.length} continual ${plans.length === 1 ? "plan" : "plans"}</h3>
<p class="plan-overview-copy">Monitor recurring work, keep candidate leads in view, and jump into details when something needs attention.</p>
</div>
<div class="plan-overview-stats">
<div class="plan-overview-stat">
<span class="plan-overview-stat-value">${activeCount}</span>
<span class="plan-overview-stat-label">active</span>
</div>
<div class="plan-overview-stat">
<span class="plan-overview-stat-value">${attentionCount}</span>
<span class="plan-overview-stat-label">needs eyes</span>
</div>
</div>
`;
plansDashboardEl.appendChild(overview);
for (const plan of plans) { for (const plan of plans) {
const card = document.createElement("article"); const card = document.createElement("article");
card.className = `plan-card${plan.status === "active" ? " active" : ""}`; card.className = `plan-card plan-status-${slugifyPlanValue(plan.status)}${plan.status === "active" ? " active" : ""}`;
const heading = document.createElement("div");
heading.className = "plan-card-heading";
const title = document.createElement("h3"); const title = document.createElement("h3");
title.textContent = plan.title || "Untitled plan"; title.textContent = plan.title || "Untitled plan";
const statusBadge = document.createElement("span");
statusBadge.className = `plan-status-badge plan-status-${slugifyPlanValue(plan.status)}`;
statusBadge.textContent = humanizePlanValue(plan.status || "unknown");
const meta = document.createElement("div"); const meta = document.createElement("div");
meta.className = "plan-meta"; meta.className = "plan-meta";
meta.textContent = plan.objective || ""; meta.textContent = plan.objective || "";
const pills = document.createElement("div"); const pills = document.createElement("div");
pills.className = "plan-pill-row"; pills.className = "plan-pill-row";
for (const value of [plan.status, plan.kind, plan.next_run_at ? `next ${formatShortDate(plan.next_run_at)}` : "not scheduled"]) { for (const value of [plan.kind, plan.next_run_at ? `next ${formatShortDate(plan.next_run_at)}` : "not scheduled"]) {
const pill = document.createElement("span"); const pill = document.createElement("span");
pill.className = "plan-pill"; pill.className = "plan-pill";
pill.textContent = value; pill.textContent = humanizePlanValue(value);
pills.appendChild(pill); pills.appendChild(pill);
} }
const metrics = document.createElement("div");
metrics.className = "plan-metrics";
metrics.append(
planMetric("Checklist", String((plan.items || []).length)),
planMetric("Cadence", summarizeCadence(plan.cadence)),
planMetric("Updated", formatShortDate(plan.updated_at || plan.created_at))
);
const controls = document.createElement("div"); const controls = document.createElement("div");
controls.className = "plan-controls"; controls.className = "plan-controls";
controls.append( controls.append(
planButton("Details", () => loadPlanDetail(plan.id, card)), planButton("Details", () => loadPlanDetail(plan.id, card)),
planButton("Run", () => postPlanAction(plan.id, "run")), planButton("Run", () => postPlanAction(plan.id, "run")),
planButton(plan.status === "active" ? "Pause" : "Resume", () => postPlanAction(plan.id, plan.status === "active" ? "pause" : "resume")), planButton(plan.status === "active" ? "Pause" : "Resume", () => postPlanAction(plan.id, plan.status === "active" ? "pause" : "resume")),
planButton("Cancel", () => postPlanAction(plan.id, "cancel"), "secondary small-button") planButton("Delete", () => deletePlan(plan.id), "secondary small-button")
); );
card.append(title, meta, pills, controls); heading.append(title, statusBadge);
card.append(heading, meta, pills, metrics, controls);
plansDashboardEl.appendChild(card); plansDashboardEl.appendChild(card);
if (openPlanId && plan.id === openPlanId) await loadPlanDetail(plan.id, card); if (openPlanId && plan.id === openPlanId) await loadPlanDetail(plan.id, card);
} }
@@ -1330,41 +1516,113 @@ async function loadPlanDetail(planId, card) {
existing.remove(); existing.remove();
return; return;
} }
const loading = document.createElement("div");
loading.className = "plan-detail plan-detail-loading";
loading.textContent = "Loading plan details...";
card.appendChild(loading);
const response = await fetch(`/api/plans/${encodeURIComponent(planId)}`); const response = await fetch(`/api/plans/${encodeURIComponent(planId)}`);
const result = await response.json(); const result = await response.json();
const plan = result.plan; const plan = result.plan;
loading.remove();
const detail = document.createElement("div"); const detail = document.createElement("div");
detail.className = "plan-detail"; detail.className = "plan-detail";
detail.append( detail.append(
planSection("Checklist", (plan.items || []).map((item) => `${item.item_name}: ${item.acquired_quantity || 0}/${item.desired_quantity || 1}${item.max_unit_price ? `, max ${Number(item.max_unit_price).toLocaleString()} UEC` : ""} (${item.status})`)), planSection("Checklist", checklistLines(plan), "checklist"),
planSection("Best Candidates", bestCandidateLines(plan)), planSection("Best Candidates", bestCandidateLines(plan), "candidates"),
planSection("Recent Events", (plan.events || []).slice(0, 5).map((event) => `${formatShortDate(event.created_at)} ${event.kind}: ${event.message}`)) planSection("Recent Events", recentEventLines(plan), "events")
); );
card.appendChild(detail); card.appendChild(detail);
} }
function planSection(title, lines) { function planSection(title, lines, sectionClass = "") {
const wrapper = document.createElement("section"); const wrapper = document.createElement("section");
wrapper.className = `plan-section${sectionClass ? ` plan-section-${sectionClass}` : ""}`;
const heading = document.createElement("h4"); const heading = document.createElement("h4");
heading.textContent = title; heading.textContent = title;
const list = document.createElement("ul"); const list = document.createElement("ul");
list.className = "plan-list"; list.className = "plan-list";
const items = lines.length ? lines : ["Empty"]; const items = lines.length ? lines : [planListItemData("Empty", "Nothing to show right now.")];
for (const line of items) { for (const line of items) {
const item = document.createElement("li"); const item = document.createElement("li");
if (typeof line === "string") {
item.textContent = line; item.textContent = line;
} else {
item.className = line.className || "";
const titleEl = document.createElement("div");
titleEl.className = "plan-list-title";
titleEl.textContent = line.title;
const bodyEl = document.createElement("div");
bodyEl.className = "plan-list-body";
bodyEl.textContent = line.body;
item.append(titleEl, bodyEl);
}
list.appendChild(item); list.appendChild(item);
} }
wrapper.append(heading, list); wrapper.append(heading, list);
return wrapper; return wrapper;
} }
function planMetric(label, value) {
const metric = document.createElement("div");
metric.className = "plan-metric";
const metricLabel = document.createElement("span");
metricLabel.className = "plan-metric-label";
metricLabel.textContent = label;
const metricValue = document.createElement("span");
metricValue.className = "plan-metric-value";
metricValue.textContent = value;
metric.append(metricLabel, metricValue);
return metric;
}
function summarizeCadence(cadence) {
if (!cadence) return "manual";
return cadence.replace(/\s+/g, " ").trim();
}
function slugifyPlanValue(value) {
return String(value || "unknown")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
}
function humanizePlanValue(value) {
return String(value || "")
.replace(/_/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
}
function planListItemData(title, body, className = "") {
return { title, body, className };
}
function checklistLines(plan) {
return (plan.items || []).map((item) => {
const quantity = `${item.acquired_quantity || 0}/${item.desired_quantity || 1}`;
const price = item.max_unit_price ? `Max ${Number(item.max_unit_price).toLocaleString()} UEC` : "No price cap";
return planListItemData(item.item_name, `${quantity} acquired • ${price}${humanizePlanValue(item.status || "pending")}`);
});
}
function bestCandidateLines(plan) { function bestCandidateLines(plan) {
const byItem = new Map((plan.items || []).map((item) => [item.id, item.item_name])); const byItem = new Map((plan.items || []).map((item) => [item.id, item.item_name]));
return (plan.candidates || []) return (plan.candidates || [])
.filter((candidate) => candidate.status === "current" || candidate.status === "drafted") .filter((candidate) => candidate.status === "current" || candidate.status === "drafted")
.slice(0, 6) .slice(0, 6)
.map((candidate) => `${byItem.get(candidate.plan_item_id) || "Item"}: ${candidate.title || candidate.listing_slug || candidate.listing_id} at ${Number(candidate.price || 0).toLocaleString()} ${candidate.currency || "UEC"} from ${candidate.seller || "unknown"} (${candidate.status})`); .map((candidate) => {
const title = byItem.get(candidate.plan_item_id) || "Item";
const listing = candidate.title || candidate.listing_slug || candidate.listing_id || "Unnamed listing";
const price = `${Number(candidate.price || 0).toLocaleString()} ${candidate.currency || "UEC"}`;
const seller = candidate.seller || "unknown seller";
return planListItemData(title, `${listing}${price}${seller}${humanizePlanValue(candidate.status || "current")}`);
});
}
function recentEventLines(plan) {
return (plan.events || [])
.slice(0, 5)
.map((event) => planListItemData(`${formatShortDate(event.created_at)}${humanizePlanValue(event.kind || "event")}`, event.message || "No details."));
} }
async function postPlanAction(planId, action) { async function postPlanAction(planId, action) {
@@ -1382,6 +1640,22 @@ async function postPlanAction(planId, action) {
} }
} }
async function deletePlan(planId) {
if (!window.confirm("Delete this plan and its stored history?")) return;
plansStatusEl.textContent = "delete requested";
try {
const response = await fetch(`/api/plans/${encodeURIComponent(planId)}`, { method: "DELETE" });
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
plansStatusEl.textContent = result.summary || "Plan deleted";
await refreshPlans();
await refreshPending();
await refreshInbox();
} catch (error) {
plansStatusEl.textContent = `Plan delete failed: ${fetchErrorMessage(error)}`;
}
}
function formatShortDate(value) { function formatShortDate(value) {
if (!value) return ""; if (!value) return "";
const date = new Date(value); const date = new Date(value);
@@ -1390,11 +1664,21 @@ function formatShortDate(value) {
} }
async function checkHealth() { async function checkHealth() {
try {
let health = {};
try { try {
const response = await fetch("/api/health"); const response = await fetch("/api/health");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json(); const result = await response.json();
const health = result.ollama || {}; health = result.inference || result.ollama || {};
const provider = health.provider === "openai" ? "OpenAI" : "Ollama"; } catch (primaryError) {
const fallbackResponse = await fetch("/api/ollama/status");
if (!fallbackResponse.ok) throw primaryError;
health = await fallbackResponse.json();
}
const provider = providerDisplayName(health.provider);
const isDeepSeekProvider = health.provider === "deepseek";
const isCloudProvider = isDeepSeekProvider;
ollamaOnline = Boolean(health.online); ollamaOnline = Boolean(health.online);
if (!ollamaOnline) { if (!ollamaOnline) {
statusEl.textContent = "Offline"; statusEl.textContent = "Offline";
@@ -1403,7 +1687,7 @@ async function checkHealth() {
return false; return false;
} }
if (health.model_available === false) { if (health.model_available === false) {
const action = health.provider === "openai" ? "Load OpenAI Models." : "Install Model."; const action = isCloudProvider ? "Save a working DeepSeek model." : "Install Model.";
setWarning(`${provider} needs the configured model "${health.model}". Open the model provider tab and use ${action}`); setWarning(`${provider} needs the configured model "${health.model}". Open the model provider tab and use ${action}`);
ollamaToggle?.classList.add("attention-pulse"); ollamaToggle?.classList.add("attention-pulse");
} else { } else {
@@ -1619,6 +1903,7 @@ ollamaToggle?.addEventListener("click", () => toggleSidebarPanel("ollama"));
plansRefreshButton?.addEventListener("click", () => refreshPlans()); plansRefreshButton?.addEventListener("click", () => refreshPlans());
plansCloseButton?.addEventListener("click", closePlansPanel); plansCloseButton?.addEventListener("click", closePlansPanel);
planForm?.addEventListener("submit", createPlan); planForm?.addEventListener("submit", createPlan);
planAutofillButton?.addEventListener("click", autofillPlanDraft);
ollamaForm?.addEventListener("submit", saveOllamaConfig); ollamaForm?.addEventListener("submit", saveOllamaConfig);
ollamaRefreshButton?.addEventListener("click", refreshOllamaStatus); ollamaRefreshButton?.addEventListener("click", refreshOllamaStatus);
ollamaDownloadButton?.addEventListener("click", () => { ollamaDownloadButton?.addEventListener("click", () => {
@@ -1637,9 +1922,11 @@ ollamaPullButton?.addEventListener("click", () => {
markOllamaActionClicked("pull"); markOllamaActionClicked("pull");
postOllamaAction("/api/ollama/pull", { body: { model: configuredOllamaModel() } }); postOllamaAction("/api/ollama/pull", { body: { model: configuredOllamaModel() } });
}); });
openaiModelsRefreshButton?.addEventListener("click", () => { providerModelSelect?.addEventListener("change", syncSelectedProviderModel);
markOllamaActionClicked("openai-models"); document.getElementById("model-provider")?.addEventListener("change", () => {
refreshOpenAIModels(); const provider = document.getElementById("model-provider")?.value || "ollama";
updateProviderFieldVisibility(provider);
renderProviderModelOptions(latestOllamaStatus?.models || [], { ...latestOllamaStatus, provider });
}); });
updateCheckButton?.addEventListener("click", checkForUpdate); updateCheckButton?.addEventListener("click", checkForUpdate);
updateInstallButton?.addEventListener("click", installUpdate); updateInstallButton?.addEventListener("click", installUpdate);
@@ -1701,6 +1988,8 @@ async function sendMessage() {
const event = JSON.parse(line.slice(6)); const event = JSON.parse(line.slice(6));
if (event.type === "status") { if (event.type === "status") {
setMessageActivity(assistantNode, event.message, true); setMessageActivity(assistantNode, event.message, true);
} else if (event.type === "reasoning") {
appendThinkingText(assistantNode, event.content || "");
} else if (event.type === "metrics") { } else if (event.type === "metrics") {
setMessageMetrics(assistantNode, formatMetrics(event)); setMessageMetrics(assistantNode, formatMetrics(event));
} else if (event.type === "warning") { } else if (event.type === "warning") {
+39 -16
View File
@@ -119,22 +119,24 @@
</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>Model Provider</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>Provider <label>Provider
<select id="model-provider" name="model_provider"> <select id="model-provider" name="model_provider">
<option value="ollama">Ollama</option> <option value="deepseek">DeepSeek V4 (Recommended)</option>
<option value="openai">OpenAI</option> <option value="ollama">Local Ollama</option>
</select> </select>
</label> </label>
<label>Ollama URL<input id="ollama-base-url" name="ollama_base_url" type="text"></label> <label data-provider-scope="deepseek">DeepSeek URL<input id="deepseek-base-url" name="deepseek_base_url" type="text"></label>
<label>Ollama Model<input id="ollama-model" name="ollama_model" type="text" list="provider-models"></label> <label data-provider-scope="deepseek">DeepSeek API Key<input id="deepseek-api-key" name="deepseek_api_key" type="password" autocomplete="off"></label>
<label>Context Tokens<input id="ollama-num-ctx" name="ollama_num_ctx" type="number" min="1024" step="1024"></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>OpenAI URL<input id="openai-base-url" name="openai_base_url" type="text"></label> <label data-provider-scope="ollama">Ollama URL<input id="ollama-base-url" name="ollama_base_url" type="text"></label>
<label>OpenAI API Key<input id="openai-api-key" name="openai_api_key" type="password" autocomplete="off"></label> <label data-provider-scope="ollama">Ollama Model<input id="ollama-model" name="ollama_model" type="text" list="provider-models"></label>
<label>OpenAI Model<input id="openai-model" name="openai_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> <datalist id="provider-models"></datalist>
<button type="submit">Save Provider Config</button> <button type="submit">Save Provider Config</button>
</form> </form>
@@ -144,7 +146,6 @@
<button class="secondary small-button" id="ollama-install" type="button">Auto Install</button> <button class="secondary small-button" id="ollama-install" type="button">Auto Install</button>
<button class="secondary small-button" id="ollama-launch" type="button">Launch</button> <button class="secondary small-button" id="ollama-launch" type="button">Launch</button>
<button class="small-button" id="ollama-pull" type="button">Install Model</button> <button class="small-button" id="ollama-pull" type="button">Install Model</button>
<button class="secondary small-button" id="openai-models-refresh" type="button">Load OpenAI Models</button>
</div> </div>
<div class="config-status" id="ollama-message"></div> <div class="config-status" id="ollama-message"></div>
</div> </div>
@@ -157,10 +158,10 @@
<i data-lucide="brain" aria-hidden="true"></i> <i data-lucide="brain" aria-hidden="true"></i>
<span>Memory</span> <span>Memory</span>
</button> </button>
<button class="sidebar-tool-button" id="ollama-toggle" type="button" aria-expanded="false" aria-controls="ollama-panel" title="Ollama"> <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();"> <img class="sidebar-tool-image" src="/static/art/ollama-icon.svg" alt="" onerror="this.remove();">
<i data-lucide="bot" aria-hidden="true"></i> <i data-lucide="bot" aria-hidden="true"></i>
<span>Ollama</span> <span>Inference</span>
</button> </button>
</div> </div>
</section> </section>
@@ -199,23 +200,45 @@
</div> </div>
</div> </div>
<div class="plans-panel-body"> <div class="plans-panel-body">
<form class="config-form" id="plan-form"> <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>Title<input id="plan-title" type="text" placeholder="Wikelo Idris parts"></label>
<label>Objective<input id="plan-objective" type="text" placeholder="Find and draft deals for the parts I list"></label> <label>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 <label>Kind
<select id="plan-kind"> <select id="plan-kind">
<option value="buying">Buying</option> <option value="buying">Buying</option>
<option value="custom">Custom</option> <option value="custom">Custom</option>
</select> </select>
</label> </label>
<label>Items<textarea id="plan-items" rows="4" placeholder="One item per line, optionally: name | quantity | max unit price"></textarea></label>
<label>Instructions<textarea id="plan-instructions" rows="3" placeholder="Extra guidance for custom or buying plans"></textarea></label>
<label>Cron Cadence<input id="plan-cadence" type="text" placeholder="0 */6 * * *"></label>
<label>Message Tone<input id="plan-tone" type="text" placeholder="polite and concise"></label> <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> <button type="submit">Create Plan</button>
</div>
<div class="config-status" id="plans-status"></div> <div class="config-status" id="plans-status"></div>
</form> </form>
</div>
</aside>
<section class="plans-dashboard-shell">
<div class="plans-dashboard" id="plans-dashboard"></div> <div class="plans-dashboard" id="plans-dashboard"></div>
</section>
</div> </div>
</div> </div>
<div class="modal-backdrop" id="update-modal" hidden> <div class="modal-backdrop" id="update-modal" hidden>
+353 -30
View File
@@ -1039,15 +1039,15 @@ button {
.plans-floating-panel { .plans-floating-panel {
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
width: min(680px, calc(100vw - 28px)); width: min(980px, calc(100vw - 28px));
} }
.plans-panel-body { .plans-panel-body {
display: grid; display: grid;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
gap: 16px; gap: 20px;
min-height: 0; min-height: 0;
padding: 16px; padding: 20px;
overflow: auto; overflow: auto;
} }
@@ -1106,10 +1106,8 @@ button.secondary {
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
margin-top: auto; margin-top: auto;
position: sticky; padding-top: 24px;
bottom: -28px; background: transparent;
padding-bottom: 28px;
background: linear-gradient(180deg, rgba(247, 241, 220, 0) 0%, var(--cream) 22%, var(--cream) 100%);
} }
.sidebar-tool-buttons { .sidebar-tool-buttons {
@@ -1119,6 +1117,12 @@ button.secondary {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
gap: 8px; 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 {
@@ -1208,6 +1212,11 @@ button.secondary {
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
.sidebar-panel .section-title-row {
position: relative;
z-index: 1;
}
.config-form { .config-form {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -1248,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;
@@ -1482,36 +1571,175 @@ pre {
.plans-dashboard { .plans-dashboard {
display: grid; display: grid;
gap: 12px; gap: 14px;
margin-top: 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 { .plan-card {
display: grid; display: grid;
gap: 10px; gap: 12px;
padding: 13px; padding: 16px;
border: 1px solid var(--line); border: 1px solid rgba(221, 206, 176, 0.92);
border-radius: 16px; border-radius: 20px;
background: rgba(255, 250, 240, 0.82); 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 { .plan-card.active {
border-color: rgba(52, 83, 38, 0.42); border-color: rgba(52, 83, 38, 0.32);
background: #edf3df; 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 { .plan-card h3 {
margin: 0; margin: 0;
color: var(--forest); color: var(--forest);
font-size: 16px; font-size: 19px;
line-height: 1.25; 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-meta,
.plan-line { .plan-line {
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 13px;
line-height: 1.4; line-height: 1.55;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
@@ -1525,33 +1753,82 @@ pre {
.plan-pill { .plan-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
min-height: 24px; min-height: 26px;
padding: 4px 8px; padding: 4px 10px;
border: 1px solid rgba(52, 83, 38, 0.24); border: 1px solid rgba(52, 83, 38, 0.14);
border-radius: 999px; border-radius: 999px;
background: rgba(255, 250, 240, 0.8); background: rgba(255, 250, 240, 0.88);
color: var(--forest); color: var(--forest);
font-size: 11px; font-size: 11px;
font-weight: 800; font-weight: 800;
text-transform: uppercase; 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 { .plan-controls button {
flex: 1 1 80px; flex: 1 1 80px;
min-width: 0; min-width: 0;
} }
.plan-detail { .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; display: grid;
gap: 8px; gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--line);
} }
.plan-detail h4 { .plan-detail h4 {
margin: 0; margin: 0;
color: var(--forest); color: var(--forest);
font-size: 13px; font-size: 13px;
letter-spacing: 0.04em;
text-transform: uppercase;
} }
.plan-list { .plan-list {
@@ -1563,15 +1840,29 @@ pre {
} }
.plan-list li { .plan-list li {
padding: 8px; display: grid;
gap: 5px;
padding: 10px 11px;
border: 1px solid rgba(221, 206, 176, 0.72); border: 1px solid rgba(221, 206, 176, 0.72);
border-radius: 8px; border-radius: 12px;
background: rgba(255, 253, 247, 0.72); background: rgba(255, 253, 247, 0.8);
color: var(--brown); color: var(--brown);
font-size: 12px; font-size: 12px;
line-height: 1.4; 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;
@@ -1751,6 +2042,38 @@ pre {
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 { .plans-panel-body {
grid-template-columns: 1fr; 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>