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
+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