feat: deepseek
Build Release EXE / build-windows-exe (release) Successful in 1m2s

This commit is contained in:
2026-06-08 23:41:46 -04:00
parent 00cf6f8747
commit 454bb57484
24 changed files with 1719 additions and 183 deletions
+396 -38
View File
@@ -20,7 +20,8 @@ from traderai.tools import ToolRegistry
from traderai.version import __version__
SYSTEM_PROMPT = """You are TraderAI, a local assistant for UEX marketplace work.
SYSTEM_PROMPT = """You are TraderAI, a sharp Star Citizen marketplace copilot for UEX work.
Sound like a competent player who knows the game and the market. Be natural, direct, and helpful. Avoid corporate filler, robotic phrasing, and meta notes.
Use tools when the user asks about UEX data, open/current listings, active negotiations, unread notifications, messages, offers, or posting ads.
Use continual plan tools when the user asks for multi-day or recurring marketplace work, such as finding several parts, watching for deals, tracking candidates, or coordinating negotiations over time.
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.
@@ -29,11 +30,13 @@ When the user asks for history, trends, changes over time, or past prices, prefe
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 Wikelo ship project tools when the user asks for Wikelo ship requirements, Wikelo build materials, or what items are needed for a Wikelo ship project.
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.
Treat UEX marketplace prices as in-game aUEC/UEC credits, never real-world dollars, unless the user explicitly says otherwise.
For marketplace writes, draft the exact pending action and tell the user what will be sent; never claim it was sent until approval succeeds.
When drafting negotiation messages or marketplace replies, write like a real player would. Keep messages human, concise, and purposeful. Never include internal notes like "Tone note".
For continual plans, never invent an unknown parts checklist. If the required items cannot be derived from provided details or tools, create the plan in a needs-input state and say what item list is missing.
When a scheduled wake job fires, always write a concise Inbox-ready result that says what you checked, the key findings, and the suggested next action.
Keep prices, listing ids, slugs, users, and UEX status codes precise. If data is missing, say what you need next."""
@@ -64,7 +67,7 @@ class OllamaAgent:
self.thread_messages: dict[str, list[dict[str, Any]]] = {}
async def health(self) -> dict[str, Any]:
if self.provider == "openai":
if self._is_openai_compatible_provider():
return await self._openai_health()
if self.provider == "codex":
return await self._codex_health()
@@ -119,7 +122,7 @@ class OllamaAgent:
last_tool_results: list[dict[str, Any]] = []
image_scope = self.tools.chat_image_scope(normalized_images) if hasattr(self.tools, "chat_image_scope") else nullcontext()
with image_scope:
for _ in range(10):
for _ in self._tool_rounds():
try:
response = await self._chat_once(
prompt_text,
@@ -155,7 +158,7 @@ class OllamaAgent:
result = await self.tools.execute(name, arguments)
last_tool_results.append({"tool": name, "result": result})
messages.append({"role": "tool", "tool_name": name, "tool_call_id": call.get("id"), "content": json.dumps(result)})
fallback = "I hit the tool-call limit while working on that. Try narrowing the request or approve any pending action first."
fallback = self._tool_round_limit_message()
messages.append({"role": "assistant", "content": fallback})
if self.memory:
self.memory.add_conversation("assistant", fallback, resolved_thread_id)
@@ -187,7 +190,7 @@ class OllamaAgent:
last_tool_results: list[dict[str, Any]] = []
image_scope = self.tools.chat_image_scope(normalized_images) if hasattr(self.tools, "chat_image_scope") else nullcontext()
with image_scope:
for _ in range(10):
for _ in self._tool_rounds():
assistant_message: dict[str, Any] = {"role": "assistant", "content": ""}
tool_calls: list[dict[str, Any]] = []
@@ -198,7 +201,15 @@ class OllamaAgent:
previous_interaction=previous_interaction,
thread_id=resolved_thread_id,
):
if event.get("type") == "reasoning":
reasoning_chunk = event.get("content") or ""
if reasoning_chunk:
assistant_message["reasoning_content"] = assistant_message.get("reasoning_content", "") + reasoning_chunk
yield {"type": "reasoning", "content": reasoning_chunk}
continue
message = event.get("message") or {}
if message.get("reasoning_content"):
assistant_message["reasoning_content"] = message.get("reasoning_content")
chunk = message.get("content") or ""
if chunk:
assistant_message["content"] += chunk
@@ -247,7 +258,7 @@ class OllamaAgent:
messages.append({"role": "tool", "tool_name": name, "tool_call_id": call.get("id"), "content": json.dumps(result)})
yield {"type": "status", "message": "Writing response"}
fallback = "I hit the tool-call limit while working on that. Try narrowing the request or approve any pending action first."
fallback = self._tool_round_limit_message()
messages.append({"role": "assistant", "content": fallback})
if self.memory:
self.memory.add_conversation("assistant", fallback, resolved_thread_id)
@@ -260,7 +271,7 @@ class OllamaAgent:
previous_interaction = self.memory.last_interaction("wake") if self.memory else None
messages.append({"role": "user", "content": wake_message})
last_tool_results: list[dict[str, Any]] = []
for _ in range(10):
for _ in self._tool_rounds():
try:
response = await self._chat_once(
wake_message,
@@ -298,7 +309,7 @@ class OllamaAgent:
result = await self.tools.execute(name, arguments)
last_tool_results.append({"tool": name, "result": result})
messages.append({"role": "tool", "tool_name": name, "tool_call_id": call.get("id"), "content": json.dumps(result)})
content = "I hit the tool-call limit while running this scheduled wake job. Check the job prompt or pending approvals."
content = self._wake_tool_round_limit_message()
messages.append({"role": "assistant", "content": content})
if self.memory:
self.memory.add_conversation("system", wake_message, "wake")
@@ -312,7 +323,7 @@ class OllamaAgent:
previous_interaction: dict[str, Any] | None = None,
thread_id: str | None = DEFAULT_THREAD_ID,
) -> dict[str, Any]:
if self.provider == "openai":
if self._is_openai_compatible_provider():
return await self._openai_chat(
query,
messages,
@@ -340,7 +351,7 @@ class OllamaAgent:
previous_interaction: dict[str, Any] | None = None,
thread_id: str | None = DEFAULT_THREAD_ID,
) -> AsyncIterator[dict[str, Any]]:
if self.provider == "openai":
if self._is_openai_compatible_provider():
async for event in self._openai_chat_stream(
query,
messages,
@@ -441,8 +452,8 @@ class OllamaAgent:
thread_id=thread_id,
),
"tools": self.tools.schemas,
"reasoning_effort": self.reasoning_effort,
"stream": False,
**self._openai_request_options(stream=False),
},
)
response.raise_for_status()
@@ -452,6 +463,7 @@ class OllamaAgent:
return {
"message": {
"role": message.get("role", "assistant"),
"reasoning_content": message.get("reasoning_content") or "",
"content": message.get("content") or "",
"tool_calls": message.get("tool_calls") or [],
}
@@ -479,8 +491,8 @@ class OllamaAgent:
thread_id=thread_id,
),
"tools": self.tools.schemas,
"reasoning_effort": self.reasoning_effort,
"stream": True,
**self._openai_request_options(stream=True),
},
) as response:
response.raise_for_status()
@@ -493,8 +505,15 @@ class OllamaAgent:
if payload == "[DONE]":
break
event = json.loads(payload)
if event.get("usage"):
metrics = self._cloud_usage_metrics(event["usage"])
if metrics:
yield {"type": "metrics", **metrics}
choice = (event.get("choices") or [{}])[0]
delta = choice.get("delta") or {}
reasoning_content = delta.get("reasoning_content") or ""
if reasoning_content:
yield {"type": "reasoning", "content": reasoning_content}
content = delta.get("content") or ""
if content:
yield {"message": {"role": "assistant", "content": content}}
@@ -502,9 +521,11 @@ class OllamaAgent:
self._merge_openai_tool_call(tool_calls, tool_call)
finish_reason = choice.get("finish_reason")
if finish_reason:
message = choice.get("message") or {}
yield {
"message": {
"role": "assistant",
"reasoning_content": message.get("reasoning_content") or "",
"content": "",
"tool_calls": self._ordered_tool_calls(tool_calls),
},
@@ -574,18 +595,19 @@ class OllamaAgent:
continue
attached_image_count = len(message.get("images") or [])
break
context = self._runtime_context(
stable_context, volatile_context = self._runtime_context_parts(
query,
previous_interaction=previous_interaction,
thread_id=thread_id,
attached_image_count=attached_image_count,
)
if not context:
contexts = [part for part in ("\n".join(stable_context), "\n".join(volatile_context)) if part]
if not contexts:
return messages
return [messages[0], {"role": "system", "content": context}, *messages[1:]]
return [messages[0], *({"role": "system", "content": context} for context in contexts), *messages[1:]]
async def _openai_health(self) -> dict[str, Any]:
return await self._cloud_health("openai")
return await self._cloud_health(self.provider if self._is_openai_compatible_provider() else "openai")
async def _codex_health(self) -> dict[str, Any]:
command = self._codex_command()
@@ -671,6 +693,19 @@ class OllamaAgent:
"Content-Type": "application/json",
}
def _openai_request_options(self, stream: bool) -> dict[str, Any]:
if self.provider == "deepseek":
options: dict[str, Any] = {}
if self.reasoning_effort in {"none", "minimal"}:
options["thinking"] = {"type": "disabled"}
else:
options["thinking"] = {"type": "enabled"}
options["reasoning_effort"] = "max" if self.reasoning_effort in {"xhigh", "max"} else "high"
if stream:
options["stream_options"] = {"include_usage": True}
return options
return {"reasoning_effort": self.reasoning_effort}
def _openai_messages(
self,
query: str,
@@ -706,6 +741,8 @@ class OllamaAgent:
entry["content"] = content_parts
if role == "assistant" and message.get("tool_calls"):
entry["tool_calls"] = message["tool_calls"]
if role == "assistant" and message.get("reasoning_content"):
entry["reasoning_content"] = message["reasoning_content"]
if role == "tool":
entry["tool_call_id"] = message.get("tool_call_id") or message.get("tool_name") or "tool"
normalized.append(entry)
@@ -729,10 +766,29 @@ class OllamaAgent:
def _provider_label(self) -> str:
if self.provider == "openai":
return "OpenAI model"
if self.provider == "deepseek":
return "DeepSeek model"
if self.provider == "codex":
return "Codex model"
return "local model"
def _is_openai_compatible_provider(self) -> bool:
return self.provider in {"openai", "deepseek"}
def _tool_rounds(self):
if self.provider == "deepseek":
while True:
yield None
return
for _ in range(10):
yield None
def _tool_round_limit_message(self) -> str:
return "I hit the tool-call limit while working on that. Try narrowing the request or approve any pending action first."
def _wake_tool_round_limit_message(self) -> str:
return "I hit the tool-call limit while running this scheduled wake job. Check the job prompt or pending approvals."
@staticmethod
def _merge_openai_tool_call(target: dict[int, dict[str, Any]], delta: dict[str, Any]) -> None:
index = int(delta.get("index") or 0)
@@ -1175,20 +1231,21 @@ class OllamaAgent:
models.append(slug)
return sorted(set(models))
def _runtime_context(
def _runtime_context_parts(
self,
query: str,
previous_interaction: dict[str, Any] | None = None,
thread_id: str | None = DEFAULT_THREAD_ID,
attached_image_count: int = 0,
) -> str:
) -> tuple[list[str], list[str]]:
local_zone = get_localzone()
parts = [
stable_parts: list[str] = []
volatile_parts = [
f"Current local date/time: {iso_now()} UTC; {iso_now_in_zone(local_zone)} {local_zone}.",
]
if attached_image_count:
label = "image" if attached_image_count == 1 else "images"
parts.append(
volatile_parts.append(
f"Current user message includes {attached_image_count} pasted {label}. "
"You can inspect them visually. If the user wants one reused in a marketplace listing draft, "
"call draft_marketplace_listing or draft_marketplace_listing_with_cornerstone_image with "
@@ -1202,34 +1259,34 @@ class OllamaAgent:
if uex.bearer_token:
auth_methods.append("bearer token")
if auth_methods:
parts.append(
stable_parts.append(
"UEX API authentication is configured server-side with "
+ " and ".join(auth_methods)
+ "; use authenticated UEX tools directly and do not ask for tokens."
)
else:
parts.append("UEX API authentication is not configured server-side.")
stable_parts.append("UEX API authentication is not configured server-side.")
if self.user_name:
parts.append(f"Known user name/handle: {self.user_name}.")
stable_parts.append(f"Known user name/handle: {self.user_name}.")
if self.memory is None:
return "\n".join(parts)
return stable_parts, volatile_parts
profile = self.memory.get_profile()
if profile:
identity = self._profile_identity(profile)
if identity:
parts.append(identity)
parts.append(f"Known user profile JSON: {json.dumps(self._profile_for_prompt(profile), ensure_ascii=True)}.")
stable_parts.append(identity)
stable_parts.append(f"Known user profile JSON: {json.dumps(self._profile_for_prompt(profile), ensure_ascii=True)}.")
last = previous_interaction if previous_interaction is not None else self.memory.last_interaction(thread_id)
if last:
parts.append(
volatile_parts.append(
f"Previous interaction before this message: {last['created_at']} "
f"({time_since(last['created_at'])}, role {last['role']})."
)
else:
parts.append("Previous interaction before this message: none recorded.")
volatile_parts.append("Previous interaction before this message: none recorded.")
memories = self.memory.recall(query, limit=6)
if memories:
@@ -1237,17 +1294,24 @@ class OllamaAgent:
f"- [{item['kind']}, importance {item['importance']}] {item['content']}"
for item in memories
)
parts.append(f"Relevant long-term memories:\n{memory_text}")
volatile_parts.append(f"Relevant long-term memories:\n{memory_text}")
recent = self.memory.recent_conversation(limit=6, thread_id=thread_id)
if recent:
recent_text = "\n".join(
f"- {item['created_at']} {item['role']}: {item['content'][:500]}"
for item in recent
)
parts.append(f"Recent conversation excerpts from this chat:\n{recent_text}")
return stable_parts, volatile_parts
return "\n".join(parts)
def _runtime_context(
self,
query: str,
previous_interaction: dict[str, Any] | None = None,
thread_id: str | None = DEFAULT_THREAD_ID,
attached_image_count: int = 0,
) -> str:
stable_parts, volatile_parts = self._runtime_context_parts(
query,
previous_interaction=previous_interaction,
thread_id=thread_id,
attached_image_count=attached_image_count,
)
return "\n".join(part for part in ("\n".join(stable_parts), "\n".join(volatile_parts)) if part)
def _messages_for_thread(self, thread_id: str | None) -> list[dict[str, Any]]:
resolved_thread_id = self._thread_id(thread_id)
@@ -1283,7 +1347,7 @@ class OllamaAgent:
f"Message: {first_message[:800]}"
)
try:
if self.provider == "openai":
if self._is_openai_compatible_provider():
async with httpx.AsyncClient(timeout=20) as client:
response = await client.post(
f"{self.base_url}/chat/completions",
@@ -1295,6 +1359,7 @@ class OllamaAgent:
{"role": "user", "content": prompt},
],
"stream": False,
**self._openai_request_options(stream=False),
},
)
response.raise_for_status()
@@ -1330,6 +1395,34 @@ class OllamaAgent:
except Exception:
return ""
async def generate_plan_draft(
self,
title: str = "",
objective: str = "",
kind: str = "buying",
constraints: dict[str, Any] | None = None,
items: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
clean_title = str(title or "").strip()
clean_objective = str(objective or "").strip()
clean_kind = str(kind or "buying").strip().casefold() or "buying"
clean_constraints = dict(constraints or {})
clean_items = self._normalize_plan_items(items or [])
seed = {
"title": clean_title,
"objective": clean_objective,
"kind": clean_kind,
"constraints": clean_constraints,
"items": clean_items,
}
prompt = self._plan_draft_prompt(seed)
fallback = self._heuristic_plan_draft(seed)
try:
payload = await self._generate_plain_text(prompt, system_prompt="You draft structured continual plan JSON for TraderAI.")
return self._normalize_plan_draft(payload, seed, fallback)
except Exception:
return fallback
@staticmethod
def _thread_id(thread_id: str | None) -> str:
return (thread_id or DEFAULT_THREAD_ID).strip() or DEFAULT_THREAD_ID
@@ -1346,6 +1439,254 @@ class OllamaAgent:
text = " ".join(words[:8])
return text[:64]
async def _generate_plain_text(self, prompt: str, system_prompt: str) -> str:
if self._is_openai_compatible_provider():
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
f"{self.base_url}/chat/completions",
headers=self._openai_headers(),
json={
"model": self.model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
],
"stream": False,
**self._openai_request_options(stream=False),
},
)
response.raise_for_status()
choice = (response.json().get("choices") or [{}])[0]
message = choice.get("message") or {}
return str(message.get("content") or "")
if self.provider == "codex":
result = await self._codex_app_server_turn(
prompt,
[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
],
thread_id="plan-draft",
)
return str(result.get("message") or "")
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
f"{self.base_url}/api/chat",
json={
"model": self.model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
],
"options": self._ollama_options(),
"stream": False,
},
)
response.raise_for_status()
message = response.json().get("message") or {}
return str(message.get("content") or "")
@classmethod
def _normalize_plan_draft(
cls,
raw_text: str,
seed: dict[str, Any],
fallback: dict[str, Any] | None = None,
) -> dict[str, Any]:
base = dict(fallback or cls._heuristic_plan_draft(seed))
payload = cls._parse_json_object(raw_text)
if not isinstance(payload, dict):
return base
title = str(payload.get("title") or seed.get("title") or base.get("title") or "").strip()
objective = str(payload.get("objective") or seed.get("objective") or base.get("objective") or "").strip()
kind = str(payload.get("kind") or seed.get("kind") or base.get("kind") or "buying").strip().casefold() or "buying"
cadence = cls._normalize_plan_cadence(payload.get("cadence")) or base.get("cadence")
constraints = cls._normalize_plan_constraints(payload.get("constraints"), seed.get("constraints") or {}, base.get("constraints") or {})
items = cls._normalize_plan_items(payload.get("items") or seed.get("items") or base.get("items") or [])
if kind == "buying" and not items:
items = list(base.get("items") or [])
if not constraints.get("instructions"):
constraints["instructions"] = (base.get("constraints") or {}).get("instructions") or cls._default_plan_instructions(kind)
if not constraints.get("message_tone"):
constraints["message_tone"] = (base.get("constraints") or {}).get("message_tone") or "friendly and direct"
return {
"title": title or base.get("title") or "Continual plan",
"objective": objective or base.get("objective") or title or "Continue this plan",
"kind": kind,
"cadence": cadence,
"constraints": constraints,
"items": items,
}
@classmethod
def _heuristic_plan_draft(cls, seed: dict[str, Any]) -> dict[str, Any]:
title = str(seed.get("title") or "").strip()
objective = str(seed.get("objective") or "").strip()
kind = str(seed.get("kind") or "buying").strip().casefold() or "buying"
constraints = cls._normalize_plan_constraints(seed.get("constraints"), {}, {})
items = cls._normalize_plan_items(seed.get("items") or [])
if not items and kind == "buying":
inferred_names = cls._infer_item_names(f"{title}\n{objective}")
items = [{"item_name": name, "desired_quantity": 1, "max_unit_price": None} for name in inferred_names[:8]]
if not constraints.get("message_tone"):
constraints["message_tone"] = "friendly and direct"
if not constraints.get("instructions"):
constraints["instructions"] = cls._default_plan_instructions(kind)
return {
"title": title or "Continual plan",
"objective": objective or title or "Continue this plan",
"kind": kind,
"cadence": cls._normalize_plan_cadence(seed.get("cadence")) or ("0 */6 * * *" if kind == "buying" else "0 */4 * * *"),
"constraints": constraints,
"items": items,
}
@staticmethod
def _default_plan_instructions(kind: str) -> str:
if kind == "custom":
return "Check for meaningful updates, summarize what changed, and suggest the next move."
return "Track the best active listings, avoid bad prices, and draft messages for approval when a strong candidate appears."
@staticmethod
def _plan_draft_prompt(seed: dict[str, Any]) -> str:
return (
"Draft a continual TraderAI plan as strict JSON.\n"
"Return one JSON object only with keys: title, objective, kind, cadence, constraints, items.\n"
"constraints may include message_tone, instructions, preferred_locations, excluded_sellers, max_unit_price.\n"
"items must be an array of objects with item_name, desired_quantity, max_unit_price.\n"
"If the request is vague, still fill cadence, message_tone, and instructions.\n"
"Only include checklist items when they can be reasonably inferred from the request or existing draft.\n"
"Do not wrap the JSON in markdown.\n\n"
f"Current draft seed: {json.dumps(seed, ensure_ascii=True)}"
)
@staticmethod
def _parse_json_object(raw_text: str) -> dict[str, Any] | None:
text = str(raw_text or "").strip()
if not text:
return None
try:
parsed = json.loads(text)
return parsed if isinstance(parsed, dict) else None
except ValueError:
pass
start = text.find("{")
end = text.rfind("}")
if start == -1 or end <= start:
return None
try:
parsed = json.loads(text[start : end + 1])
return parsed if isinstance(parsed, dict) else None
except ValueError:
return None
@classmethod
def _normalize_plan_constraints(cls, value: Any, seed: dict[str, Any], fallback: dict[str, Any]) -> dict[str, Any]:
merged: dict[str, Any] = {}
for source in (fallback, seed, value if isinstance(value, dict) else {}):
if not isinstance(source, dict):
continue
for key, item in source.items():
if item in (None, "", [], {}):
continue
if key in {"preferred_locations", "excluded_sellers"}:
if isinstance(item, list):
merged[key] = [str(entry).strip() for entry in item if str(entry).strip()]
elif key == "max_unit_price":
try:
merged[key] = float(item)
except (TypeError, ValueError):
continue
else:
merged[key] = str(item).strip()
return merged
@staticmethod
def _normalize_plan_cadence(value: Any) -> str | None:
text = str(value or "").strip()
if not text:
return None
parts = text.split()
return text if len(parts) == 5 else None
@classmethod
def _normalize_plan_items(cls, items: Any) -> list[dict[str, Any]]:
if not isinstance(items, list):
return []
normalized: list[dict[str, Any]] = []
for item in items:
if not isinstance(item, dict):
continue
name = str(item.get("item_name") or item.get("name") or "").strip()
if not name:
continue
normalized_item: dict[str, Any] = {"item_name": name}
try:
normalized_item["desired_quantity"] = max(1, int(item.get("desired_quantity") or item.get("quantity") or 1))
except (TypeError, ValueError):
normalized_item["desired_quantity"] = 1
try:
if item.get("max_unit_price") not in (None, ""):
normalized_item["max_unit_price"] = float(item.get("max_unit_price"))
elif item.get("max_price") not in (None, ""):
normalized_item["max_unit_price"] = float(item.get("max_price"))
else:
normalized_item["max_unit_price"] = None
except (TypeError, ValueError):
normalized_item["max_unit_price"] = None
normalized.append(normalized_item)
return normalized
@staticmethod
def _infer_item_names(text: str) -> list[str]:
source = str(text or "")
quoted = [match.strip() for match in re.findall(r'"([^"\n]{2,80})"|\'([^\'\n]{2,80})\'', source)]
names = [next((part for part in group if part), "") for group in quoted]
if names:
return [name for name in names if name]
lines = []
for raw_line in source.splitlines():
line = raw_line.strip(" -*\t")
if not line:
continue
if any(token in line for token in [",", ";", "/"]):
parts = re.split(r"[,;/]+", line)
lines.extend(part.strip() for part in parts if part.strip())
else:
lines.append(line)
stopwords = {
"need", "needs", "want", "wants", "find", "draft", "deal", "deals", "parts", "part", "items",
"watch", "track", "check", "buy", "buying", "for", "the", "and", "with", "from", "best", "cheapest",
}
inferred = []
for line in lines:
clean = re.sub(r"\s+", " ", line).strip().strip(".")
if len(clean) < 3:
continue
lowered = clean.casefold()
if lowered in stopwords:
continue
if any(phrase in lowered for phrase in ["find and draft", "check for", "continue this plan"]):
continue
inferred.append(clean[:120])
deduped = []
seen = set()
for item in inferred:
key = item.casefold()
if key in seen:
continue
seen.add(key)
deduped.append(item)
return deduped
def _pending_payloads(self) -> list[dict[str, Any]]:
return [
{
@@ -1405,6 +1746,8 @@ class OllamaAgent:
"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_wikelo_ship_projects": "Searching Wikelo ship projects",
"get_wikelo_ship_project": "Fetching Wikelo ship requirements",
"search_cornerstone_items": "Searching Cornerstone items",
"get_cornerstone_item_locations": "Fetching Cornerstone item locations",
"get_cornerstone_item_media": "Fetching Cornerstone item media",
@@ -1442,6 +1785,21 @@ class OllamaAgent:
"writing_tokens_per_second": rate(output_tokens, output_duration),
}
@staticmethod
def _cloud_usage_metrics(usage: dict[str, Any]) -> dict[str, Any]:
prompt_tokens = int(usage.get("prompt_tokens") or 0)
completion_tokens = int(usage.get("completion_tokens") or 0)
cache_hit_tokens = int(usage.get("prompt_cache_hit_tokens") or 0)
cache_miss_tokens = int(usage.get("prompt_cache_miss_tokens") or 0)
metrics = {
"reading_tokens": prompt_tokens,
"writing_tokens": completion_tokens,
}
if cache_hit_tokens or cache_miss_tokens:
metrics["cache_hit_tokens"] = cache_hit_tokens
metrics["cache_miss_tokens"] = cache_miss_tokens
return metrics
@staticmethod
def _profile_identity(profile: dict[str, Any]) -> str:
user = profile.get("uex_user")
+11 -5
View File
@@ -17,6 +17,8 @@ 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},
"deepseek_base_url": {"env": "DEEPSEEK_BASE_URL", "type": "string", "secret": False},
"deepseek_model": {"env": "DEEPSEEK_MODEL", "type": "string", "secret": False},
"model_reasoning_effort": {"env": "MODEL_REASONING_EFFORT", "type": "string", "secret": False},
"codex_command": {"env": "CODEX_COMMAND", "type": "string", "secret": False},
"codex_model": {"env": "CODEX_MODEL", "type": "string", "secret": False},
@@ -26,6 +28,7 @@ CONFIG_FIELDS: dict[str, dict[str, Any]] = {
"scwiki_base_url": {"env": "SCWIKI_BASE_URL", "type": "string", "secret": False},
"scwiki_api_base_url": {"env": "SCWIKI_API_BASE_URL", "type": "string", "secret": False},
"openai_api_key": {"env": "OPENAI_API_KEY", "type": "string", "secret": True},
"deepseek_api_key": {"env": "DEEPSEEK_API_KEY", "type": "string", "secret": True},
"uex_secret_key": {"env": "UEX_SECRET_KEY", "type": "string", "secret": True},
"uex_bearer_token": {"env": "UEX_BEARER_TOKEN", "type": "string", "secret": True},
"traderai_user_name": {"env": "TRADERAI_USER_NAME", "type": "string", "secret": False},
@@ -77,6 +80,8 @@ class Settings(BaseSettings):
ollama_num_ctx: int = 64512
openai_base_url: str = "https://api.openai.com/v1"
openai_model: str = "gpt-5.4-mini"
deepseek_base_url: str = "https://api.deepseek.com"
deepseek_model: str = "deepseek-v4-flash"
model_reasoning_effort: str = "medium"
codex_command: str = "codex"
codex_model: str = "gpt-5.4"
@@ -86,14 +91,15 @@ class Settings(BaseSettings):
scwiki_base_url: str = "https://starcitizen.tools"
scwiki_api_base_url: str = "https://api.star-citizen.wiki"
openai_api_key: str | None = Field(default=None)
deepseek_api_key: str | None = Field(default=None)
uex_secret_key: str | None = Field(default=None)
uex_bearer_token: str | None = Field(default=None)
traderai_user_name: str | None = Field(default=None)
traderai_memory_path: str = Field(default_factory=lambda: str(default_memory_path()))
uex_notification_poll_seconds: int = 60
uex_notification_poll_seconds: int = 300
require_write_approval: bool = True
@field_validator("openai_api_key", "uex_secret_key", "uex_bearer_token", "traderai_user_name", mode="before")
@field_validator("openai_api_key", "deepseek_api_key", "uex_secret_key", "uex_bearer_token", "traderai_user_name", mode="before")
@classmethod
def _blank_optional(cls, value: Any) -> Any:
return None if value == "" else value
@@ -102,13 +108,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", "codex"} else "ollama"
return text if text in {"ollama", "deepseek"} else "ollama"
@field_validator("model_reasoning_effort", mode="before")
@classmethod
def _normalize_reasoning_effort(cls, value: Any) -> str:
text = str(value or "medium").strip().casefold()
return text if text in {"none", "minimal", "low", "medium", "high", "xhigh"} else "medium"
return text if text in {"none", "minimal", "low", "medium", "high", "xhigh", "max"} else "medium"
@field_validator("traderai_memory_path", mode="before")
@classmethod
@@ -167,7 +173,7 @@ def save_settings(values: dict[str, Any]) -> dict[str, Any]:
def _coerce_value(key: str, value: Any) -> Any:
field_type = CONFIG_FIELDS[key]["type"]
if value == "":
return None if key in {"openai_api_key", "uex_secret_key", "uex_bearer_token", "traderai_user_name"} else ""
return None if key in {"openai_api_key", "deepseek_api_key", "uex_secret_key", "uex_bearer_token", "traderai_user_name"} else ""
if field_type == "integer":
return int(value)
if field_type == "boolean":
+1 -1
View File
@@ -55,7 +55,7 @@ def _plural(value: int, unit: str) -> str:
class MemoryStore:
def __init__(self, path: str) -> None:
self.path = Path(path)
self.path = Path(path).expanduser().resolve()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
+18 -5
View File
@@ -528,12 +528,13 @@ class ContinualPlanRunner:
async def _draft_buying_message(self, plan: dict[str, Any], item: dict[str, Any], candidate: dict[str, Any]) -> dict[str, Any]:
tone = (plan.get("constraints") or {}).get("message_tone") or "polite and concise"
greeting = "Hi" if "professional" in str(tone).casefold() or "polite" in str(tone).casefold() else "Hey"
build_context = self._plan_build_context(plan["objective"])
message = (
f"Hi, I am interested in your {candidate.get('title') or item['item_name']} listing "
f"for {self._format_price(candidate.get('price'), candidate.get('currency'))}. "
f"Is it still available? I am trying to complete: {plan['objective']}. "
f"Tone note: {tone}."
)
f"{greeting}, is your {candidate.get('title') or item['item_name']} listing still available "
f"for {self._format_price(candidate.get('price'), candidate.get('currency'))}? "
f"{build_context}If you still have it, I can move quickly."
).strip()
return await self.tools.draft_negotiation_message(
message=message,
id_listing=self._int_or_none(candidate.get("listing_id")),
@@ -543,6 +544,18 @@ class ContinualPlanRunner:
listing_slug=candidate.get("listing_slug"),
)
@staticmethod
def _plan_build_context(objective: str) -> str:
text = str(objective or "").strip().rstrip(".")
if not text:
return ""
lowered = text.casefold()
if "polaris" in lowered:
return "I'm putting together parts for a Polaris build. "
if "mission" in lowered:
return "I'm trying to wrap up a mission build. "
return "I'm sourcing parts for a build. "
@staticmethod
def _candidate_score(listing: dict[str, Any], item: dict[str, Any], preferred_locations: list[str]) -> float:
price = float(listing.get("price") or 10**12)
+68 -5
View File
@@ -31,6 +31,7 @@ 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__
from traderai.wikelo_projects_client import WikeloProjectsClient
def resource_path(*parts: str) -> Path:
@@ -85,6 +86,14 @@ class ContinualPlanCreateRequest(BaseModel):
items: list[ContinualPlanItemRequest] = []
class ContinualPlanDraftRequest(BaseModel):
title: str = ""
objective: str = ""
kind: str = "buying"
constraints: dict[str, Any] = {}
items: list[ContinualPlanItemRequest] = []
class ContinualPlanEventRequest(BaseModel):
kind: str = "note"
message: str
@@ -115,6 +124,7 @@ def create_app() -> FastAPI:
scmdb = SCMDBClient(current_settings.scmdb_base_url)
cornerstone = CornerstoneClient(current_settings.cornerstone_base_url)
scwiki = StarCitizenWikiClient(current_settings.scwiki_base_url, current_settings.scwiki_api_base_url)
wikelo = WikeloProjectsClient()
tools = ToolRegistry(
uex,
current_settings.require_write_approval,
@@ -123,6 +133,7 @@ def create_app() -> FastAPI:
scmdb=scmdb,
cornerstone=cornerstone,
scwiki=scwiki,
wikelo=wikelo,
plan_store=plan_store,
)
plan_runner = ContinualPlanRunner(plan_store, tools, memory)
@@ -493,6 +504,18 @@ def create_app() -> FastAPI:
raise HTTPException(status_code=400, detail=result["error"])
return result
@app.post("/api/plans/draft")
async def draft_continual_plan(request: ContinualPlanDraftRequest) -> dict:
agent = runtime["agent"]
draft = await agent.generate_plan_draft(
title=request.title,
objective=request.objective,
kind=request.kind,
constraints=request.constraints,
items=[item.model_dump() for item in request.items],
)
return {"draft": draft}
@app.get("/api/plans/{plan_id}")
async def continual_plan(plan_id: str) -> dict:
plan = plan_store.get_plan(plan_id)
@@ -592,6 +615,8 @@ async def inspect_model_provider() -> dict[str, Any]:
settings = get_settings()
if settings.model_provider == "openai":
return await inspect_openai()
if settings.model_provider == "deepseek":
return await inspect_deepseek()
if settings.model_provider == "codex":
return await inspect_codex()
return await inspect_ollama()
@@ -602,6 +627,16 @@ async def inspect_openai() -> dict[str, Any]:
return await inspect_cloud_provider_config("openai", settings.openai_base_url, settings.openai_api_key, settings.openai_model)
async def inspect_deepseek() -> dict[str, Any]:
settings = get_settings()
return await inspect_cloud_provider_config(
"deepseek",
settings.deepseek_base_url,
settings.deepseek_api_key,
settings.deepseek_model,
)
async def inspect_codex() -> dict[str, Any]:
settings = get_settings()
command = find_codex_cli(settings.codex_command)
@@ -638,6 +673,8 @@ async def inspect_cloud_provider() -> dict[str, Any]:
settings = get_settings()
if settings.model_provider == "codex":
return await inspect_codex()
if settings.model_provider == "deepseek":
return await inspect_deepseek()
return await inspect_openai()
@@ -647,6 +684,8 @@ async def inspect_provider_models(provider: str | None = None) -> dict[str, Any]
return await inspect_codex()
if normalized == "ollama":
return await inspect_ollama()
if normalized == "deepseek":
return await inspect_deepseek()
return await inspect_openai()
@@ -669,8 +708,8 @@ async def inspect_cloud_provider_config(
"provider": provider,
"model_available": False,
"configured_model": model,
"configured_reasoning_effort": settings.model_reasoning_effort,
"reasoning_efforts": reasoning_effort_options(),
"configured_reasoning_effort": canonical_provider_reasoning_effort(provider, settings.model_reasoning_effort),
"reasoning_efforts": provider_reasoning_efforts(provider, model),
"base_url": base_url,
"models": [],
"message": f"{provider_name} is selected, but no API key is configured.",
@@ -698,8 +737,8 @@ async def inspect_cloud_provider_config(
"provider": provider,
"model_available": model_available,
"configured_model": model,
"configured_reasoning_effort": settings.model_reasoning_effort,
"reasoning_efforts": reasoning_effort_options(),
"configured_reasoning_effort": canonical_provider_reasoning_effort(provider, settings.model_reasoning_effort),
"reasoning_efforts": provider_reasoning_efforts(provider, model),
"base_url": base_url,
"models": models,
"message": cloud_status_message(provider, online, bool(api_key), model_available, model),
@@ -783,13 +822,15 @@ def codex_status_message(installed: bool, logged_in: bool, model_available: bool
def provider_settings(settings: Any) -> tuple[str, str, str | None]:
if settings.model_provider == "openai":
return settings.openai_base_url, settings.openai_model, settings.openai_api_key
if settings.model_provider == "deepseek":
return settings.deepseek_base_url, settings.deepseek_model, settings.deepseek_api_key
if settings.model_provider == "codex":
return settings.codex_command, settings.codex_model, None
return settings.ollama_base_url, settings.ollama_model, None
def provider_display_name(provider: str) -> str:
return {"openai": "OpenAI", "codex": "Codex"}.get(provider, "Ollama")
return {"openai": "OpenAI", "deepseek": "DeepSeek", "codex": "Codex"}.get(provider, "Ollama")
def find_codex_cli(configured_command: str | None = None) -> Path | None:
@@ -1056,6 +1097,28 @@ def reasoning_effort_options() -> list[str]:
return ["none", "minimal", "low", "medium", "high", "xhigh"]
def deepseek_reasoning_efforts(model: str) -> list[str]:
supported_models = {"deepseek-v4-flash", "deepseek-v4-pro", "deepseek-chat", "deepseek-reasoner"}
return ["none", "high", "max"] if model in supported_models else ["none", "high"]
def provider_reasoning_efforts(provider: str, model: str) -> list[str]:
if provider == "deepseek":
return deepseek_reasoning_efforts(model)
return reasoning_effort_options()
def canonical_provider_reasoning_effort(provider: str, effort: str) -> str:
normalized = str(effort or "medium").strip().casefold()
if provider != "deepseek":
return normalized
if normalized in {"none", "minimal"}:
return "none"
if normalized in {"xhigh", "max"}:
return "max"
return "high"
def find_ollama_executable() -> Path | None:
candidates = [
shutil.which("ollama"),
+140
View File
@@ -12,6 +12,7 @@ from traderai.scheduler import WakeScheduler
from traderai.scmdb_client import SCMDBClient
from traderai.starcitizen_wiki_client import StarCitizenWikiClient
from traderai.uex_client import UEXClient
from traderai.wikelo_projects_client import WikeloProjectsClient
ToolHandler = Callable[..., Awaitable[dict[str, Any]]]
@@ -172,6 +173,7 @@ class ToolRegistry:
scmdb: SCMDBClient | None = None,
cornerstone: CornerstoneClient | None = None,
scwiki: StarCitizenWikiClient | None = None,
wikelo: WikeloProjectsClient | None = None,
plan_store: Any | None = None,
plan_runner: Any | None = None,
) -> None:
@@ -179,6 +181,7 @@ class ToolRegistry:
self.scmdb = scmdb or SCMDBClient()
self.cornerstone = cornerstone or CornerstoneClient()
self.scwiki = scwiki or StarCitizenWikiClient()
self.wikelo = wikelo or WikeloProjectsClient()
self.require_write_approval = require_write_approval
self.memory = memory
self.scheduler = scheduler
@@ -214,6 +217,8 @@ class ToolRegistry:
"get_scwiki_page": self.get_scwiki_page,
"search_scwiki_vehicles": self.search_scwiki_vehicles,
"get_scwiki_vehicle": self.get_scwiki_vehicle,
"search_wikelo_ship_projects": self.search_wikelo_ship_projects,
"get_wikelo_ship_project": self.get_wikelo_ship_project,
"search_cornerstone_items": self.search_cornerstone_items,
"get_cornerstone_item_locations": self.get_cornerstone_item_locations,
"get_cornerstone_item_media": self.get_cornerstone_item_media,
@@ -244,6 +249,7 @@ class ToolRegistry:
*self._uex_delete_schemas(),
*self._scmdb_schemas(),
*self._scwiki_schemas(),
*self._wikelo_schemas(),
*self._cornerstone_schemas(),
{
"type": "function",
@@ -1071,6 +1077,39 @@ class ToolRegistry:
},
]
@classmethod
def _wikelo_schemas(cls) -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": "search_wikelo_ship_projects",
"description": "Search Wikelo ship projects and their required materials from wikelo-projects.com. Use this when the user asks for Wikelo ship requirements or build materials.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Ship or project name to search for, such as Polaris, Idris, Zeus, or Guardian."},
"limit": {"type": "integer", "minimum": 1, "maximum": 10, "default": 5},
},
},
},
},
{
"type": "function",
"function": {
"name": "get_wikelo_ship_project",
"description": "Fetch one Wikelo ship project with its required materials and contribution progress.",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Wikelo ship project id."},
"ship_name": {"type": "string", "description": "Ship or project name if the project id is not known."},
},
},
},
},
]
@classmethod
def _cornerstone_schemas(cls) -> list[dict[str, Any]]:
return [
@@ -1740,6 +1779,51 @@ class ToolRegistry:
vehicle = await self.scwiki.get_vehicle(resolved_slug)
return {"source": self.scwiki.api_base_url, "vehicle": self._summarize_scwiki_vehicle(vehicle)}
async def search_wikelo_ship_projects(self, query: str, limit: int = 5) -> dict[str, Any]:
projects = await self.wikelo.list_ship_projects()
q = (query or "").casefold().strip()
matches = []
for project in projects:
score = self._wikelo_ship_match_score(q, project)
if q and score <= 0:
continue
matches.append((score, project))
matches.sort(
key=lambda match: (
-match[0],
str(match[1].get("ship_name") or "").casefold(),
str(match[1].get("id") or ""),
)
)
limit = max(1, min(limit, 10))
return {
"source": f"{self.wikelo.base_url}/Ships",
"query": query,
"matched": len(matches),
"projects": [self._summarize_wikelo_ship_project(item) for _, item in matches[:limit]],
}
async def get_wikelo_ship_project(self, project_id: str | None = None, ship_name: str | None = None) -> dict[str, Any]:
projects = await self.wikelo.list_ship_projects()
if project_id:
for project in projects:
if str(project.get("id") or "").strip() == str(project_id).strip():
return {"source": f"{self.wikelo.base_url}/Ships", "project": self._summarize_wikelo_ship_project(project, detailed=True)}
return {"error": "No Wikelo ship project matched that id."}
if not ship_name:
return {"error": "Provide project_id or ship_name."}
ranked = [
(self._wikelo_ship_match_score(ship_name.casefold().strip(), project), project)
for project in projects
]
ranked = [match for match in ranked if match[0] > 0]
ranked.sort(key=lambda match: (-match[0], str(match[1].get("ship_name") or "").casefold()))
if not ranked:
return {"error": "No Wikelo ship project matched."}
return {"source": f"{self.wikelo.base_url}/Ships", "project": self._summarize_wikelo_ship_project(ranked[0][1], detailed=True)}
async def search_cornerstone_items(
self,
query: str = "",
@@ -2492,6 +2576,62 @@ class ToolRegistry:
"version": vehicle.get("version"),
}
@staticmethod
def _wikelo_ship_match_score(query: str, project: dict[str, Any]) -> int:
if not query:
return 1
ship_name = str(project.get("ship_name") or "").casefold()
description = str(project.get("description") or "").casefold()
materials = " ".join(
str(item.get("material_name") or "").casefold()
for item in (project.get("required_materials") or [])
if isinstance(item, dict)
)
haystack = " ".join(part for part in [ship_name, description, materials] if part)
if ship_name == query:
return 10000
if query in ship_name:
return 9000 - ship_name.index(query)
if query in description:
return 7000 - description.index(query)
if query in materials:
return 5000 - materials.index(query)
tokens = [token for token in query.split() if token]
if tokens and all(token in haystack for token in tokens):
return 3000 - len(haystack)
return 0
@classmethod
def _summarize_wikelo_ship_project(cls, project: dict[str, Any], detailed: bool = False) -> dict[str, Any]:
materials = []
for item in (project.get("required_materials") or []):
if not isinstance(item, dict):
continue
quantity_needed = item.get("quantity_needed")
quantity_collected = item.get("quantity_collected")
materials.append(
{
"material_name": item.get("material_name"),
"quantity_needed": int(quantity_needed) if isinstance(quantity_needed, (int, float)) and float(quantity_needed).is_integer() else quantity_needed,
"quantity_collected": int(quantity_collected) if isinstance(quantity_collected, (int, float)) and float(quantity_collected).is_integer() else quantity_collected,
}
)
summary = {
"id": project.get("id"),
"ship_name": project.get("ship_name"),
"description": project.get("description"),
"status": project.get("status"),
"privacy": project.get("privacy"),
"owner_name": project.get("owner_name"),
"org_name": project.get("org_name"),
"home_port": project.get("home_port"),
"ship_image": project.get("ship_image"),
"materials_count": len(materials),
"required_materials": materials if detailed else materials[:12],
"source_url": f"https://wikelo-projects.com/Ships",
}
return {key: value for key, value in summary.items() if value not in (None, "", [], {})}
@classmethod
def _summarize_negotiation(cls, negotiation: dict[str, Any]) -> dict[str, Any]:
summary = cls._project_item(negotiation, mode="summary")
+3 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
__version__ = "0.0.6"
__version__ = "0.0.8"
RELEASES_URL = "https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases"
RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingConglomerate/TraderAI/releases"
@@ -13,3 +13,5 @@ RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingCo
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from typing import Any
import httpx
class WikeloProjectsError(RuntimeError):
pass
class WikeloProjectsClient:
APP_ID = "695be2905c0b4866dfb21265"
def __init__(self, base_url: str = "https://wikelo-projects.com") -> None:
self.base_url = base_url.rstrip("/")
async def list_ship_projects(self) -> list[dict[str, Any]]:
body = await self._get_json(f"{self.base_url}/api/apps/{self.APP_ID}/entities/ShipProject")
if not isinstance(body, list):
raise WikeloProjectsError("Wikelo ship projects response was not a list.")
return [item for item in body if isinstance(item, dict)]
async def _get_json(self, url: str) -> Any:
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
response = await client.get(url, headers={"Accept": "application/json"})
try:
body = response.json()
except ValueError as exc:
raise WikeloProjectsError(f"Wikelo Projects returned non-JSON response: HTTP {response.status_code}") from exc
if response.status_code >= 400:
raise WikeloProjectsError(f"Wikelo Projects HTTP {response.status_code}: {body}")
return body