feat: infrance
Build Release EXE / build-windows-exe (release) Successful in 58s

This commit is contained in:
2026-06-08 20:28:06 -04:00
parent 6bd1e81a51
commit 00cf6f8747
20 changed files with 2789 additions and 180 deletions
+6 -1
View File
@@ -3,11 +3,16 @@ OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=qwen3.5:9b
OLLAMA_NUM_CTX=64512
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_MODEL=gpt-5.3-codex
OPENAI_MODEL=gpt-5.4-mini
OPENAI_API_KEY=
MODEL_REASONING_EFFORT=medium
CODEX_COMMAND=codex
CODEX_MODEL=gpt-5.4
UEX_BASE_URL=https://api.uexcorp.space/2.0
SCMDB_BASE_URL=https://scmdb.net
CORNERSTONE_BASE_URL=https://finder.cstone.space
SCWIKI_BASE_URL=https://starcitizen.tools
SCWIKI_API_BASE_URL=https://api.star-citizen.wiki
UEX_SECRET_KEY=
UEX_BEARER_TOKEN=
TRADERAI_USER_NAME=
+5 -3
View File
@@ -1,6 +1,6 @@
# TraderAI
Local Ollama- or OpenAI-powered chat for UEX marketplace workflows.
Local Ollama-, OpenAI-, or Codex-powered chat for UEX marketplace workflows.
## What It Does
@@ -25,7 +25,9 @@ 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.
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 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 OpenAI and Codex and defaults to `medium`.
`SCMDB_BASE_URL` defaults to `https://scmdb.net`.
`CORNERSTONE_BASE_URL` defaults to `https://finder.cstone.space`.
4. Install and run:
@@ -39,7 +41,7 @@ Local Ollama- or OpenAI-powered chat for UEX marketplace workflows.
## 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, 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.
## Releases And Updates
+2 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "traderai"
version = "0.0.6"
description = "Local Ollama-powered assistant for UEX marketplace workflows."
description = "Local Ollama, OpenAI, or Codex assistant for UEX marketplace workflows."
requires-python = ">=3.11"
dependencies = [
"apscheduler>=3.10.4",
@@ -40,3 +40,4 @@ include = ["traderai*"]
+84
View File
@@ -217,6 +217,90 @@ def test_ollama_options_include_num_ctx():
assert agent._ollama_options() == {"num_ctx": 64000}
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_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
async def test_wake_response_executes_tool_calls(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
+25
View File
@@ -0,0 +1,25 @@
from traderai.config import Settings
def test_model_provider_accepts_codex():
settings = Settings(model_provider="codex")
assert settings.model_provider == "codex"
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"
+36
View File
@@ -104,6 +104,26 @@ def test_continual_plan_store_creates_buying_checklist(tmp_path):
assert plan["items"][0]["desired_quantity"] == 2
def test_continual_plan_store_deletes_plan_and_related_records(tmp_path):
_, store, _, _, _ = plan_stack(tmp_path)
plan = store.create_plan(
"Delete me",
objective="Remove everything",
items=[{"item_name": "Wikelo Idris panel", "desired_quantity": 1}],
)
item_id = int(plan["items"][0]["id"])
candidate = store.upsert_candidate(plan["id"], item_id, {"id": "listing-1", "title": "Panel", "price": 10}, 0.9)
store.add_negotiation(plan["id"], item_id, int(candidate["id"]), {"listing_id": "listing-1", "listing_slug": "panel", "id_negotiation": "neg-1", "hash": "hash-1"})
assert store.delete_plan(plan["id"]) is True
assert store.get_plan(plan["id"]) is None
assert store.list_items(plan["id"]) == []
assert store.list_candidates(plan["id"]) == []
assert store.list_negotiations(plan["id"]) == []
assert store.list_events(plan["id"]) == []
@pytest.mark.asyncio
async def test_buying_runner_tracks_candidates_and_drafts_only(tmp_path):
memory, store, tools, runner, _ = plan_stack(tmp_path)
@@ -215,3 +235,19 @@ async def test_scheduler_schedules_overdue_plan_catchup_on_start(tmp_path):
assert catchup is not None
assert any(event["kind"] == "catchup_scheduled" for event in snapshot["events"])
@pytest.mark.asyncio
async def test_tools_delete_continual_plan_removes_it(tmp_path):
_, store, tools, _, _ = plan_stack(tmp_path)
plan = store.create_plan(
"Delete through tools",
objective="Remove via registry",
items=[{"item_name": "Wikelo Idris panel"}],
)
result = await tools.delete_continual_plan(plan["id"])
assert result["deleted"] is True
assert result["plan_id"] == plan["id"]
assert store.get_plan(plan["id"]) is None
+152
View File
@@ -0,0 +1,152 @@
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),
)
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": "codex", "codex_model": "gpt-5.4"}},
).json()
assert updated["restart_required"] is False
after = client.get("/api/health").json()
assert after["model_provider"] == "codex"
assert after["inference"]["provider"] == "codex"
chat = client.post("/api/chat", json={"message": "hi", "thread_id": "thread-1", "images": []}).json()
assert chat["message"] == "codex:gpt-5.4"
def make_settings(tmp_path, model_provider="ollama", ollama_model="qwen3.5:9b", codex_model="gpt-5.4"):
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",
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",
)
+217
View File
@@ -10,8 +10,10 @@ from traderai.uex_client import UEXClient
class FakeUEX:
def __init__(self):
self.posts = []
self.get_calls = []
async def get(self, path, params=None, authenticated=False):
self.get_calls.append({"path": path, "params": params, "authenticated": authenticated})
if path == "commodities_prices_history":
return {
"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"
return {
"data": [
@@ -259,6 +289,85 @@ 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",
}
@pytest.mark.asyncio
async def test_search_marketplace_listings_filters_locally():
registry = ToolRegistry(FakeUEX())
@@ -333,6 +442,65 @@ async def test_uex_get_projects_and_limits_results():
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
async def test_uex_api_catalog_exposes_resources_without_live_call():
registry = ToolRegistry(FakeUEX())
@@ -368,6 +536,7 @@ def test_schemas_expose_specific_uex_tools_instead_of_generic_api_tool():
assert "get_uex_commodities_prices" in names
assert "get_uex_vehicles" in names
assert "get_marketplace_trends" in names
assert "draft_uex_marketplace_advertise" in names
assert "delete_uex_marketplace_listings" in names
assert "uex_get" not in names
@@ -395,6 +564,17 @@ def test_schemas_expose_cornerstone_item_tools():
assert "draft_marketplace_listing_with_cornerstone_image" in names
def test_schemas_expose_scwiki_tools():
registry = ToolRegistry(FakeUEX(), scwiki=FakeSCWiki())
names = {schema["function"]["name"] for schema in registry.schemas}
assert "search_scwiki_pages" in names
assert "get_scwiki_page" in names
assert "search_scwiki_vehicles" in names
assert "get_scwiki_vehicle" in names
@pytest.mark.asyncio
async def test_search_scmdb_missions_returns_reward_summary():
registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB())
@@ -469,6 +649,43 @@ 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_draft_marketplace_listing_with_cornerstone_image_adds_image_data_and_redacts_display():
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
+581 -7
View File
@@ -1,9 +1,15 @@
from __future__ import annotations
import asyncio
import json
import re
import shutil
import subprocess
import tempfile
import uuid
from collections.abc import AsyncIterator
from contextlib import nullcontext
from pathlib import Path
from typing import Any
import httpx
@@ -11,6 +17,7 @@ from tzlocal import get_localzone
from traderai.memory import DEFAULT_THREAD_ID, MemoryStore, iso_now, iso_now_in_zone, time_since
from traderai.tools import ToolRegistry
from traderai.version import __version__
SYSTEM_PROMPT = """You are TraderAI, a local assistant for UEX marketplace work.
@@ -19,7 +26,9 @@ Use continual plan tools when the user asks for multi-day or recurring marketpla
UEX credentials are configured server-side when available. Never ask the user to provide UEX_SECRET_KEY or UEX_BEARER_TOKEN in chat; call the authenticated UEX tool and only mention credential configuration if the tool returns an authentication error.
Use the specific UEX tool for the needed endpoint, such as get_uex_commodities_prices or get_uex_vehicles. Use fields, limit, and summary mode so tool results stay compact.
When the user asks for history, trends, changes over time, or past prices, prefer the summarize_uex_*_history tools when available; use search_uex_api_index(history_only=true) if you need to discover history endpoints.
When you need missing Star Citizen knowledge to answer accurately, use Star Citizen Wiki tools during your reasoning instead of guessing.
Use SCMDB tools when the user asks about Star Citizen missions/contracts, mission rewards, payouts, reputation gains, item rewards, blueprint rewards, or hauling mission cargo. Prefer SCMDB live data unless the user asks for PTU or a specific game version.
Use Star Citizen Wiki tools for general game knowledge, ships and vehicles, store availability, purchase locations, ship prices, manufacturers, locations, and page summaries from starcitizen.tools.
Use Cornerstone tools when the user asks where an item is sold, which shops carry an item, item store locations, in-game item base prices, or Universal Item Finder data.
When drafting UEX marketplace item posts that need images, use Cornerstone media tools or draft_marketplace_listing_with_cornerstone_image so the pending listing can include UEX image_data sourced from Cornerstone.
Prefer open and current UEX marketplace information. Do not use historical sale data, completed sale records, or sale/average-history information unless the user explicitly asks for historical sales.
@@ -41,6 +50,7 @@ class OllamaAgent:
num_ctx: int | None = None,
provider: str = "ollama",
api_key: str | None = None,
reasoning_effort: str = "medium",
) -> None:
self.base_url = base_url.rstrip("/")
self.model = model
@@ -50,11 +60,14 @@ class OllamaAgent:
self.num_ctx = num_ctx
self.provider = provider.strip().casefold() or "ollama"
self.api_key = api_key
self.reasoning_effort = reasoning_effort.strip().casefold() or "medium"
self.thread_messages: dict[str, list[dict[str, Any]]] = {}
async def health(self) -> dict[str, Any]:
if self.provider == "openai":
return await self._openai_health()
if self.provider == "codex":
return await self._codex_health()
try:
async with httpx.AsyncClient(timeout=3) as client:
response = await client.get(f"{self.base_url}/api/tags")
@@ -83,6 +96,8 @@ class OllamaAgent:
health = await self.health()
if not health["online"]:
raise OllamaUnavailable(health["message"])
if health.get("model_available") is False:
raise OllamaUnavailable(health["message"])
async def chat(
self,
@@ -304,6 +319,13 @@ class OllamaAgent:
previous_interaction=previous_interaction,
thread_id=thread_id,
)
if self.provider == "codex":
return await self._codex_chat(
query,
messages,
previous_interaction=previous_interaction,
thread_id=thread_id,
)
return await self._ollama_chat(
query,
messages,
@@ -327,6 +349,15 @@ class OllamaAgent:
):
yield event
return
if self.provider == "codex":
async for event in self._codex_chat_stream(
query,
messages,
previous_interaction=previous_interaction,
thread_id=thread_id,
):
yield event
return
async for event in self._ollama_chat_stream(
query,
messages,
@@ -410,6 +441,7 @@ class OllamaAgent:
thread_id=thread_id,
),
"tools": self.tools.schemas,
"reasoning_effort": self.reasoning_effort,
"stream": False,
},
)
@@ -447,6 +479,7 @@ class OllamaAgent:
thread_id=thread_id,
),
"tools": self.tools.schemas,
"reasoning_effort": self.reasoning_effort,
"stream": True,
},
) as response:
@@ -487,6 +520,47 @@ class OllamaAgent:
"done": True,
}
async def _codex_chat(
self,
query: str = "",
messages: list[dict[str, Any]] | None = None,
previous_interaction: dict[str, Any] | None = None,
thread_id: str | None = DEFAULT_THREAD_ID,
) -> dict[str, Any]:
result = await self._codex_cli_turn(
query,
messages or self._messages_for_thread(thread_id),
previous_interaction=previous_interaction,
thread_id=thread_id,
)
return self._codex_structured_response(result)
async def _codex_chat_stream(
self,
query: str = "",
messages: list[dict[str, Any]] | None = None,
previous_interaction: dict[str, Any] | None = None,
thread_id: str | None = DEFAULT_THREAD_ID,
) -> AsyncIterator[dict[str, Any]]:
result = await self._codex_cli_turn(
query,
messages or self._messages_for_thread(thread_id),
previous_interaction=previous_interaction,
thread_id=thread_id,
)
response = self._codex_structured_response(result)
message = response["message"]
if message.get("content"):
yield {"message": {"role": "assistant", "content": message["content"]}}
yield {
"message": {
"role": "assistant",
"content": "",
"tool_calls": message.get("tool_calls") or [],
},
"done": True,
}
def _messages_with_context(
self,
query: str,
@@ -511,15 +585,57 @@ class OllamaAgent:
return [messages[0], {"role": "system", "content": context}, *messages[1:]]
async def _openai_health(self) -> dict[str, Any]:
return await self._cloud_health("openai")
async def _codex_health(self) -> dict[str, Any]:
command = self._codex_command()
if not command:
return {
"online": False,
"model": self.model,
"base_url": self.base_url,
"provider": "codex",
"model_available": False,
"models": [],
"message": "Codex CLI was not found on PATH.",
"detail": "",
}
try:
account, models = await self._codex_app_server_status()
except Exception as exc:
return {
"online": False,
"model": self.model,
"base_url": command,
"provider": "codex",
"model_available": False,
"models": [],
"message": "Codex App Server is installed, but TraderAI could not connect to it.",
"detail": str(exc),
}
logged_in = bool(account)
detail = f"Logged in as {account.get('email')}" if isinstance(account, dict) and account.get("email") else ""
return {
"online": logged_in,
"model": self.model,
"base_url": command,
"provider": "codex",
"model_available": self.model in models if models else bool(self.model),
"models": models,
"message": "Codex App Server is online." if logged_in else "Codex CLI is installed, but not logged in with ChatGPT.",
"detail": detail,
}
async def _cloud_health(self, provider: str) -> dict[str, Any]:
if not self.api_key:
return {
"online": False,
"model": self.model,
"base_url": self.base_url,
"provider": "openai",
"provider": provider,
"model_available": False,
"models": [],
"message": "OpenAI is selected, but no OpenAI API key is configured.",
"message": f"{self._provider_label()} is selected, but no API key is configured.",
"detail": "",
}
try:
@@ -532,10 +648,10 @@ class OllamaAgent:
"online": False,
"model": self.model,
"base_url": self.base_url,
"provider": "openai",
"provider": provider,
"model_available": False,
"models": [],
"message": f"OpenAI is unreachable at {self.base_url} or rejected the API key.",
"message": f"{self._provider_label()} is unreachable at {self.base_url} or rejected the API key.",
"detail": str(exc),
}
models = sorted(item.get("id") for item in body.get("data", []) if item.get("id"))
@@ -543,10 +659,10 @@ class OllamaAgent:
"online": True,
"model": self.model,
"base_url": self.base_url,
"provider": "openai",
"provider": provider,
"model_available": self.model in models,
"models": models,
"message": "OpenAI is online.",
"message": f"{self._provider_label()} is online.",
}
def _openai_headers(self) -> dict[str, str]:
@@ -595,8 +711,27 @@ class OllamaAgent:
normalized.append(entry)
return normalized
def _codex_tool_catalog(self) -> list[dict[str, Any]]:
tools: list[dict[str, Any]] = []
for schema in self.tools.schemas:
if schema.get("type") != "function":
continue
function = schema.get("function") or {}
tools.append(
{
"name": function.get("name", ""),
"description": function.get("description", ""),
"parameters": function.get("parameters") or {"type": "object", "properties": {}},
}
)
return tools
def _provider_label(self) -> str:
return "OpenAI model" if self.provider == "openai" else "local model"
if self.provider == "openai":
return "OpenAI model"
if self.provider == "codex":
return "Codex model"
return "local model"
@staticmethod
def _merge_openai_tool_call(target: dict[int, dict[str, Any]], delta: dict[str, Any]) -> None:
@@ -615,6 +750,431 @@ class OllamaAgent:
def _ordered_tool_calls(tool_calls: dict[int, dict[str, Any]]) -> list[dict[str, Any]]:
return [tool_calls[index] for index in sorted(tool_calls)]
async def _codex_cli_turn(
self,
query: str,
messages: list[dict[str, Any]],
previous_interaction: dict[str, Any] | None = None,
thread_id: str | None = DEFAULT_THREAD_ID,
) -> dict[str, Any]:
return await self._codex_app_server_turn(
query,
messages,
previous_interaction=previous_interaction,
thread_id=thread_id,
)
async def _codex_app_server_turn(
self,
query: str,
messages: list[dict[str, Any]],
previous_interaction: dict[str, Any] | None = None,
thread_id: str | None = DEFAULT_THREAD_ID,
) -> dict[str, Any]:
prompt = self._codex_cli_prompt(
query,
messages,
previous_interaction=previous_interaction,
thread_id=thread_id,
)
final_text = ""
process = await self._start_codex_app_server()
request_id = 1
async def send_request(method: str, params: dict[str, Any] | None = None, timeout: int = 120) -> 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 self._codex_app_server_write(process, payload)
while True:
message = await self._codex_app_server_read(process, timeout=timeout)
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 self._handle_codex_app_server_message(process, message)
try:
await send_request(
"initialize",
{
"clientInfo": {"name": "TraderAI", "version": __version__},
"capabilities": {"experimentalApi": True},
},
timeout=30,
)
await self._codex_app_server_write(process, {"jsonrpc": "2.0", "method": "initialized", "params": {}})
thread = await send_request(
"thread/start",
{
"model": self.model,
"modelProvider": None,
"cwd": str(Path.cwd()),
"approvalPolicy": "never",
"sandbox": "read-only",
"baseInstructions": "You are TraderAI running through the local Codex App Server using ChatGPT OAuth.",
"developerInstructions": (
"Do not run shell commands, inspect files, or modify the workspace. "
"Answer only with JSON matching the requested output schema."
),
"ephemeral": True,
"experimentalRawEvents": False,
"persistExtendedHistory": False,
},
timeout=30,
)
thread_id_value = ((thread.get("thread") or {}).get("id") or thread.get("threadId") or "").strip()
if not thread_id_value:
raise RuntimeError(f"Codex App Server did not return a thread id: {thread!r}")
turn = await send_request(
"turn/start",
{
"threadId": thread_id_value,
"input": [{"type": "text", "text": prompt, "text_elements": []}],
"cwd": str(Path.cwd()),
"approvalPolicy": "never",
"sandboxPolicy": {"type": "readOnly", "access": {"type": "fullAccess"}},
"model": self.model,
"effort": self.reasoning_effort,
"summary": "none",
"outputSchema": self._codex_output_schema(),
},
timeout=60,
)
turn_id = ((turn.get("turn") or {}).get("id") or "").strip()
if not turn_id:
raise RuntimeError(f"Codex App Server did not return a turn id: {turn!r}")
while True:
message = await self._codex_app_server_read(process, timeout=240)
method = message.get("method")
params = message.get("params") or {}
if method == "item/agentMessage/delta" and params.get("turnId") == turn_id:
final_text += params.get("delta") or ""
elif method == "item/completed" and params.get("turnId") == turn_id:
item = params.get("item") or {}
if item.get("type") == "agentMessage":
final_text = item.get("text") or final_text
elif method == "turn/completed" and (params.get("turn") or {}).get("id") == turn_id:
turn_status = (params.get("turn") or {}).get("status")
if turn_status != "completed":
error = (params.get("turn") or {}).get("error") or {}
raise RuntimeError(error.get("message") or f"Codex App Server turn ended with status {turn_status}.")
break
elif method == "error":
error = params.get("message") or params.get("error") or params
raise RuntimeError(f"Codex App Server error: {error}")
else:
await self._handle_codex_app_server_message(process, message)
finally:
await self._stop_codex_app_server(process)
return self._parse_codex_app_server_text(final_text)
def _codex_cli_prompt(
self,
query: str,
messages: list[dict[str, Any]],
previous_interaction: dict[str, Any] | None = None,
thread_id: str | None = DEFAULT_THREAD_ID,
) -> str:
conversation_lines: list[str] = []
for message in self._messages_with_context(
query,
messages,
previous_interaction=previous_interaction,
thread_id=thread_id,
):
role = message.get("role", "unknown")
content = message.get("content", "")
suffix = ""
if role == "user" and message.get("images"):
suffix = f" [attached images: {len(message.get('images') or [])}]"
if role == "tool":
suffix = f" [tool {message.get('tool_name') or ''}]"
if role == "assistant" and message.get("tool_calls"):
suffix = f" [tool calls: {json.dumps(message.get('tool_calls'), ensure_ascii=True)}]"
conversation_lines.append(f"{role}{suffix}: {content}")
tools_json = json.dumps(self._codex_tool_catalog(), ensure_ascii=True, indent=2)
return (
"You are TraderAI running through the local Codex App Server using ChatGPT OAuth.\n"
"Do not run shell commands, inspect files, or modify the workspace.\n"
"Your only job is to decide whether to answer directly or request exactly one TraderAI tool.\n\n"
"Return JSON that matches the provided schema.\n"
"- If you can answer now, set kind to final, put the user-facing reply in message, set tool_name to an empty string, and set arguments_json to '{}'.\n"
"- If you need a tool, set kind to tool_call, set tool_name to the exact tool name, set message to an empty string, and set arguments_json to a valid JSON object string.\n"
"- Never return more than one tool call at a time.\n"
"- Prefer the TraderAI tools over guessing.\n\n"
f"Available tools:\n{tools_json}\n\n"
"Conversation transcript:\n"
+ "\n".join(conversation_lines)
)
def _codex_structured_response(self, result: dict[str, Any]) -> dict[str, Any]:
if result.get("kind") == "tool_call":
tool_name = str(result.get("tool_name") or "").strip()
arguments_json = str(result.get("arguments_json") or "{}").strip() or "{}"
return {
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": f"codex-{uuid.uuid4()}",
"type": "function",
"function": {
"name": tool_name,
"arguments": arguments_json,
},
}
],
}
}
return {
"message": {
"role": "assistant",
"content": str(result.get("message") or ""),
"tool_calls": [],
}
}
def _write_codex_schema(self) -> str:
schema = self._codex_output_schema()
with tempfile.NamedTemporaryFile("w", suffix="-traderai-codex-schema.json", delete=False, encoding="utf-8") as handle:
json.dump(schema, handle, ensure_ascii=True)
return handle.name
@staticmethod
def _codex_output_schema() -> dict[str, Any]:
return {
"type": "object",
"properties": {
"kind": {"type": "string", "enum": ["final", "tool_call"]},
"message": {"type": "string"},
"tool_name": {"type": "string"},
"arguments_json": {"type": "string"},
},
"required": ["kind", "message", "tool_name", "arguments_json"],
"additionalProperties": False,
}
def _parse_codex_app_server_text(self, final_text: str) -> dict[str, Any]:
if not final_text.strip():
raise RuntimeError("Codex App Server returned an empty response.")
try:
parsed = json.loads(final_text)
except ValueError as exc:
raise RuntimeError(f"Codex App Server returned non-JSON output: {final_text}") from exc
if parsed.get("kind") not in {"final", "tool_call"}:
raise RuntimeError(f"Codex App Server returned an invalid result kind: {parsed!r}")
return parsed
def _parse_codex_exec_output(self, output: dict[str, Any]) -> dict[str, Any]:
events = output.get("events") or []
final_text = ""
error_text = ""
for event in events:
if event.get("type") == "item.completed":
item = event.get("item") or {}
if item.get("type") == "agent_message":
final_text = item.get("text") or final_text
elif event.get("type") == "error":
error_text = event.get("message") or error_text
elif event.get("type") == "turn.failed":
details = event.get("error") or {}
error_text = details.get("message") or error_text
if output.get("returncode") != 0 and not final_text:
raise RuntimeError(error_text or output.get("stderr") or "Codex CLI failed.")
try:
parsed = json.loads(final_text)
except ValueError as exc:
raise RuntimeError(f"Codex CLI returned non-JSON output: {final_text}") from exc
if parsed.get("kind") not in {"final", "tool_call"}:
raise RuntimeError(f"Codex CLI returned an invalid result kind: {parsed!r}")
return parsed
def _codex_command(self, required: bool = False) -> str | None:
configured = self.base_url.strip() if self.base_url else "codex"
resolved = shutil.which(configured) or configured
if required and not Path(resolved).exists() and shutil.which(resolved) is None:
raise RuntimeError("Codex CLI was not found on PATH.")
return resolved
async def _codex_app_server_status(self) -> tuple[dict[str, Any] | None, list[str]]:
process = await self._start_codex_app_server()
request_id = 1
async def send_request(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 self._codex_app_server_write(process, payload)
while True:
message = await self._codex_app_server_read(process, timeout=30)
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 self._handle_codex_app_server_message(process, message)
try:
await send_request(
"initialize",
{
"clientInfo": {"name": "TraderAI", "version": __version__},
"capabilities": {"experimentalApi": True},
},
)
await self._codex_app_server_write(process, {"jsonrpc": "2.0", "method": "initialized", "params": {}})
account_result = await send_request("account/read", {"refreshToken": False})
models: 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_request("model/list", params)
for item in page.get("data") or []:
model = item.get("id") or item.get("model")
if model:
models.append(model)
cursor = page.get("nextCursor")
if not cursor:
break
return account_result.get("account"), sorted(set(models))
finally:
await self._stop_codex_app_server(process)
async def _start_codex_app_server(self) -> asyncio.subprocess.Process:
return await asyncio.create_subprocess_exec(
self._codex_command(required=True),
"app-server",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
async def _codex_app_server_write(self, process: asyncio.subprocess.Process, 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 _codex_app_server_read(self, process: asyncio.subprocess.Process, timeout: int) -> 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 TimeoutError as exc:
raise RuntimeError("Codex App Server timed out.") 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 TimeoutError:
stderr = ""
raise RuntimeError(stderr or "Codex App Server exited without a response.")
try:
return json.loads(line.decode("utf-8", errors="replace"))
except ValueError as exc:
raise RuntimeError(f"Codex App Server returned invalid JSON-RPC: {line!r}") from exc
async def _handle_codex_app_server_message(self, process: asyncio.subprocess.Process, message: dict[str, Any]) -> None:
if "id" not in message or "method" not in message:
return
method = message.get("method")
if method in {
"item/commandExecution/requestApproval",
"item/fileChange/requestApproval",
"applyPatchApproval",
"execCommandApproval",
}:
await self._codex_app_server_write(
process,
{
"jsonrpc": "2.0",
"id": message["id"],
"result": {
"decision": "deny",
"message": "TraderAI does not allow Codex to run commands or change files.",
},
},
)
return
await self._codex_app_server_write(
process,
{
"jsonrpc": "2.0",
"id": message["id"],
"error": {"code": -32601, "message": f"TraderAI does not handle Codex App Server request {method}."},
},
)
async def _stop_codex_app_server(self, process: asyncio.subprocess.Process) -> None:
if process.returncode is not None:
return
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=3)
except TimeoutError:
process.kill()
await process.wait()
async def _run_command(self, command: list[str], timeout: int = 120, stdin_text: str | None = None) -> dict[str, Any]:
process = await asyncio.create_subprocess_exec(
*command,
stdin=asyncio.subprocess.PIPE if stdin_text is not None else None,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
payload = stdin_text.encode("utf-8") if stdin_text is not None else None
stdout, stderr = await asyncio.wait_for(process.communicate(payload), timeout=timeout)
except TimeoutError:
process.kill()
await process.communicate()
raise RuntimeError(f"Command timed out: {' '.join(command[:3])}")
stdout_text = stdout.decode("utf-8", errors="replace")
stderr_text = stderr.decode("utf-8", errors="replace")
events = []
for line in stdout_text.splitlines():
line = line.strip()
if not line:
continue
try:
events.append(json.loads(line))
except ValueError:
events.append({"type": "stdout", "text": line})
return {
"returncode": process.returncode,
"stdout": stdout_text,
"stderr": stderr_text,
"events": events,
}
def _codex_model_cache(self) -> 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 _runtime_context(
self,
query: str,
@@ -741,6 +1301,16 @@ class OllamaAgent:
choice = (response.json().get("choices") or [{}])[0]
message = choice.get("message") or {}
return self._clean_generated_title(message.get("content", ""))
if self.provider == "codex":
result = await self._codex_app_server_turn(
prompt,
[
{"role": "system", "content": "You write short chat titles."},
{"role": "user", "content": prompt},
],
thread_id="title",
)
return self._clean_generated_title(result.get("message", ""))
async with httpx.AsyncClient(timeout=20) as client:
response = await client.post(
f"{self.base_url}/api/chat",
@@ -831,6 +1401,10 @@ class OllamaAgent:
"list_scmdb_versions": "Checking SCMDB versions",
"search_scmdb_missions": "Searching SCMDB missions",
"get_scmdb_mission_rewards": "Fetching SCMDB mission rewards",
"search_scwiki_pages": "Searching Star Citizen Wiki",
"get_scwiki_page": "Reading Star Citizen Wiki page",
"search_scwiki_vehicles": "Searching Star Citizen Wiki vehicles",
"get_scwiki_vehicle": "Fetching Star Citizen Wiki vehicle",
"search_cornerstone_items": "Searching Cornerstone items",
"get_cornerstone_item_locations": "Fetching Cornerstone item locations",
"get_cornerstone_item_media": "Fetching Cornerstone item media",
+18 -2
View File
@@ -17,9 +17,14 @@ CONFIG_FIELDS: dict[str, dict[str, Any]] = {
"ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False},
"openai_base_url": {"env": "OPENAI_BASE_URL", "type": "string", "secret": False},
"openai_model": {"env": "OPENAI_MODEL", "type": "string", "secret": False},
"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},
"scmdb_base_url": {"env": "SCMDB_BASE_URL", "type": "string", "secret": False},
"cornerstone_base_url": {"env": "CORNERSTONE_BASE_URL", "type": "string", "secret": False},
"scwiki_base_url": {"env": "SCWIKI_BASE_URL", "type": "string", "secret": False},
"scwiki_api_base_url": {"env": "SCWIKI_API_BASE_URL", "type": "string", "secret": False},
"openai_api_key": {"env": "OPENAI_API_KEY", "type": "string", "secret": True},
"uex_secret_key": {"env": "UEX_SECRET_KEY", "type": "string", "secret": True},
"uex_bearer_token": {"env": "UEX_BEARER_TOKEN", "type": "string", "secret": True},
@@ -71,10 +76,15 @@ class Settings(BaseSettings):
ollama_model: str = "qwen3.5:9b"
ollama_num_ctx: int = 64512
openai_base_url: str = "https://api.openai.com/v1"
openai_model: str = "gpt-5.3-codex"
openai_model: str = "gpt-5.4-mini"
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"
scmdb_base_url: str = "https://scmdb.net"
cornerstone_base_url: str = "https://finder.cstone.space"
scwiki_base_url: str = "https://starcitizen.tools"
scwiki_api_base_url: str = "https://api.star-citizen.wiki"
openai_api_key: str | None = Field(default=None)
uex_secret_key: str | None = Field(default=None)
uex_bearer_token: str | None = Field(default=None)
@@ -92,7 +102,13 @@ class Settings(BaseSettings):
@classmethod
def _normalize_model_provider(cls, value: Any) -> str:
text = str(value or "ollama").strip().casefold()
return text if text in {"ollama", "openai"} else "ollama"
return text if text in {"ollama", "openai", "codex"} 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"} else "medium"
@field_validator("traderai_memory_path", mode="before")
@classmethod
+31 -3
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import os
from pathlib import Path
import shutil
@@ -25,6 +26,10 @@ def resource_path(*parts: str) -> Path:
def main() -> None:
try:
_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(f"cwd={Path.cwd()}")
_log(f"executable={sys.executable}")
@@ -36,9 +41,13 @@ def main() -> None:
_log("existing TraderAI backend found; opening window")
_open_window(url)
return
server_thread = threading.Thread(target=_run_server, args=(port,), daemon=True)
server_thread.start()
_log("backend thread started")
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.start()
_log("backend thread started")
_wait_for_server(url)
_log("backend health check passed")
_open_window(url)
@@ -62,6 +71,22 @@ def _select_port() -> int:
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:
try:
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:
try:
_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
config = uvicorn.Config(
+11
View File
@@ -232,6 +232,17 @@ class ContinualPlanStore:
self.add_event(plan_id, status, f"Plan status changed to {status}.")
return self.get_plan(plan_id)
def delete_plan(self, plan_id: str) -> bool:
with self.memory._connect() as db:
deleted = db.execute("DELETE FROM continual_plans WHERE id = ?", (plan_id,)).rowcount
if not deleted:
return False
db.execute("DELETE FROM continual_plan_items WHERE plan_id = ?", (plan_id,))
db.execute("DELETE FROM continual_plan_candidates WHERE plan_id = ?", (plan_id,))
db.execute("DELETE FROM continual_plan_events WHERE plan_id = ?", (plan_id,))
db.execute("DELETE FROM continual_plan_negotiations WHERE plan_id = ?", (plan_id,))
return True
def add_event(self, plan_id: str, kind: str, message: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
now = iso_now()
with self.memory._connect() as db:
+502 -58
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import os
import asyncio
import json
import shutil
import subprocess
@@ -26,6 +27,7 @@ from traderai.memory import DEFAULT_THREAD_ID, MemoryStore
from traderai.plans import ContinualPlanRunner, ContinualPlanStore
from traderai.scheduler import WakeScheduler
from traderai.scmdb_client import SCMDBClient
from traderai.starcitizen_wiki_client import StarCitizenWikiClient
from traderai.tools import ToolRegistry
from traderai.uex_client import UEXClient
from traderai.version import RELEASES_API_URL, RELEASES_URL, __version__
@@ -106,34 +108,52 @@ def create_app() -> FastAPI:
memory = MemoryStore(settings.traderai_memory_path)
plan_store = ContinualPlanStore(memory)
scheduler = WakeScheduler(memory)
uex = UEXClient(settings.uex_base_url, settings.uex_secret_key, settings.uex_bearer_token)
scmdb = SCMDBClient(settings.scmdb_base_url)
cornerstone = CornerstoneClient(settings.cornerstone_base_url)
tools = ToolRegistry(
uex,
settings.require_write_approval,
memory=memory,
scheduler=scheduler,
scmdb=scmdb,
cornerstone=cornerstone,
plan_store=plan_store,
)
plan_runner = ContinualPlanRunner(plan_store, tools, memory)
tools.plan_runner = plan_runner
agent = OllamaAgent(
settings.openai_base_url if settings.model_provider == "openai" else settings.ollama_base_url,
settings.openai_model if settings.model_provider == "openai" else settings.ollama_model,
tools,
memory=memory,
user_name=settings.traderai_user_name,
num_ctx=settings.ollama_num_ctx,
provider=settings.model_provider,
api_key=settings.openai_api_key,
)
plan_runner.bind_agent(agent)
scheduler.bind_agent(agent)
scheduler.bind_plan_runner(plan_runner)
scheduler.bind_uex_notifications(uex, settings.uex_notification_poll_seconds)
runtime: dict[str, Any] = {}
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)
tools = ToolRegistry(
uex,
current_settings.require_write_approval,
memory=memory,
scheduler=scheduler,
scmdb=scmdb,
cornerstone=cornerstone,
scwiki=scwiki,
plan_store=plan_store,
)
plan_runner = ContinualPlanRunner(plan_store, tools, memory)
tools.plan_runner = plan_runner
provider_base_url, provider_model, provider_api_key = provider_settings(current_settings)
agent = OllamaAgent(
provider_base_url,
provider_model,
tools,
memory=memory,
user_name=current_settings.traderai_user_name,
num_ctx=current_settings.ollama_num_ctx,
provider=current_settings.model_provider,
api_key=provider_api_key,
reasoning_effort=current_settings.model_reasoning_effort,
)
plan_runner.bind_agent(agent)
scheduler.bind_agent(agent)
scheduler.bind_plan_runner(plan_runner)
scheduler.bind_uex_notifications(uex, current_settings.uex_notification_poll_seconds)
runtime.update(
{
"settings": current_settings,
"uex": uex,
"tools": tools,
"plan_runner": plan_runner,
"agent": agent,
}
)
configure_runtime(settings)
app = FastAPI(title="TraderAI")
static_dir = resource_path("web")
@@ -149,17 +169,20 @@ def create_app() -> FastAPI:
scheduler.shutdown()
async def refresh_user_profile() -> None:
if settings.traderai_user_name:
memory.set_profile("configured_name", settings.traderai_user_name)
agent.user_name = agent.user_name or settings.traderai_user_name
current_settings = get_settings()
agent = runtime["agent"]
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:
response = await uex.get_user(authenticated=True)
except Exception as exc:
memory.set_profile("uex_user_error", str(exc))
if settings.traderai_user_name:
if current_settings.traderai_user_name:
try:
response = await uex.get_user(username=settings.traderai_user_name)
response = await uex.get_user(username=current_settings.traderai_user_name)
except Exception:
return
else:
@@ -178,9 +201,13 @@ def create_app() -> FastAPI:
@app.get("/api/health")
async def health() -> dict:
agent = runtime["agent"]
current_settings = get_settings()
inference = await agent.health()
return {
"ollama": await agent.health(),
"model_provider": settings.model_provider,
"inference": inference,
"ollama": inference,
"model_provider": current_settings.model_provider,
"user": memory.get_profile(),
"jobs": scheduler.list_jobs(),
"app_data_dir": settings_payload()["app_data_dir"],
@@ -193,27 +220,62 @@ def create_app() -> FastAPI:
@app.post("/api/config")
async def update_config(request: ConfigUpdateRequest) -> dict:
previous_settings = get_settings()
updated = save_settings(request.values)
updated["restart_required"] = True
updated["message"] = "Configuration saved. Restart TraderAI for all settings to take effect."
current_settings = get_settings()
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
@app.get("/api/ollama/status")
async def ollama_status() -> dict:
return await inspect_model_provider()
@app.get("/api/openai/models")
async def openai_models() -> dict:
status = await inspect_openai()
@app.get("/api/provider/models")
async def provider_models(provider: str | None = None) -> dict:
status = await inspect_provider_models(provider)
return {
"provider": "openai",
"provider": status.get("provider", "openai"),
"configured_model": status.get("configured_model"),
"models": status.get("models", []),
"reasoning_efforts": status.get("reasoning_efforts", reasoning_effort_options()),
"configured_reasoning_effort": status.get("configured_reasoning_effort", get_settings().model_reasoning_effort),
"message": status.get("message", ""),
"detail": status.get("detail", ""),
"online": status.get("online", False),
}
@app.post("/api/codex/login")
async def launch_codex_login() -> dict:
current_settings = get_settings()
command = find_codex_cli(current_settings.codex_command)
if not command:
raise HTTPException(status_code=404, detail="Codex CLI was not found on PATH.")
try:
login = await start_codex_browser_login(command)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Codex App Server login failed: {exception_detail(exc)}") from exc
return {
"installed": True,
"running": False,
"online": False,
"provider": "codex",
"login_id": login.get("loginId"),
"auth_url": login.get("authUrl"),
"base_url": str(command),
"message": "Opened Codex App Server sign-in in your browser. Finish the flow, then TraderAI will detect the new login.",
}
@app.post("/api/ollama/launch")
async def launch_ollama() -> dict:
command = ollama_launch_command()
@@ -319,6 +381,7 @@ def create_app() -> FastAPI:
@app.post("/api/chat")
async def chat(request: ChatRequest) -> dict:
agent = runtime["agent"]
try:
return await agent.chat(
request.message,
@@ -330,6 +393,8 @@ def create_app() -> FastAPI:
@app.post("/api/chat/stream")
async def chat_stream(request: ChatRequest) -> StreamingResponse:
agent = runtime["agent"]
async def events():
async for event in agent.chat_events(
request.message,
@@ -367,6 +432,7 @@ def create_app() -> FastAPI:
@app.get("/api/pending-actions")
async def pending_actions() -> dict:
agent = runtime["agent"]
return {"pending_actions": agent._pending_payloads()}
@app.get("/api/notifications")
@@ -393,11 +459,13 @@ def create_app() -> FastAPI:
@app.get("/api/negotiations/{identifier}/messages")
async def negotiation_messages(identifier: str) -> dict:
uex = runtime["uex"]
params = negotiation_identifier_params(identifier)
return await uex.get("marketplace_negotiations_messages", params, authenticated=True)
@app.post("/api/negotiations/{identifier}/messages")
async def send_negotiation_message(identifier: str, request: DirectNegotiationMessageRequest) -> dict:
uex = runtime["uex"]
params = negotiation_identifier_params(identifier)
payload = {**params, "message": request.message, "is_production": 1}
return await uex.post("marketplace_negotiations_messages", payload, authenticated=True)
@@ -412,6 +480,7 @@ def create_app() -> FastAPI:
@app.post("/api/plans")
async def create_continual_plan(request: ContinualPlanCreateRequest) -> dict:
tools = runtime["tools"]
result = await tools.create_continual_plan(
title=request.title,
objective=request.objective,
@@ -433,6 +502,7 @@ def create_app() -> FastAPI:
@app.post("/api/plans/{plan_id}/pause")
async def pause_continual_plan(plan_id: str) -> dict:
tools = runtime["tools"]
result = await tools.pause_continual_plan(plan_id)
if result.get("error"):
raise HTTPException(status_code=404, detail=result["error"])
@@ -440,6 +510,7 @@ def create_app() -> FastAPI:
@app.post("/api/plans/{plan_id}/resume")
async def resume_continual_plan(plan_id: str) -> dict:
tools = runtime["tools"]
result = await tools.resume_continual_plan(plan_id)
if result.get("error"):
raise HTTPException(status_code=404, detail=result["error"])
@@ -447,13 +518,23 @@ def create_app() -> FastAPI:
@app.post("/api/plans/{plan_id}/cancel")
async def cancel_continual_plan(plan_id: str) -> dict:
tools = runtime["tools"]
result = await tools.cancel_continual_plan(plan_id)
if result.get("error"):
raise HTTPException(status_code=404, detail=result["error"])
return result
@app.delete("/api/plans/{plan_id}")
async def delete_continual_plan(plan_id: str) -> dict:
tools = runtime["tools"]
result = await tools.delete_continual_plan(plan_id)
if result.get("error"):
raise HTTPException(status_code=404, detail=result["error"])
return result
@app.post("/api/plans/{plan_id}/run")
async def run_continual_plan(plan_id: str) -> dict:
tools = runtime["tools"]
result = await tools.run_continual_plan_now(plan_id)
if result.get("error"):
raise HTTPException(status_code=400, detail=result["error"])
@@ -487,10 +568,12 @@ def create_app() -> FastAPI:
@app.post("/api/approve/{action_id}")
async def approve(action_id: str) -> dict:
tools = runtime["tools"]
return await tools.approve(action_id)
@app.post("/api/decline/{action_id}")
async def decline(action_id: str) -> dict:
tools = runtime["tools"]
return await tools.decline(action_id)
return app
@@ -509,33 +592,96 @@ async def inspect_model_provider() -> dict[str, Any]:
settings = get_settings()
if settings.model_provider == "openai":
return await inspect_openai()
if settings.model_provider == "codex":
return await inspect_codex()
return await inspect_ollama()
async def inspect_openai() -> dict[str, Any]:
settings = get_settings()
return await inspect_cloud_provider_config("openai", settings.openai_base_url, settings.openai_api_key, settings.openai_model)
async def inspect_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()
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()
return await inspect_openai()
async def inspect_cloud_provider_config(
provider: str,
base_url: str,
api_key: str | None,
model: str,
) -> dict[str, Any]:
settings = get_settings()
models: list[str] = []
online = False
detail = ""
if not settings.openai_api_key:
provider_name = provider_display_name(provider)
if not api_key:
return {
"installed": True,
"running": False,
"online": False,
"provider": "openai",
"provider": provider,
"model_available": False,
"configured_model": settings.openai_model,
"base_url": settings.openai_base_url,
"configured_model": model,
"configured_reasoning_effort": settings.model_reasoning_effort,
"reasoning_efforts": reasoning_effort_options(),
"base_url": base_url,
"models": [],
"message": "OpenAI is selected, but no API key is configured.",
"message": f"{provider_name} is selected, but no API key is configured.",
"detail": "",
}
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(
f"{settings.openai_base_url.rstrip('/')}/models",
headers={"Authorization": f"Bearer {settings.openai_api_key}"},
f"{base_url.rstrip('/')}/models",
headers={"Authorization": f"Bearer {api_key}"},
)
response.raise_for_status()
body = response.json()
@@ -544,17 +690,19 @@ async def inspect_openai() -> dict[str, Any]:
except (httpx.HTTPError, ValueError) as exc:
detail = str(exc)
model_available = settings.openai_model in models
model_available = model in models
return {
"installed": True,
"running": online,
"online": online,
"provider": "openai",
"provider": provider,
"model_available": model_available,
"configured_model": settings.openai_model,
"base_url": settings.openai_base_url,
"configured_model": model,
"configured_reasoning_effort": settings.model_reasoning_effort,
"reasoning_efforts": reasoning_effort_options(),
"base_url": base_url,
"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,
}
@@ -587,6 +735,8 @@ async def inspect_ollama() -> dict[str, Any]:
"provider": "ollama",
"model_available": model_available,
"configured_model": settings.ollama_model,
"configured_reasoning_effort": settings.model_reasoning_effort,
"reasoning_efforts": reasoning_effort_options(),
"base_url": settings.ollama_base_url,
"num_ctx": settings.ollama_num_ctx,
"models": models,
@@ -599,14 +749,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:
return "OpenAI API key is not configured."
return f"{provider_name} API key is not configured."
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:
return f'OpenAI is reachable, but model "{model}" was not returned by the API.'
return "OpenAI is ready."
return f'{provider_name} is reachable, but model "{model}" was not returned by the API.'
return f"{provider_name} is ready."
def ollama_status_message(installed: bool, running: bool, model_available: bool, model: str) -> str:
@@ -619,6 +770,292 @@ def ollama_status_message(installed: bool, running: bool, model_available: bool,
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 == "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", "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 find_ollama_executable() -> Path | None:
candidates = [
shutil.which("ollama"),
@@ -671,6 +1108,13 @@ def popen_hidden(command: list[str]) -> subprocess.Popen:
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]:
try:
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
+285 -3
View File
@@ -10,6 +10,7 @@ from traderai.cornerstone_client import CornerstoneClient, parse_cornerstone_ite
from traderai.memory import MemoryStore
from traderai.scheduler import WakeScheduler
from traderai.scmdb_client import SCMDBClient
from traderai.starcitizen_wiki_client import StarCitizenWikiClient
from traderai.uex_client import UEXClient
@@ -58,10 +59,14 @@ UEX_GET_RESOURCES: dict[str, dict[str, Any]] = {
"marketplace_averages": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "marketplace"},
"marketplace_averages_all": {"params": [], "auth": False, "group": "marketplace", "heavy": True},
"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_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_history": {
"params": [
@@ -83,7 +88,11 @@ UEX_GET_RESOURCES: dict[str, dict[str, Any]] = {
"group": "marketplace",
"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"},
"orbits": {"params": ["id", "id_star_system", "name", "slug"], "auth": False, "group": "locations"},
"orbits_distances": {"params": ["id_origin", "id_destination"], "auth": False, "group": "locations"},
@@ -162,12 +171,14 @@ class ToolRegistry:
scheduler: WakeScheduler | None = None,
scmdb: SCMDBClient | None = None,
cornerstone: CornerstoneClient | None = None,
scwiki: StarCitizenWikiClient | None = None,
plan_store: Any | None = None,
plan_runner: Any | None = None,
) -> None:
self.uex = uex
self.scmdb = scmdb or SCMDBClient()
self.cornerstone = cornerstone or CornerstoneClient()
self.scwiki = scwiki or StarCitizenWikiClient()
self.require_write_approval = require_write_approval
self.memory = memory
self.scheduler = scheduler
@@ -178,6 +189,7 @@ class ToolRegistry:
self.handlers: dict[str, ToolHandler] = {
"search_marketplace_listings": self.search_marketplace_listings,
"get_marketplace_listing": self.get_marketplace_listing,
"get_marketplace_trends": self.get_marketplace_trends,
"list_marketplace_negotiations": self.list_marketplace_negotiations,
"get_negotiation_messages": self.get_negotiation_messages,
"draft_negotiation_message": self.draft_negotiation_message,
@@ -192,11 +204,16 @@ class ToolRegistry:
"pause_continual_plan": self.pause_continual_plan,
"resume_continual_plan": self.resume_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,
"check_uex_notifications": self.check_uex_notifications,
"list_scmdb_versions": self.list_scmdb_versions,
"search_scmdb_missions": self.search_scmdb_missions,
"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_cornerstone_items": self.search_cornerstone_items,
"get_cornerstone_item_locations": self.get_cornerstone_item_locations,
"get_cornerstone_item_media": self.get_cornerstone_item_media,
@@ -226,6 +243,7 @@ class ToolRegistry:
*self._uex_post_schemas(),
*self._uex_delete_schemas(),
*self._scmdb_schemas(),
*self._scwiki_schemas(),
*self._cornerstone_schemas(),
{
"type": "function",
@@ -261,6 +279,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",
"function": {
@@ -480,6 +516,14 @@ class ToolRegistry:
"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",
"function": {
@@ -965,6 +1009,68 @@ 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 _cornerstone_schemas(cls) -> list[dict[str, Any]]:
return [
@@ -1213,6 +1319,49 @@ class ToolRegistry:
response = await self.uex.get("marketplace_listings", {"id": id, "slug": slug})
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(
self,
id: int | None = None,
@@ -1405,6 +1554,19 @@ class ToolRegistry:
self.scheduler.unschedule_plan(plan_id)
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]:
if self.plan_runner is None:
return {"error": "Continual plan runner is not configured."}
@@ -1535,6 +1697,49 @@ class ToolRegistry:
"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_cornerstone_items(
self,
query: str = "",
@@ -2210,6 +2415,83 @@ class ToolRegistry:
"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"),
}
@classmethod
def _summarize_negotiation(cls, negotiation: dict[str, Any]) -> dict[str, Any]:
summary = cls._project_item(negotiation, mode="summary")
+1
View File
@@ -12,3 +12,4 @@ RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingCo
Generated
+1
View File
@@ -1052,3 +1052,4 @@ wheels = [
+322 -44
View File
@@ -26,7 +26,10 @@ const ollamaDownloadButton = document.getElementById("ollama-download");
const ollamaInstallButton = document.getElementById("ollama-install");
const ollamaLaunchButton = document.getElementById("ollama-launch");
const ollamaPullButton = document.getElementById("ollama-pull");
const codexLoginButton = document.getElementById("codex-login");
const openaiModelsRefreshButton = document.getElementById("openai-models-refresh");
const providerModelSelect = document.getElementById("provider-model-select");
const modelReasoningEffortSelect = document.getElementById("model-reasoning-effort");
const ollamaStatusEl = document.getElementById("ollama-status");
const ollamaMessageEl = document.getElementById("ollama-message");
const updateCheckButton = document.getElementById("update-check");
@@ -57,6 +60,7 @@ const planForm = document.getElementById("plan-form");
const plansStatusEl = document.getElementById("plans-status");
const plansDashboardEl = document.getElementById("plans-dashboard");
const plansRailListEl = document.getElementById("plans-rail-list");
const providerScopedFields = Array.from(document.querySelectorAll("[data-provider-scope]"));
let ollamaOnline = true;
let latestUpdate = null;
@@ -599,6 +603,9 @@ const ollamaFieldIds = {
openai_base_url: "openai-base-url",
openai_api_key: "openai-api-key",
openai_model: "openai-model",
model_reasoning_effort: "model-reasoning-effort",
codex_command: "codex-command",
codex_model: "codex-model",
};
async function refreshConfig() {
@@ -636,6 +643,8 @@ function renderConfig(config) {
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}`;
configStatusEl.textContent = "";
}
@@ -703,18 +712,21 @@ async function refreshOllamaStatus() {
function renderOllamaStatus(status) {
if (!ollamaStatusEl) return;
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 ready = status.provider === "openai"
const isOpenAIProvider = status.provider === "openai";
const isCodexProvider = status.provider === "codex";
const ready = isOpenAIProvider
? Boolean(status.online && status.model_available)
: Boolean(status.installed && status.running && status.model_available);
const pillClass = ready ? "status-pill" : "status-pill warning";
const detailItems = [
ollamaStatusItem("Provider", provider),
ollamaStatusItem("Model", status.configured_model || ""),
ollamaStatusItem("URL", status.base_url || ""),
ollamaStatusItem(isCodexProvider ? "Command" : "URL", status.base_url || ""),
];
if (status.provider !== "openai") {
if (!isOpenAIProvider && !isCodexProvider) {
detailItems.splice(1, 0, ollamaStatusItem("Installed", status.installed ? "Yes" : "No"));
detailItems.splice(2, 0, ollamaStatusItem("Running", status.running ? "Yes" : "No"));
detailItems.push(ollamaStatusItem("Pulled", status.model_available ? "Yes" : "No"));
@@ -728,27 +740,32 @@ function renderOllamaStatus(status) {
<div class="ollama-status-grid">
${detailItems.join("")}
</div>
${ollamaStatusItem(status.provider === "openai" ? "Available Models" : "Installed Models", models)}
${ollamaStatusItem(isOpenAIProvider || isCodexProvider ? "Available Models" : "Installed Models", models)}
${status.detail ? ollamaStatusItem("Detail", status.detail) : ""}
`;
if (ollamaDownloadButton) ollamaDownloadButton.hidden = status.provider === "openai";
if (ollamaDownloadButton) ollamaDownloadButton.hidden = isOpenAIProvider || isCodexProvider;
if (ollamaInstallButton) {
ollamaInstallButton.hidden = status.provider === "openai" || !status.can_auto_install;
ollamaInstallButton.hidden = isOpenAIProvider || isCodexProvider || !status.can_auto_install;
ollamaInstallButton.disabled = Boolean(status.installed) || !status.can_auto_install;
}
if (ollamaLaunchButton) {
ollamaLaunchButton.hidden = status.provider === "openai";
ollamaLaunchButton.hidden = isOpenAIProvider || isCodexProvider;
ollamaLaunchButton.disabled = !status.installed || Boolean(status.running);
}
if (ollamaPullButton) {
ollamaPullButton.hidden = status.provider === "openai";
ollamaPullButton.hidden = isOpenAIProvider || isCodexProvider;
ollamaPullButton.disabled = !status.running || Boolean(status.model_available);
}
if (codexLoginButton) {
codexLoginButton.hidden = !isCodexProvider;
codexLoginButton.disabled = Boolean(status.online);
}
if (openaiModelsRefreshButton) {
openaiModelsRefreshButton.hidden = status.provider !== "openai";
openaiModelsRefreshButton.hidden = false;
openaiModelsRefreshButton.disabled = false;
}
renderProviderModelOptions(status.models || []);
renderProviderModelOptions(status.models || [], status);
renderReasoningEffortOptions(status.reasoning_efforts || [], status.configured_reasoning_effort || "medium");
updateOllamaAttention(status);
}
@@ -791,15 +808,18 @@ function setOllamaButtonAttention(button, action, active) {
function updateOllamaAttention(status = null) {
const currentStatus = status || latestOllamaStatus;
if (!currentStatus) return;
const ready = currentStatus.provider === "openai"
const isOpenAIProvider = currentStatus.provider === "openai";
const isCodexProvider = currentStatus.provider === "codex";
const ready = isOpenAIProvider
? Boolean(currentStatus.online && currentStatus.model_available)
: Boolean(currentStatus.installed && currentStatus.running && currentStatus.model_available);
ollamaToggle?.classList.toggle("attention-pulse", !ready);
setOllamaButtonAttention(ollamaDownloadButton, "download", currentStatus.provider !== "openai" && !currentStatus.installed);
setOllamaButtonAttention(ollamaInstallButton, "install", currentStatus.provider !== "openai" && !currentStatus.installed && currentStatus.can_auto_install);
setOllamaButtonAttention(ollamaLaunchButton, "launch", currentStatus.provider !== "openai" && currentStatus.installed && !currentStatus.running);
setOllamaButtonAttention(ollamaPullButton, "pull", currentStatus.provider !== "openai" && currentStatus.running && !currentStatus.model_available);
setOllamaButtonAttention(openaiModelsRefreshButton, "openai-models", currentStatus.provider === "openai" && !currentStatus.model_available);
setOllamaButtonAttention(ollamaDownloadButton, "download", !isOpenAIProvider && !isCodexProvider && !currentStatus.installed);
setOllamaButtonAttention(ollamaInstallButton, "install", !isOpenAIProvider && !isCodexProvider && !currentStatus.installed && currentStatus.can_auto_install);
setOllamaButtonAttention(ollamaLaunchButton, "launch", !isOpenAIProvider && !isCodexProvider && currentStatus.installed && !currentStatus.running);
setOllamaButtonAttention(ollamaPullButton, "pull", !isOpenAIProvider && !isCodexProvider && currentStatus.running && !currentStatus.model_available);
setOllamaButtonAttention(codexLoginButton, "codex-login", isCodexProvider && !currentStatus.online);
setOllamaButtonAttention(openaiModelsRefreshButton, "openai-models", isOpenAIProvider && !currentStatus.model_available);
if (ready) clickedOllamaActions.clear();
}
@@ -807,31 +827,137 @@ function configuredOllamaModel() {
return document.getElementById("ollama-model")?.value || "";
}
function renderProviderModelOptions(models) {
function updateProviderFieldVisibility(provider) {
for (const field of providerScopedFields) {
const scope = field.dataset.providerScope;
field.hidden = scope !== provider;
}
}
function renderProviderModelOptions(models, status = latestOllamaStatus) {
const datalist = document.getElementById("provider-models");
if (!datalist) return;
datalist.innerHTML = "";
if (datalist) datalist.innerHTML = "";
for (const model of models) {
if (datalist) {
const option = document.createElement("option");
option.value = model;
datalist.appendChild(option);
}
}
if (!providerModelSelect) return;
const provider = status?.provider || document.getElementById("model-provider")?.value || "ollama";
const configuredModel = configuredProviderModel(provider);
providerModelSelect.innerHTML = "";
const allModels = [...new Set([configuredModel, ...models].filter(Boolean))];
if (!allModels.length) {
const option = document.createElement("option");
option.value = "";
option.textContent = "No models detected";
providerModelSelect.appendChild(option);
providerModelSelect.disabled = true;
return;
}
providerModelSelect.disabled = false;
for (const model of allModels) {
const option = document.createElement("option");
option.value = model;
datalist.appendChild(option);
option.textContent = model;
if (model === configuredModel) option.selected = true;
providerModelSelect.appendChild(option);
}
}
async function refreshOpenAIModels() {
setOllamaMessage("Loading OpenAI models");
setOllamaMessage("Loading provider models");
try {
const response = await fetch("/api/openai/models");
const provider = document.getElementById("model-provider")?.value || latestOllamaStatus?.provider || "openai";
const response = await fetch(`/api/provider/models?provider=${encodeURIComponent(provider)}`);
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
renderProviderModelOptions(result.models || []);
setOllamaMessage(result.message || "Loaded OpenAI models");
renderProviderModelOptions(result.models || [], {
provider: result.provider || provider,
configured_model: configuredProviderModel(result.provider || provider),
});
setOllamaMessage(result.message || "Loaded provider models");
await refreshOllamaStatus();
} catch (error) {
setOllamaMessage(`OpenAI models failed: ${fetchErrorMessage(error)}`);
setOllamaMessage(`Provider model load failed: ${fetchErrorMessage(error)}`);
}
}
async function launchCodexLogin() {
markOllamaActionClicked("codex-login");
setOllamaMessage("Starting Codex sign-in");
try {
const response = await fetch("/api/codex/login", { method: "POST" });
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
if (result.auth_url) {
window.open(result.auth_url, "_blank", "noopener,noreferrer");
}
setOllamaMessage(result.message || "Opened Codex sign-in in your browser. Waiting for completion...");
await waitForCodexLogin();
} catch (error) {
setOllamaMessage(`Codex sign-in failed: ${fetchErrorMessage(error)}`);
}
}
async function waitForCodexLogin() {
for (let attempt = 0; attempt < 80; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, attempt < 8 ? 1500 : 3000));
await refreshOllamaStatus();
const provider = latestOllamaStatus?.provider || "";
if (provider === "codex" && latestOllamaStatus?.online) {
setOllamaMessage("Codex sign-in complete.");
return;
}
}
setOllamaMessage("Codex sign-in opened. If you completed it, click Load Provider Models or refresh provider status.");
}
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 === "openai") return document.getElementById("openai-model")?.value || "";
if (provider === "codex") return document.getElementById("codex-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 === "openai") {
const field = document.getElementById("openai-model");
if (field) field.value = selectedModel;
return;
}
if (provider === "codex") {
const field = document.getElementById("codex-model");
if (field) field.value = selectedModel;
return;
}
const field = document.getElementById("ollama-model");
if (field) field.value = selectedModel;
}
function providerDisplayName(provider) {
if (provider === "openai") return "OpenAI";
if (provider === "codex") return "Codex";
return "Local Ollama";
}
async function checkForUpdate(promptUser = false) {
if (!updateStatusEl) return;
updateStatusEl.textContent = "Checking releases";
@@ -1282,34 +1408,81 @@ function renderPlansRail(plans) {
async function renderPlans(plans, openPlanId = null) {
plansDashboardEl.innerHTML = "";
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;
}
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) {
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");
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");
meta.className = "plan-meta";
meta.textContent = plan.objective || "";
const pills = document.createElement("div");
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");
pill.className = "plan-pill";
pill.textContent = value;
pill.textContent = humanizePlanValue(value);
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");
controls.className = "plan-controls";
controls.append(
planButton("Details", () => loadPlanDetail(plan.id, card)),
planButton("Run", () => postPlanAction(plan.id, "run")),
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);
if (openPlanId && plan.id === openPlanId) await loadPlanDetail(plan.id, card);
}
@@ -1330,41 +1503,113 @@ async function loadPlanDetail(planId, card) {
existing.remove();
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 result = await response.json();
const plan = result.plan;
loading.remove();
const detail = document.createElement("div");
detail.className = "plan-detail";
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("Best Candidates", bestCandidateLines(plan)),
planSection("Recent Events", (plan.events || []).slice(0, 5).map((event) => `${formatShortDate(event.created_at)} ${event.kind}: ${event.message}`))
planSection("Checklist", checklistLines(plan), "checklist"),
planSection("Best Candidates", bestCandidateLines(plan), "candidates"),
planSection("Recent Events", recentEventLines(plan), "events")
);
card.appendChild(detail);
}
function planSection(title, lines) {
function planSection(title, lines, sectionClass = "") {
const wrapper = document.createElement("section");
wrapper.className = `plan-section${sectionClass ? ` plan-section-${sectionClass}` : ""}`;
const heading = document.createElement("h4");
heading.textContent = title;
const list = document.createElement("ul");
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) {
const item = document.createElement("li");
item.textContent = line;
if (typeof line === "string") {
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);
}
wrapper.append(heading, list);
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) {
const byItem = new Map((plan.items || []).map((item) => [item.id, item.item_name]));
return (plan.candidates || [])
.filter((candidate) => candidate.status === "current" || candidate.status === "drafted")
.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) {
@@ -1382,6 +1627,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) {
if (!value) return "";
const date = new Date(value);
@@ -1391,10 +1652,20 @@ function formatShortDate(value) {
async function checkHealth() {
try {
const response = await fetch("/api/health");
const result = await response.json();
const health = result.ollama || {};
const provider = health.provider === "openai" ? "OpenAI" : "Ollama";
let health = {};
try {
const response = await fetch("/api/health");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
health = result.inference || result.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 isOpenAIProvider = health.provider === "openai";
const isCodexProvider = health.provider === "codex";
ollamaOnline = Boolean(health.online);
if (!ollamaOnline) {
statusEl.textContent = "Offline";
@@ -1403,7 +1674,7 @@ async function checkHealth() {
return false;
}
if (health.model_available === false) {
const action = health.provider === "openai" ? "Load OpenAI Models." : "Install Model.";
const action = isOpenAIProvider ? "Load Provider Models." : isCodexProvider ? "Sign In to Codex." : "Install Model.";
setWarning(`${provider} needs the configured model "${health.model}". Open the model provider tab and use ${action}`);
ollamaToggle?.classList.add("attention-pulse");
} else {
@@ -1637,6 +1908,13 @@ ollamaPullButton?.addEventListener("click", () => {
markOllamaActionClicked("pull");
postOllamaAction("/api/ollama/pull", { body: { model: configuredOllamaModel() } });
});
codexLoginButton?.addEventListener("click", launchCodexLogin);
providerModelSelect?.addEventListener("change", syncSelectedProviderModel);
document.getElementById("model-provider")?.addEventListener("change", () => {
const provider = document.getElementById("model-provider")?.value || "ollama";
updateProviderFieldVisibility(provider);
renderProviderModelOptions(latestOllamaStatus?.models || [], { ...latestOllamaStatus, provider });
});
openaiModelsRefreshButton?.addEventListener("click", () => {
markOllamaActionClicked("openai-models");
refreshOpenAIModels();
+53 -28
View File
@@ -119,22 +119,27 @@
</div>
<div class="sidebar-panel" id="ollama-panel" hidden>
<div class="section-title-row">
<h2>Model Provider</h2>
<h2>Inference</h2>
<button class="secondary small-button" id="ollama-refresh" type="button">Refresh</button>
</div>
<form class="config-form" id="ollama-config-form">
<label>Provider
<select id="model-provider" name="model_provider">
<option value="ollama">Ollama</option>
<option value="ollama">Local Ollama</option>
<option value="openai">OpenAI</option>
<option value="codex">Codex</option>
</select>
</label>
<label>Ollama URL<input id="ollama-base-url" name="ollama_base_url" type="text"></label>
<label>Ollama Model<input id="ollama-model" name="ollama_model" type="text" list="provider-models"></label>
<label>Context Tokens<input id="ollama-num-ctx" name="ollama_num_ctx" type="number" min="1024" step="1024"></label>
<label>OpenAI URL<input id="openai-base-url" name="openai_base_url" type="text"></label>
<label>OpenAI API Key<input id="openai-api-key" name="openai_api_key" type="password" autocomplete="off"></label>
<label>OpenAI Model<input id="openai-model" name="openai_model" type="text" list="provider-models"></label>
<label data-provider-scope="ollama">Ollama URL<input id="ollama-base-url" name="ollama_base_url" type="text"></label>
<label data-provider-scope="ollama">Ollama Model<input id="ollama-model" name="ollama_model" type="text" list="provider-models"></label>
<label data-provider-scope="ollama">Context Tokens<input id="ollama-num-ctx" name="ollama_num_ctx" type="number" min="1024" step="1024"></label>
<label data-provider-scope="openai">OpenAI URL<input id="openai-base-url" name="openai_base_url" type="text"></label>
<label data-provider-scope="openai">OpenAI API Key<input id="openai-api-key" name="openai_api_key" type="password" autocomplete="off"></label>
<label data-provider-scope="openai">OpenAI Model<input id="openai-model" name="openai_model" type="text" list="provider-models"></label>
<label data-provider-scope="codex">Codex Command<input id="codex-command" name="codex_command" type="text"></label>
<label data-provider-scope="codex">Codex Model<input id="codex-model" name="codex_model" type="text" list="provider-models"></label>
<label>Available Models<select id="provider-model-select"></select></label>
<label>Reasoning Effort<select id="model-reasoning-effort" name="model_reasoning_effort"></select></label>
<datalist id="provider-models"></datalist>
<button type="submit">Save Provider Config</button>
</form>
@@ -144,7 +149,8 @@
<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="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>
<button class="secondary small-button" id="codex-login" type="button">Sign In to Codex</button>
<button class="secondary small-button" id="openai-models-refresh" type="button">Load Provider Models</button>
</div>
<div class="config-status" id="ollama-message"></div>
</div>
@@ -157,10 +163,10 @@
<i data-lucide="brain" aria-hidden="true"></i>
<span>Memory</span>
</button>
<button class="sidebar-tool-button" id="ollama-toggle" type="button" aria-expanded="false" aria-controls="ollama-panel" title="Ollama">
<button class="sidebar-tool-button" id="ollama-toggle" type="button" aria-expanded="false" aria-controls="ollama-panel" title="Inference">
<img class="sidebar-tool-image" src="/static/art/ollama-icon.svg" alt="" onerror="this.remove();">
<i data-lucide="bot" aria-hidden="true"></i>
<span>Ollama</span>
<span>Inference</span>
</button>
</div>
</section>
@@ -199,23 +205,42 @@
</div>
</div>
<div class="plans-panel-body">
<form class="config-form" id="plan-form">
<label>Title<input id="plan-title" type="text" placeholder="Wikelo Idris parts"></label>
<label>Objective<input id="plan-objective" type="text" placeholder="Find and draft deals for the parts I list"></label>
<label>Kind
<select id="plan-kind">
<option value="buying">Buying</option>
<option value="custom">Custom</option>
</select>
</label>
<label>Items<textarea id="plan-items" rows="4" placeholder="One item per line, optionally: name | quantity | max unit price"></textarea></label>
<label>Instructions<textarea id="plan-instructions" rows="3" placeholder="Extra guidance for custom or buying plans"></textarea></label>
<label>Cron Cadence<input id="plan-cadence" type="text" placeholder="0 */6 * * *"></label>
<label>Message Tone<input id="plan-tone" type="text" placeholder="polite and concise"></label>
<button type="submit">Create Plan</button>
<div class="config-status" id="plans-status"></div>
</form>
<div class="plans-dashboard" id="plans-dashboard"></div>
<aside class="plan-creator-shell">
<div class="plan-creator-card">
<div class="plan-creator-copy">
<p class="eyebrow">New continual plan</p>
<h3>Set the watch once</h3>
<p>Spin up buying runs or custom follow-up work with a title, a goal, and just enough guardrails to keep it on track.</p>
</div>
<form class="config-form plan-form-grid" id="plan-form">
<label>Title<input id="plan-title" type="text" placeholder="Wikelo Idris parts"></label>
<label>Objective<input id="plan-objective" type="text" placeholder="Find and draft deals for the parts I list"></label>
<div class="plan-form-split">
<label>Kind
<select id="plan-kind">
<option value="buying">Buying</option>
<option value="custom">Custom</option>
</select>
</label>
<label>Message Tone<input id="plan-tone" type="text" placeholder="polite and concise"></label>
</div>
<label>Items<textarea id="plan-items" rows="5" placeholder="One item per line, optionally: name | quantity | max unit price"></textarea></label>
<label>Instructions<textarea id="plan-instructions" rows="4" placeholder="Extra guidance for custom or buying plans"></textarea></label>
<div class="plan-form-split">
<label>Cron Cadence<input id="plan-cadence" type="text" placeholder="0 */6 * * *"></label>
<div class="plan-form-hint">
<strong>Tip</strong>
<span>Buying plans work best with item lines. Custom plans can run with just instructions.</span>
</div>
</div>
<button type="submit">Create Plan</button>
<div class="config-status" id="plans-status"></div>
</form>
</div>
</aside>
<section class="plans-dashboard-shell">
<div class="plans-dashboard" id="plans-dashboard"></div>
</section>
</div>
</div>
<div class="modal-backdrop" id="update-modal" hidden>
+344 -30
View File
@@ -1039,15 +1039,15 @@ button {
.plans-floating-panel {
grid-template-rows: auto minmax(0, 1fr);
width: min(680px, calc(100vw - 28px));
width: min(980px, calc(100vw - 28px));
}
.plans-panel-body {
display: grid;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
gap: 16px;
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
gap: 20px;
min-height: 0;
padding: 16px;
padding: 20px;
overflow: auto;
}
@@ -1106,10 +1106,8 @@ button.secondary {
flex-direction: column;
gap: 14px;
margin-top: auto;
position: sticky;
bottom: -28px;
padding-bottom: 28px;
background: linear-gradient(180deg, rgba(247, 241, 220, 0) 0%, var(--cream) 22%, var(--cream) 100%);
padding-top: 24px;
background: transparent;
}
.sidebar-tool-buttons {
@@ -1119,6 +1117,12 @@ button.secondary {
width: 100%;
min-width: 0;
gap: 8px;
position: sticky;
bottom: 0;
z-index: 2;
padding-top: 14px;
padding-bottom: 2px;
background: linear-gradient(180deg, var(--ivory) 0%, var(--cream) 100%);
}
.sidebar-tool-button {
@@ -1208,6 +1212,11 @@ button.secondary {
border-bottom: 1px solid var(--line);
}
.sidebar-panel .section-title-row {
position: relative;
z-index: 1;
}
.config-form {
display: grid;
gap: 10px;
@@ -1248,6 +1257,77 @@ button.secondary {
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-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 {
display: grid;
gap: 8px;
@@ -1482,36 +1562,175 @@ pre {
.plans-dashboard {
display: grid;
gap: 12px;
margin-top: 14px;
gap: 14px;
min-height: 0;
}
.plans-overview {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
padding: 6px 2px 10px;
}
.plans-overview h3 {
margin: 4px 0 0;
color: var(--forest);
font-family: "Playfair Display", Georgia, serif;
font-size: 31px;
line-height: 1.04;
}
.plan-overview-copy {
max-width: 48ch;
margin: 8px 0 0;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.plan-overview-stats {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.plan-overview-stat {
display: grid;
gap: 2px;
min-width: 110px;
padding: 12px 14px;
border: 1px solid rgba(212, 175, 55, 0.28);
border-radius: 16px;
background: rgba(255, 250, 240, 0.78);
box-shadow: 0 12px 24px rgba(38, 58, 27, 0.06);
}
.plan-overview-stat-value {
color: var(--forest);
font-size: 24px;
font-weight: 800;
line-height: 1;
}
.plan-overview-stat-label {
color: var(--muted);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.plan-empty-state {
padding: 22px;
border: 1px dashed rgba(52, 83, 38, 0.24);
border-radius: 22px;
background: rgba(255, 253, 247, 0.72);
}
.plan-empty-state h4 {
margin: 0 0 6px;
color: var(--forest);
font-size: 18px;
}
.plan-empty-state p {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.plan-card {
display: grid;
gap: 10px;
padding: 13px;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255, 250, 240, 0.82);
gap: 12px;
padding: 16px;
border: 1px solid rgba(221, 206, 176, 0.92);
border-radius: 20px;
background:
linear-gradient(180deg, rgba(255, 252, 246, 0.96), rgba(251, 244, 223, 0.88));
box-shadow: 0 16px 30px rgba(38, 58, 27, 0.06);
}
.plan-card.active {
border-color: rgba(52, 83, 38, 0.42);
background: #edf3df;
border-color: rgba(52, 83, 38, 0.32);
background:
radial-gradient(circle at top right, rgba(190, 212, 144, 0.22), transparent 26%),
linear-gradient(180deg, rgba(247, 250, 238, 0.98), rgba(237, 243, 223, 0.96));
}
.plan-card-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.plan-card h3 {
margin: 0;
color: var(--forest);
font-size: 16px;
line-height: 1.25;
font-size: 19px;
line-height: 1.18;
}
.plan-status-badge {
flex: 0 0 auto;
padding: 6px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.plan-status-active {
border-color: rgba(52, 83, 38, 0.28);
background: rgba(237, 243, 223, 0.9);
color: var(--forest);
}
.plan-status-badge.plan-status-active {
border: 1px solid rgba(52, 83, 38, 0.24);
}
.plan-status-paused {
border-color: rgba(196, 170, 115, 0.42);
background: rgba(255, 246, 220, 0.86);
color: #7a5a18;
}
.plan-status-badge.plan-status-paused {
border: 1px solid rgba(196, 170, 115, 0.34);
}
.plan-status-needs-input {
border-color: rgba(159, 60, 50, 0.24);
background: rgba(255, 241, 237, 0.88);
color: var(--danger);
}
.plan-status-badge.plan-status-needs-input {
border: 1px solid rgba(159, 60, 50, 0.22);
}
.plan-status-canceled,
.plan-status-completed {
opacity: 0.84;
}
.plan-status-badge.plan-status-canceled,
.plan-status-badge.plan-status-completed {
border: 1px solid rgba(111, 91, 80, 0.18);
background: rgba(255, 250, 240, 0.82);
color: var(--muted);
}
.plan-meta,
.plan-line {
color: var(--muted);
font-size: 12px;
line-height: 1.4;
font-size: 13px;
line-height: 1.55;
overflow-wrap: anywhere;
}
@@ -1525,33 +1744,82 @@ pre {
.plan-pill {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 4px 8px;
border: 1px solid rgba(52, 83, 38, 0.24);
min-height: 26px;
padding: 4px 10px;
border: 1px solid rgba(52, 83, 38, 0.14);
border-radius: 999px;
background: rgba(255, 250, 240, 0.8);
background: rgba(255, 250, 240, 0.88);
color: var(--forest);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.plan-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.plan-metric {
display: grid;
gap: 4px;
padding: 11px 12px;
border: 1px solid rgba(221, 206, 176, 0.78);
border-radius: 14px;
background: rgba(255, 253, 247, 0.76);
}
.plan-metric-label {
color: var(--muted);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.plan-metric-value {
color: var(--brown);
font-size: 13px;
font-weight: 700;
line-height: 1.35;
}
.plan-controls button {
flex: 1 1 80px;
min-width: 0;
}
.plan-detail {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
padding-top: 10px;
border-top: 1px solid rgba(221, 206, 176, 0.92);
}
.plan-detail-loading {
grid-template-columns: 1fr;
padding: 16px;
border: 1px dashed rgba(52, 83, 38, 0.2);
border-radius: 16px;
background: rgba(255, 253, 247, 0.72);
color: var(--muted);
font-size: 13px;
text-align: center;
}
.plan-section {
display: grid;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--line);
}
.plan-detail h4 {
margin: 0;
color: var(--forest);
font-size: 13px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.plan-list {
@@ -1563,15 +1831,29 @@ pre {
}
.plan-list li {
padding: 8px;
display: grid;
gap: 5px;
padding: 10px 11px;
border: 1px solid rgba(221, 206, 176, 0.72);
border-radius: 8px;
background: rgba(255, 253, 247, 0.72);
border-radius: 12px;
background: rgba(255, 253, 247, 0.8);
color: var(--brown);
font-size: 12px;
line-height: 1.4;
}
.plan-list-title {
color: var(--forest);
font-size: 12px;
font-weight: 800;
}
.plan-list-body {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.decline-button {
border: 1px solid var(--line-strong);
background: #fff9e9;
@@ -1751,6 +2033,38 @@ pre {
grid-column: 1;
}
.plans-overview {
flex-direction: column;
align-items: flex-start;
}
.plan-detail {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.plan-form-split,
.plan-metrics {
grid-template-columns: 1fr;
}
.plan-card-heading {
flex-direction: column;
}
.plan-status-badge {
align-self: flex-start;
}
}
@media (max-width: 640px) {
.plans-floating-panel {
width: min(100vw - 18px, 980px);
right: 9px;
bottom: 9px;
}
.plans-panel-body {
grid-template-columns: 1fr;
}