3 Commits

Author SHA1 Message Date
HRiggs cf0693f319 versioning: 0.0.9
Build Release EXE / build-windows-exe (release) Successful in 51s
2026-06-09 11:24:33 -04:00
HRiggs 8fac3d2bae feat: chat 2026-06-09 11:24:15 -04:00
HRiggs 454bb57484 feat: deepseek
Build Release EXE / build-windows-exe (release) Successful in 1m2s
2026-06-08 23:41:46 -04:00
28 changed files with 3736 additions and 220 deletions
+5 -1
View File
@@ -5,6 +5,9 @@ OLLAMA_NUM_CTX=64512
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_MODEL=gpt-5.4-mini
OPENAI_API_KEY=
DEEPSEEK_BASE_URL=https://api.deepseek.com
DEEPSEEK_MODEL=deepseek-v4-flash
DEEPSEEK_API_KEY=
MODEL_REASONING_EFFORT=medium
CODEX_COMMAND=codex
CODEX_MODEL=gpt-5.4
@@ -15,7 +18,8 @@ SCWIKI_BASE_URL=https://starcitizen.tools
SCWIKI_API_BASE_URL=https://api.star-citizen.wiki
UEX_SECRET_KEY=
UEX_BEARER_TOKEN=
UEX_NEGOTIATION_CLOSE_ENDPOINT=marketplace_negotiations_close
TRADERAI_USER_NAME=
TRADERAI_MEMORY_PATH=
UEX_NOTIFICATION_POLL_SECONDS=60
UEX_NOTIFICATION_POLL_SECONDS=300
REQUIRE_WRITE_APPROVAL=true
+4 -3
View File
@@ -1,6 +1,6 @@
# TraderAI
Local Ollama-, OpenAI-, or Codex-powered chat for UEX marketplace workflows.
Local Ollama-, DeepSeek-, OpenAI-, or Codex-powered chat for UEX marketplace workflows.
## What It Does
@@ -25,9 +25,10 @@ Local Ollama-, OpenAI-, or Codex-powered chat for UEX marketplace workflows.
```
3. Create `.env` from `.env.example` and set `UEX_SECRET_KEY` and/or `UEX_BEARER_TOKEN` if you want authenticated actions.
If you want the cheapest hosted default, set `MODEL_PROVIDER=deepseek`, set `DEEPSEEK_API_KEY`, and keep `DEEPSEEK_MODEL=deepseek-v4-flash` unless you specifically want `deepseek-v4-pro`.
If you want to use OpenAI instead of Ollama, set `MODEL_PROVIDER=openai`, set `OPENAI_API_KEY`, and optionally change `OPENAI_MODEL` from the default `gpt-5.4-mini`.
If you want to use Codex models with ChatGPT/Codex OAuth, install the Codex CLI, set `MODEL_PROVIDER=codex`, and optionally change `CODEX_MODEL` from the default `gpt-5.4`. TraderAI uses the local `codex app-server` JSON-RPC interface for both authentication and chat turns.
`MODEL_REASONING_EFFORT` controls reasoning depth for OpenAI and Codex and defaults to `medium`.
`MODEL_REASONING_EFFORT` controls reasoning depth for DeepSeek, OpenAI, and Codex and defaults to `medium`.
`SCMDB_BASE_URL` defaults to `https://scmdb.net`.
`CORNERSTONE_BASE_URL` defaults to `https://finder.cstone.space`.
4. Install and run:
@@ -41,7 +42,7 @@ Local Ollama-, OpenAI-, or Codex-powered chat for UEX marketplace workflows.
## Notes
Ollama runs locally at `http://localhost:11434` by default. This app can talk to Ollama's native chat API, OpenAI's Chat Completions API, or the local Codex App Server authenticated through ChatGPT/Codex OAuth, then executes approved UEX calls in the FastAPI backend. `OLLAMA_NUM_CTX` controls the per-request Ollama context window; `64512` is the default because Ollama recommends at least 64k tokens for agent-style workflows when hardware allows it.
Ollama runs locally at `http://localhost:11434` by default. This app can talk to Ollama's native chat API, DeepSeek's OpenAI-compatible Chat Completions API, OpenAI's Chat Completions API, or the local Codex App Server authenticated through ChatGPT/Codex OAuth, then executes approved UEX calls in the FastAPI backend. `OLLAMA_NUM_CTX` controls the per-request Ollama context window; `64512` is the default because Ollama recommends at least 64k tokens for agent-style workflows when hardware allows it. DeepSeek context caching is provider-side and automatic when repeated prompt prefixes line up.
## Releases And Updates
+4 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "traderai"
version = "0.0.6"
version = "0.0.9"
description = "Local Ollama, OpenAI, or Codex assistant for UEX marketplace workflows."
requires-python = ">=3.11"
dependencies = [
@@ -41,3 +41,6 @@ include = ["traderai*"]
+61
View File
@@ -1,5 +1,6 @@
import pytest
import asyncio
import itertools
from traderai.agent import OllamaAgent, SYSTEM_PROMPT
from traderai.memory import MemoryStore
@@ -217,6 +218,38 @@ def test_ollama_options_include_num_ctx():
assert agent._ollama_options() == {"num_ctx": 64000}
def test_deepseek_tool_rounds_are_not_capped_at_ten():
agent = OllamaAgent("https://api.deepseek.com", "deepseek-v4-flash", EmptyTools(), provider="deepseek", api_key="test")
rounds = list(itertools.islice(agent._tool_rounds(), 12))
assert len(rounds) == 12
def test_plan_draft_normalization_extracts_json_and_defaults():
seed = {"title": "Wikelo Polaris", "objective": "Find parts", "kind": "buying", "constraints": {}, "items": []}
raw = 'draft:\n{"title":"Wikelo Polaris Parts","objective":"Find and draft deals for the parts below","kind":"buying","cadence":"0 */3 * * *","constraints":{"message_tone":"casual","instructions":"Prioritize cheap listings first."},"items":[{"item_name":"RCMBNT-RGL-1","desired_quantity":2}]}'
draft = OllamaAgent._normalize_plan_draft(raw, seed)
assert draft["title"] == "Wikelo Polaris Parts"
assert draft["cadence"] == "0 */3 * * *"
assert draft["constraints"]["message_tone"] == "casual"
assert draft["items"][0]["item_name"] == "RCMBNT-RGL-1"
assert draft["items"][0]["desired_quantity"] == 2
def test_plan_draft_heuristic_fills_in_basic_instructions():
seed = {"title": "Watch open negotiations", "objective": "", "kind": "custom", "constraints": {}, "items": []}
draft = OllamaAgent._heuristic_plan_draft(seed)
assert draft["kind"] == "custom"
assert draft["cadence"] == "0 */4 * * *"
assert "summarize" in draft["constraints"]["instructions"].casefold()
assert draft["constraints"]["message_tone"] == "friendly and direct"
def test_codex_prompt_mentions_tools_and_images(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
agent = OllamaAgent("codex", "gpt-5.3-codex", EmptyTools(), memory=memory, provider="codex")
@@ -257,6 +290,34 @@ def test_codex_prompt_mentions_tools_and_images(tmp_path):
assert "tool search_marketplace_listings" in prompt
def test_deepseek_openai_messages_include_reasoning_content_for_tool_turns():
agent = OllamaAgent("https://api.deepseek.com", "deepseek-v4-flash", EmptyTools(), provider="deepseek", api_key="test")
messages = agent._openai_messages(
"check listing",
[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": "Check this listing"},
{
"role": "assistant",
"content": "",
"reasoning_content": "I should check the current listing first.",
"tool_calls": [
{
"id": "call_123",
"type": "function",
"function": {"name": "search_marketplace_listings", "arguments": "{\"query\":\"panel\"}"},
}
],
},
{"role": "tool", "tool_name": "search_marketplace_listings", "tool_call_id": "call_123", "content": "{\"ok\":true}"},
],
)
assistant_turn = next(message for message in messages if message["role"] == "assistant")
assert assistant_turn["reasoning_content"] == "I should check the current listing first."
def test_codex_structured_response_extracts_text_and_tool_calls():
agent = OllamaAgent("codex", "gpt-5.3-codex", EmptyTools(), provider="codex")
+20 -2
View File
@@ -1,10 +1,22 @@
from traderai.config import Settings
def test_model_provider_accepts_codex():
def test_model_provider_codex_falls_back_to_ollama():
settings = Settings(model_provider="codex")
assert settings.model_provider == "codex"
assert settings.model_provider == "ollama"
def test_model_provider_openai_falls_back_to_ollama():
settings = Settings(model_provider="openai")
assert settings.model_provider == "ollama"
def test_model_provider_accepts_deepseek():
settings = Settings(model_provider="deepseek")
assert settings.model_provider == "deepseek"
def test_model_provider_invalid_value_falls_back_to_ollama():
@@ -23,3 +35,9 @@ def test_reasoning_effort_accepts_supported_values():
settings = Settings(model_reasoning_effort="high")
assert settings.model_reasoning_effort == "high"
def test_reasoning_effort_accepts_max():
settings = Settings(model_reasoning_effort="max")
assert settings.model_reasoning_effort == "max"
+19
View File
@@ -55,3 +55,22 @@ def test_memory_store_renames_threads_and_deletes_outbox_items(tmp_path):
assert renamed["title"] == "Market Check"
assert deleted is True
assert store.list_outbox() == []
def test_memory_store_uses_absolute_path_across_working_directory_changes(tmp_path, monkeypatch):
original_cwd = tmp_path / "start"
original_cwd.mkdir()
monkeypatch.chdir(original_cwd)
store = MemoryStore("data/memory.sqlite3")
moved_cwd = tmp_path / "moved"
moved_cwd.mkdir()
monkeypatch.chdir(moved_cwd)
store.add_outbox("Notification survived cwd change")
snapshot = store.inspect()
assert store.path.is_absolute()
assert snapshot["outbox"][0]["content"] == "Notification survived cwd change"
+148
View File
@@ -0,0 +1,148 @@
import pytest
from traderai.memory import MemoryStore
from traderai.negotiations import NegotiationSyncService, extract_negotiation_hash
from traderai.tools import ToolRegistry
class FakeNegotiationUEX:
def __init__(self):
self.list_calls = []
self.message_calls = []
self.posts = []
async def list_negotiations(self, id=None, id_listing=None, hash=None):
self.list_calls.append({"id": id, "id_listing": id_listing, "hash": hash})
data = [
{
"id": 11,
"hash": "open-hash",
"id_listing": 101,
"listing_slug": "rgl-open",
"listing_title": "RGL Set",
"advertiser_username": "seller_a",
"client_username": "pilot_hudson",
"date_modified": 1_780_975_053,
},
{
"id": 12,
"hash": "closed-recent",
"id_listing": 102,
"listing_slug": "rgl-closed",
"listing_title": "Closed Deal",
"advertiser_username": "seller_b",
"client_username": "pilot_hudson",
"date_modified": 1_780_975_053,
"date_closed": 1_780_975_054,
},
]
if hash:
data = [item for item in data if item["hash"] == hash]
return {"status": "ok", "negotiations": data}
async def get_negotiation_messages(self, hash=None, id_negotiation=None):
self.message_calls.append({"hash": hash, "id_negotiation": id_negotiation})
return {
"status": "ok",
"messages": [
{
"id": 201,
"negotiation_hash": hash,
"user_username": "seller_a" if hash == "open-hash" else "seller_b",
"user_name": "Seller",
"message": "Still available.",
"date_added": 1_780_975_053,
}
],
}
async def send_negotiation_message(self, **payload):
self.posts.append({"kind": "message", **payload})
return {"status": "ok", "posted": self.posts[-1]}
async def close_negotiation(self, **payload):
self.posts.append({"kind": "close", **payload})
return {"status": "ok", "posted": self.posts[-1]}
def test_extract_negotiation_hash_handles_uex_redirects():
assert extract_negotiation_hash("https://uexcorp.space/marketplace/negotiate/hash/abc-123") == "abc-123"
assert extract_negotiation_hash("/marketplace/negotiate/hash/def-456") == "def-456"
assert extract_negotiation_hash("/marketplace/item/info/foo") is None
@pytest.mark.asyncio
async def test_startup_sync_keeps_open_and_recent_threads(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
memory.set_profile("uex_user", {"username": "pilot_hudson"})
service = NegotiationSyncService(memory, FakeNegotiationUEX())
result = await service.startup_sync()
negotiations = memory.list_negotiations(limit=10)
assert result["count"] == 2
assert {item["hash"] for item in negotiations} == {"open-hash", "closed-recent"}
detail = memory.get_negotiation("open-hash")
assert detail["messages"][0]["body"] == "Still available."
@pytest.mark.asyncio
async def test_notification_refresh_targets_only_changed_negotiation(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
memory.set_profile("uex_user", {"username": "pilot_hudson"})
fake = FakeNegotiationUEX()
service = NegotiationSyncService(memory, fake)
await service.startup_sync()
fake.message_calls.clear()
await service.handle_notifications(
[
{
"id": 99,
"message": "seller_a: ping",
"redir": "https://uexcorp.space/marketplace/negotiate/hash/open-hash",
"date_added": 1_780_975_060,
"date_read": 0,
}
]
)
assert fake.message_calls == [{"hash": "open-hash", "id_negotiation": None}]
assert memory.get_negotiation("open-hash")["unread_count"] == 1
@pytest.mark.asyncio
async def test_manual_send_refreshes_local_thread(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
memory.set_profile("uex_user", {"username": "pilot_hudson"})
fake = FakeNegotiationUEX()
service = NegotiationSyncService(memory, fake)
await service.startup_sync()
result = await service.manual_send_message("open-hash", "I can buy tonight.")
assert result["posted"]["kind"] == "message"
assert fake.message_calls[-1]["hash"] == "open-hash"
@pytest.mark.asyncio
async def test_draft_negotiation_close_creates_pending_action(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
registry = ToolRegistry(FakeNegotiationUEX(), memory=memory)
result = await registry.draft_negotiation_close(
hash="open-hash",
deal_closed=True,
deal_value=1_000_000,
currency="UEC",
clarity_rating=5,
speed_rating=5,
respect_rating=5,
fairness_rating=4,
comment="Smooth trade",
)
pending = result["pending_action"]
assert pending["endpoint"] == "marketplace_negotiations_close"
assert pending["payload"]["deal_closed"] == 1
assert pending["payload"]["is_production"] == 1
+3
View File
@@ -142,6 +142,9 @@ async def test_buying_runner_tracks_candidates_and_drafts_only(tmp_path):
assert len(tools.pending_actions) == 1
assert not tools.uex.posts
assert "Drafted 1 negotiation" in memory.list_outbox()[0]["content"]
pending = next(iter(tools.pending_actions.values()))
assert "Tone note" not in pending.payload["message"]
assert "Polaris build" not in pending.payload["message"] or "putting together parts for a Polaris build" in pending.payload["message"]
@pytest.mark.asyncio
+111 -5
View File
@@ -86,6 +86,7 @@ def test_config_update_rebuilds_runtime_without_restart(monkeypatch, tmp_path):
model_provider=values.get("model_provider", state["settings"].model_provider),
ollama_model=values.get("ollama_model", state["settings"].ollama_model),
codex_model=values.get("codex_model", state["settings"].codex_model),
deepseek_model=values.get("deepseek_model", state["settings"].deepseek_model),
)
return {"values": values, "fields": {}, "secrets_configured": {}, "app_data_dir": str(tmp_path)}
@@ -114,19 +115,120 @@ def test_config_update_rebuilds_runtime_without_restart(monkeypatch, tmp_path):
updated = client.post(
"/api/config",
json={"values": {"model_provider": "codex", "codex_model": "gpt-5.4"}},
json={"values": {"model_provider": "deepseek", "deepseek_model": "deepseek-v4-flash"}},
).json()
assert updated["restart_required"] is False
after = client.get("/api/health").json()
assert after["model_provider"] == "codex"
assert after["inference"]["provider"] == "codex"
assert after["model_provider"] == "deepseek"
assert after["inference"]["provider"] == "deepseek"
chat = client.post("/api/chat", json={"message": "hi", "thread_id": "thread-1", "images": []}).json()
assert chat["message"] == "codex:gpt-5.4"
assert chat["message"] == "deepseek:deepseek-v4-flash"
def make_settings(tmp_path, model_provider="ollama", ollama_model="qwen3.5:9b", codex_model="gpt-5.4"):
def test_plan_draft_endpoint_returns_agent_draft(monkeypatch, tmp_path):
state = {"settings": make_settings(tmp_path)}
class FakeScheduler:
def __init__(self, memory):
self.memory = memory
def bind_agent(self, agent):
self.agent = agent
def bind_plan_runner(self, plan_runner):
self.plan_runner = plan_runner
def bind_uex_notifications(self, uex, poll_seconds=60):
self.uex = uex
self.poll_seconds = poll_seconds
def start(self):
return None
def shutdown(self):
return None
def list_jobs(self):
return []
class FakeUEXClient:
def __init__(self, *args, **kwargs):
pass
async def get_user(self, username=None, authenticated=False):
return {}
class FakeToolRegistry:
def __init__(self, *args, **kwargs):
self.pending_actions = {}
self.plan_runner = None
async def approve(self, action_id):
return {"approved": action_id}
async def decline(self, action_id):
return {"declined": action_id}
class FakePlanRunner:
def __init__(self, store, tools, memory, agent=None):
self.store = store
self.tools = tools
self.memory = memory
self.agent = agent
def bind_agent(self, agent):
self.agent = agent
class FakeClient:
def __init__(self, *args, **kwargs):
pass
def fake_get_settings():
return state["settings"]
monkeypatch.setattr(server, "WakeScheduler", FakeScheduler)
monkeypatch.setattr(server, "UEXClient", FakeUEXClient)
monkeypatch.setattr(server, "ToolRegistry", FakeToolRegistry)
monkeypatch.setattr(server, "ContinualPlanRunner", FakePlanRunner)
monkeypatch.setattr(server, "SCMDBClient", FakeClient)
monkeypatch.setattr(server, "CornerstoneClient", FakeClient)
monkeypatch.setattr(server, "StarCitizenWikiClient", FakeClient)
monkeypatch.setattr(server, "get_settings", fake_get_settings)
monkeypatch.setattr(
server,
"settings_payload",
lambda settings=None: {"app_data_dir": str(tmp_path), "values": {}, "fields": {}, "secrets_configured": {}},
)
async def fake_generate_plan_draft(self, title="", objective="", kind="buying", constraints=None, items=None):
return {
"title": title or "Draft title",
"objective": objective or "Draft objective",
"kind": kind,
"cadence": "0 */3 * * *",
"constraints": {"message_tone": "friendly and direct", "instructions": "Start with the best listings."},
"items": [{"item_name": "RCMBNT-RGL-1", "desired_quantity": 1, "max_unit_price": None}],
}
monkeypatch.setattr(server.OllamaAgent, "generate_plan_draft", fake_generate_plan_draft)
app = server.create_app()
with TestClient(app) as client:
response = client.post(
"/api/plans/draft",
json={"title": "Polaris parts", "objective": "Find the required parts", "kind": "buying", "constraints": {}, "items": []},
)
assert response.status_code == 200
draft = response.json()["draft"]
assert draft["cadence"] == "0 */3 * * *"
assert draft["constraints"]["instructions"] == "Start with the best listings."
assert draft["items"][0]["item_name"] == "RCMBNT-RGL-1"
def make_settings(tmp_path, model_provider="ollama", ollama_model="qwen3.5:9b", codex_model="gpt-5.4", deepseek_model="deepseek-v4-flash"):
return SimpleNamespace(
traderai_memory_path=str(tmp_path / "memory.sqlite3"),
model_provider=model_provider,
@@ -136,12 +238,16 @@ def make_settings(tmp_path, model_provider="ollama", ollama_model="qwen3.5:9b",
openai_base_url="https://api.openai.com/v1",
openai_api_key=None,
openai_model="gpt-5.4-mini",
deepseek_base_url="https://api.deepseek.com",
deepseek_api_key=None,
deepseek_model=deepseek_model,
model_reasoning_effort="medium",
codex_command="codex",
codex_model=codex_model,
uex_base_url="https://api.uexcorp.space/2.0",
uex_secret_key=None,
uex_bearer_token=None,
uex_negotiation_close_endpoint="marketplace_negotiations_close",
traderai_user_name=None,
uex_notification_poll_seconds=60,
require_write_approval=True,
+71
View File
@@ -368,6 +368,37 @@ class FakeSCWiki:
}
class FakeWikelo:
base_url = "https://wikelo-projects.test"
async def list_ship_projects(self):
return [
{
"id": "ship-1",
"ship_name": "Polaris Wikelo Special",
"description": "Now make Polaris. Short Time Deal",
"status": "planning",
"privacy": "public",
"owner_name": "Chimpanz33",
"required_materials": [
{"material_name": "Wikelo Favor", "quantity_needed": 50.0, "quantity_collected": 0.0},
{"material_name": "Polaris Bit", "quantity_needed": 15.0, "quantity_collected": 2.0},
],
},
{
"id": "ship-2",
"ship_name": "Guardian",
"description": "Guardian Fight Mod",
"status": "planning",
"privacy": "public",
"owner_name": "Chimpanz33",
"required_materials": [
{"material_name": "Wikelo Favor", "quantity_needed": 20.0, "quantity_collected": 0.0},
],
},
]
@pytest.mark.asyncio
async def test_search_marketplace_listings_filters_locally():
registry = ToolRegistry(FakeUEX())
@@ -421,6 +452,17 @@ def test_uex_client_uses_bearer_and_secret_headers():
assert headers["Authorization"] == "Bearer bearer"
def test_uex_client_uses_configured_close_endpoint():
client = UEXClient(
"https://api.uexcorp.space/2.0",
secret_key="secret",
bearer_token="bearer",
negotiation_close_endpoint="custom_close_endpoint",
)
assert client.negotiation_close_endpoint == "custom_close_endpoint"
@pytest.mark.asyncio
async def test_uex_get_projects_and_limits_results():
registry = ToolRegistry(FakeUEX())
@@ -686,6 +728,35 @@ async def test_get_scwiki_vehicle_returns_ship_prices_and_store_context():
]
@pytest.mark.asyncio
async def test_search_wikelo_ship_projects_returns_material_matches():
registry = ToolRegistry(FakeUEX(), wikelo=FakeWikelo())
result = await registry.search_wikelo_ship_projects(query="Polaris")
assert result["source"] == "https://wikelo-projects.test/Ships"
assert result["matched"] == 1
assert result["projects"][0]["ship_name"] == "Polaris Wikelo Special"
assert result["projects"][0]["required_materials"][0]["material_name"] == "Wikelo Favor"
@pytest.mark.asyncio
async def test_get_wikelo_ship_project_returns_full_requirements():
registry = ToolRegistry(FakeUEX(), wikelo=FakeWikelo())
result = await registry.get_wikelo_ship_project(ship_name="Guardian")
assert result["project"]["ship_name"] == "Guardian"
assert result["project"]["materials_count"] == 1
assert result["project"]["required_materials"] == [
{
"material_name": "Wikelo Favor",
"quantity_needed": 20,
"quantity_collected": 0,
}
]
@pytest.mark.asyncio
async def test_draft_marketplace_listing_with_cornerstone_image_adds_image_data_and_redacts_display():
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
+453
View File
File diff suppressed because one or more lines are too long
+403 -38
View File
@@ -20,8 +20,10 @@ 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.
Prefer locally synced negotiation tools before live UEX negotiation reads when local context is available.
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.
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.
@@ -29,11 +31,14 @@ 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".
The user can manually send their own negotiation messages directly from the negotiations workspace, but you must still use approval-gated draft actions for AI-authored replies and deal-close submissions.
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 +69,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 +124,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 +160,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 +192,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 +203,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 +260,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 +273,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 +311,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 +325,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 +353,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 +454,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 +465,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 +493,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 +507,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 +523,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 +597,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 +695,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 +743,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 +768,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 +1233,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 +1261,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 +1296,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 +1349,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 +1361,7 @@ class OllamaAgent:
{"role": "user", "content": prompt},
],
"stream": False,
**self._openai_request_options(stream=False),
},
)
response.raise_for_status()
@@ -1330,6 +1397,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 +1441,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 +1748,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",
@@ -1415,8 +1760,13 @@ class OllamaAgent:
"search_marketplace_listings": "Searching UEX listings",
"get_marketplace_listing": "Fetching listing details",
"list_marketplace_negotiations": "Checking negotiations",
"list_local_negotiations": "Checking local negotiations",
"get_local_negotiation": "Reading local negotiation",
"search_local_negotiation_messages": "Searching local negotiation history",
"get_negotiation_messages": "Reading negotiation messages",
"draft_negotiation_message": "Drafting message for approval",
"draft_negotiation_close": "Drafting negotiation close for approval",
"draft_negotiation_rating": "Drafting negotiation rating for approval",
"draft_marketplace_listing": "Drafting listing for approval",
"draft_marketplace_listing_with_cornerstone_image": "Drafting listing with Cornerstone image",
"check_uex_notifications": "Checking UEX notifications",
@@ -1442,6 +1792,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")
+13 -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,8 +28,10 @@ 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},
"uex_negotiation_close_endpoint": {"env": "UEX_NEGOTIATION_CLOSE_ENDPOINT", "type": "string", "secret": False},
"traderai_user_name": {"env": "TRADERAI_USER_NAME", "type": "string", "secret": False},
"traderai_memory_path": {"env": "TRADERAI_MEMORY_PATH", "type": "string", "secret": False},
"uex_notification_poll_seconds": {"env": "UEX_NOTIFICATION_POLL_SECONDS", "type": "integer", "secret": False},
@@ -77,6 +81,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 +92,16 @@ 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)
uex_negotiation_close_endpoint: str = "marketplace_negotiations_close"
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 +110,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 +175,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":
+530 -1
View File
@@ -30,6 +30,16 @@ def parse_iso(value: str) -> datetime:
return parsed
def unix_to_iso(value: Any) -> str | None:
try:
timestamp = int(value)
except (TypeError, ValueError):
return None
if timestamp <= 0:
return None
return datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat()
def time_since(value: str, now: datetime | None = None) -> str:
then = parse_iso(value)
current = now or utc_now()
@@ -55,7 +65,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()
@@ -138,6 +148,56 @@ class MemoryStore:
created_at TEXT NOT NULL,
delivered_at TEXT
);
CREATE TABLE IF NOT EXISTS negotiation_threads (
negotiation_hash TEXT PRIMARY KEY,
uex_negotiation_id INTEGER,
listing_id INTEGER,
listing_slug TEXT,
title TEXT,
counterparty_username TEXT,
status TEXT NOT NULL DEFAULT 'open',
last_message_at TEXT,
last_synced_at TEXT NOT NULL,
last_notification_id INTEGER,
last_notification_at TEXT,
unread_count INTEGER NOT NULL DEFAULT 0,
closed_at TEXT,
metadata_json TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS negotiation_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
negotiation_hash TEXT NOT NULL,
uex_message_id INTEGER,
author TEXT,
author_username TEXT,
is_me INTEGER NOT NULL DEFAULT 0,
body TEXT NOT NULL,
sent_at TEXT,
source TEXT,
raw_json TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS negotiation_ratings (
negotiation_hash TEXT PRIMARY KEY,
deal_closed INTEGER NOT NULL,
deal_value REAL,
currency TEXT,
clarity_rating INTEGER,
speed_rating INTEGER,
respect_rating INTEGER,
fairness_rating INTEGER,
comment TEXT,
submitted_at TEXT NOT NULL,
raw_json TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS negotiation_sync_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
"""
)
self._ensure_column(db, "conversations", "thread_id", "TEXT")
@@ -384,6 +444,24 @@ class MemoryStore:
"SELECT id, content, created_at, delivered_at FROM outbox ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
negotiation_threads = db.execute(
"""
SELECT negotiation_hash, title, counterparty_username, status, unread_count, last_message_at, last_synced_at
FROM negotiation_threads
ORDER BY COALESCE(last_message_at, last_synced_at) DESC
LIMIT ?
""",
(limit,),
).fetchall()
negotiation_messages = db.execute(
"""
SELECT negotiation_hash, author_username, is_me, body, sent_at
FROM negotiation_messages
ORDER BY COALESCE(sent_at, '') DESC, id DESC
LIMIT ?
""",
(limit,),
).fetchall()
profile = []
for row in profile_rows:
@@ -402,6 +480,8 @@ class MemoryStore:
"profile": profile,
"scheduled_jobs": [dict(row) for row in jobs],
"outbox": [dict(row) for row in outbox],
"negotiation_threads": [dict(row) for row in negotiation_threads],
"negotiation_messages": [dict(row) for row in negotiation_messages],
}
def clear(
@@ -425,6 +505,10 @@ class MemoryStore:
deleted["scheduled_jobs"] = db.execute("DELETE FROM scheduled_jobs").rowcount
if include_outbox:
deleted["outbox"] = db.execute("DELETE FROM outbox").rowcount
deleted["negotiation_threads"] = db.execute("DELETE FROM negotiation_threads").rowcount
deleted["negotiation_messages"] = db.execute("DELETE FROM negotiation_messages").rowcount
deleted["negotiation_ratings"] = db.execute("DELETE FROM negotiation_ratings").rowcount
deleted["negotiation_sync_state"] = db.execute("DELETE FROM negotiation_sync_state").rowcount
return deleted
def set_profile(self, key: str, value: Any) -> None:
@@ -555,3 +639,448 @@ class MemoryStore:
except json.JSONDecodeError:
data["metadata"] = {}
return data
def set_negotiation_sync_state(self, key: str, value: Any) -> None:
with self._connect() as db:
db.execute(
"""
INSERT INTO negotiation_sync_state(key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
""",
(key, json.dumps(value), iso_now()),
)
def get_negotiation_sync_state(self, key: str, default: Any = None) -> Any:
with self._connect() as db:
row = db.execute(
"SELECT value FROM negotiation_sync_state WHERE key = ?",
(key,),
).fetchone()
if not row:
return default
try:
return json.loads(row["value"])
except json.JSONDecodeError:
return default
def upsert_negotiation(
self,
negotiation_hash: str,
*,
uex_negotiation_id: int | None = None,
listing_id: int | None = None,
listing_slug: str | None = None,
title: str | None = None,
counterparty_username: str | None = None,
status: str = "open",
last_message_at: str | None = None,
last_synced_at: str | None = None,
last_notification_id: int | None = None,
last_notification_at: str | None = None,
unread_count: int | None = None,
closed_at: str | None = None,
metadata: dict[str, Any] | None = None,
) -> None:
if not negotiation_hash.strip():
return
now = last_synced_at or iso_now()
with self._connect() as db:
existing = db.execute(
"""
SELECT unread_count, metadata_json
FROM negotiation_threads
WHERE negotiation_hash = ?
""",
(negotiation_hash,),
).fetchone()
current_unread = int(existing["unread_count"]) if existing else 0
merged_metadata = {}
if existing:
try:
merged_metadata = json.loads(existing["metadata_json"])
except json.JSONDecodeError:
merged_metadata = {}
if metadata:
merged_metadata.update(metadata)
db.execute(
"""
INSERT INTO negotiation_threads(
negotiation_hash,
uex_negotiation_id,
listing_id,
listing_slug,
title,
counterparty_username,
status,
last_message_at,
last_synced_at,
last_notification_id,
last_notification_at,
unread_count,
closed_at,
metadata_json
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(negotiation_hash) DO UPDATE SET
uex_negotiation_id = COALESCE(excluded.uex_negotiation_id, negotiation_threads.uex_negotiation_id),
listing_id = COALESCE(excluded.listing_id, negotiation_threads.listing_id),
listing_slug = COALESCE(excluded.listing_slug, negotiation_threads.listing_slug),
title = COALESCE(excluded.title, negotiation_threads.title),
counterparty_username = COALESCE(excluded.counterparty_username, negotiation_threads.counterparty_username),
status = COALESCE(excluded.status, negotiation_threads.status),
last_message_at = COALESCE(excluded.last_message_at, negotiation_threads.last_message_at),
last_synced_at = excluded.last_synced_at,
last_notification_id = COALESCE(excluded.last_notification_id, negotiation_threads.last_notification_id),
last_notification_at = COALESCE(excluded.last_notification_at, negotiation_threads.last_notification_at),
unread_count = COALESCE(excluded.unread_count, negotiation_threads.unread_count),
closed_at = COALESCE(excluded.closed_at, negotiation_threads.closed_at),
metadata_json = excluded.metadata_json
""",
(
negotiation_hash.strip(),
uex_negotiation_id,
listing_id,
listing_slug,
title,
counterparty_username,
status or "open",
last_message_at,
now,
last_notification_id,
last_notification_at,
current_unread if unread_count is None else max(0, int(unread_count)),
closed_at,
json.dumps(merged_metadata),
),
)
def replace_negotiation_messages(
self,
negotiation_hash: str,
messages: list[dict[str, Any]],
*,
mark_read: bool = False,
) -> None:
if not negotiation_hash.strip():
return
normalized = [self._normalize_negotiation_message(negotiation_hash, item) for item in messages]
normalized = [item for item in normalized if item]
with self._connect() as db:
db.execute("DELETE FROM negotiation_messages WHERE negotiation_hash = ?", (negotiation_hash,))
for item in normalized:
db.execute(
"""
INSERT INTO negotiation_messages(
negotiation_hash,
uex_message_id,
author,
author_username,
is_me,
body,
sent_at,
source,
raw_json
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
negotiation_hash,
item["uex_message_id"],
item["author"],
item["author_username"],
1 if item["is_me"] else 0,
item["body"],
item["sent_at"],
item["source"],
json.dumps(item["raw_json"]),
),
)
last_message_at = normalized[-1]["sent_at"] if normalized else None
db.execute(
"""
UPDATE negotiation_threads
SET last_message_at = COALESCE(?, last_message_at),
last_synced_at = ?,
unread_count = CASE WHEN ? THEN 0 ELSE unread_count END
WHERE negotiation_hash = ?
""",
(last_message_at, iso_now(), 1 if mark_read else 0, negotiation_hash),
)
def mark_negotiation_notified(
self,
negotiation_hash: str,
*,
notification_id: int | None = None,
notification_at: str | None = None,
) -> None:
with self._connect() as db:
db.execute(
"""
UPDATE negotiation_threads
SET unread_count = unread_count + 1,
last_notification_id = COALESCE(?, last_notification_id),
last_notification_at = COALESCE(?, last_notification_at)
WHERE negotiation_hash = ?
""",
(notification_id, notification_at, negotiation_hash),
)
def mark_negotiation_read(self, negotiation_hash: str) -> None:
with self._connect() as db:
db.execute(
"UPDATE negotiation_threads SET unread_count = 0 WHERE negotiation_hash = ?",
(negotiation_hash,),
)
def store_negotiation_rating(self, negotiation_hash: str, payload: dict[str, Any], raw_json: dict[str, Any] | None = None) -> None:
now = iso_now()
with self._connect() as db:
db.execute(
"""
INSERT INTO negotiation_ratings(
negotiation_hash,
deal_closed,
deal_value,
currency,
clarity_rating,
speed_rating,
respect_rating,
fairness_rating,
comment,
submitted_at,
raw_json
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(negotiation_hash) DO UPDATE SET
deal_closed = excluded.deal_closed,
deal_value = excluded.deal_value,
currency = excluded.currency,
clarity_rating = excluded.clarity_rating,
speed_rating = excluded.speed_rating,
respect_rating = excluded.respect_rating,
fairness_rating = excluded.fairness_rating,
comment = excluded.comment,
submitted_at = excluded.submitted_at,
raw_json = excluded.raw_json
""",
(
negotiation_hash,
1 if payload.get("deal_closed") else 0,
payload.get("deal_value"),
payload.get("currency"),
payload.get("clarity_rating"),
payload.get("speed_rating"),
payload.get("respect_rating"),
payload.get("fairness_rating"),
payload.get("comment"),
now,
json.dumps(raw_json or payload),
),
)
def list_negotiations(
self,
*,
status: str = "all",
unread_only: bool = False,
search: str = "",
limit: int = 50,
) -> list[dict[str, Any]]:
status_filter = str(status or "all").strip().casefold()
search_filter = f"%{search.strip().casefold()}%" if search.strip() else None
clauses = []
params: list[Any] = []
if status_filter not in {"", "all"}:
clauses.append("status = ?")
params.append(status_filter)
if unread_only:
clauses.append("unread_count > 0")
if search_filter:
clauses.append(
"""
(
lower(COALESCE(title, '')) LIKE ?
OR lower(COALESCE(counterparty_username, '')) LIKE ?
OR lower(COALESCE(listing_slug, '')) LIKE ?
)
"""
)
params.extend([search_filter, search_filter, search_filter])
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
with self._connect() as db:
rows = db.execute(
f"""
SELECT
negotiation_hash,
uex_negotiation_id,
listing_id,
listing_slug,
title,
counterparty_username,
status,
last_message_at,
last_synced_at,
last_notification_id,
last_notification_at,
unread_count,
closed_at,
metadata_json
FROM negotiation_threads
{where}
ORDER BY
unread_count DESC,
COALESCE(last_message_at, last_notification_at, last_synced_at) DESC
LIMIT ?
""",
(*params, max(1, min(limit, 500))),
).fetchall()
return [self._negotiation_thread_row(row) for row in rows]
def get_negotiation(self, negotiation_hash: str) -> dict[str, Any] | None:
with self._connect() as db:
thread = db.execute(
"""
SELECT
negotiation_hash,
uex_negotiation_id,
listing_id,
listing_slug,
title,
counterparty_username,
status,
last_message_at,
last_synced_at,
last_notification_id,
last_notification_at,
unread_count,
closed_at,
metadata_json
FROM negotiation_threads
WHERE negotiation_hash = ?
""",
(negotiation_hash,),
).fetchone()
if not thread:
return None
messages = db.execute(
"""
SELECT
uex_message_id,
author,
author_username,
is_me,
body,
sent_at,
source,
raw_json
FROM negotiation_messages
WHERE negotiation_hash = ?
ORDER BY COALESCE(sent_at, '') ASC, id ASC
""",
(negotiation_hash,),
).fetchall()
rating = db.execute(
"""
SELECT
deal_closed,
deal_value,
currency,
clarity_rating,
speed_rating,
respect_rating,
fairness_rating,
comment,
submitted_at,
raw_json
FROM negotiation_ratings
WHERE negotiation_hash = ?
""",
(negotiation_hash,),
).fetchone()
result = self._negotiation_thread_row(thread)
result["messages"] = [self._negotiation_message_row(row) for row in messages]
result["rating"] = self._negotiation_rating_row(rating) if rating else None
return result
def search_negotiation_messages(self, query: str, limit: int = 8) -> list[dict[str, Any]]:
q = query.strip()
if not q:
return []
with self._connect() as db:
rows = db.execute(
"""
SELECT
m.negotiation_hash,
t.title,
t.counterparty_username,
m.author_username,
m.is_me,
m.body,
m.sent_at
FROM negotiation_messages m
JOIN negotiation_threads t ON t.negotiation_hash = m.negotiation_hash
WHERE lower(m.body) LIKE ?
ORDER BY COALESCE(m.sent_at, '') DESC, m.id DESC
LIMIT ?
""",
(f"%{q.casefold()}%", max(1, min(limit, 50))),
).fetchall()
return [dict(row) for row in rows]
@staticmethod
def _normalize_negotiation_message(negotiation_hash: str, item: dict[str, Any]) -> dict[str, Any] | None:
if not isinstance(item, dict):
return None
body = str(item.get("body") or item.get("message") or item.get("content") or item.get("text") or item.get("event") or "").strip()
if not body:
return None
return {
"negotiation_hash": negotiation_hash,
"uex_message_id": MemoryStore._int_or_none(item.get("id") or item.get("id_message")),
"author": str(item.get("user_name") or item.get("author") or item.get("sender") or item.get("user_username") or "UEX"),
"author_username": str(item.get("user_username") or item.get("author_username") or item.get("username") or "").strip() or None,
"is_me": bool(item.get("is_me")),
"body": body,
"sent_at": unix_to_iso(item.get("date_added")) or str(item.get("sent_at") or "").strip() or None,
"source": str(item.get("api_name") or item.get("source") or "uex"),
"raw_json": item,
}
@staticmethod
def _negotiation_thread_row(row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]:
data = dict(row)
try:
data["metadata"] = json.loads(data.pop("metadata_json"))
except (KeyError, json.JSONDecodeError):
data["metadata"] = {}
data["hash"] = data.pop("negotiation_hash")
return data
@staticmethod
def _negotiation_message_row(row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]:
data = dict(row)
try:
data["raw_json"] = json.loads(data["raw_json"])
except (KeyError, json.JSONDecodeError):
data["raw_json"] = {}
data["is_me"] = bool(data.get("is_me"))
return data
@staticmethod
def _negotiation_rating_row(row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]:
data = dict(row)
try:
data["raw_json"] = json.loads(data["raw_json"])
except (KeyError, json.JSONDecodeError):
data["raw_json"] = {}
data["deal_closed"] = bool(data.get("deal_closed"))
return data
@staticmethod
def _int_or_none(value: Any) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
+248
View File
@@ -0,0 +1,248 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from urllib.parse import urlparse
from traderai.memory import MemoryStore, iso_now, unix_to_iso
from traderai.uex_client import UEXClient
UEX_NEGOTIATION_CLOSE_ENDPOINT = "marketplace_negotiations_close"
def extract_negotiation_hash(redir: str | None) -> str | None:
if not redir:
return None
parsed = urlparse(redir)
path = parsed.path or str(redir)
cleaned = path.strip("/")
parts = cleaned.split("/")
for index, part in enumerate(parts):
if part == "hash" and index + 1 < len(parts):
return parts[index + 1].strip() or None
if len(parts) >= 3 and parts[-3:-1] == ["marketplace", "negotiations"]:
return parts[-1].strip() or None
return None
@dataclass
class NegotiationRefreshResult:
hash: str
refreshed: bool
summary: dict[str, Any] | None = None
messages_count: int = 0
class NegotiationSyncService:
def __init__(self, memory: MemoryStore, uex: UEXClient) -> None:
self.memory = memory
self.uex = uex
self.recent_days = 30
async def startup_sync(self) -> dict[str, Any]:
return await self.refresh_negotiations(seed_open_messages=True)
async def refresh_negotiations(self, *, seed_open_messages: bool = False) -> dict[str, Any]:
response = await self.uex.list_negotiations()
negotiations = response.get("negotiations") or response.get("data") or []
kept_hashes: list[str] = []
refreshed = 0
for item in negotiations:
normalized = self._normalize_negotiation_summary(item)
if not normalized:
continue
cached = self.memory.get_negotiation(normalized["negotiation_hash"])
if not self._should_keep_thread(normalized, cached):
continue
kept_hashes.append(normalized["negotiation_hash"])
self.memory.upsert_negotiation(**normalized)
if seed_open_messages and (normalized["status"] == "open" or cached is None):
result = await self.refresh_negotiation(normalized["negotiation_hash"], mark_read=False, summary=normalized)
if result.refreshed:
refreshed += 1
self.memory.set_negotiation_sync_state("last_full_negotiation_sync_at", iso_now())
return {
"count": len(kept_hashes),
"refreshed_threads": refreshed,
"negotiations": self.memory.list_negotiations(limit=200),
}
async def refresh_negotiation(
self,
negotiation_hash: str,
*,
mark_read: bool = False,
summary: dict[str, Any] | None = None,
) -> NegotiationRefreshResult:
summary_data = summary or await self._fetch_summary_by_hash(negotiation_hash)
if summary_data:
self.memory.upsert_negotiation(**summary_data)
response = await self.uex.get_negotiation_messages(hash=negotiation_hash)
messages = response.get("messages") or response.get("data") or []
normalized_messages = [self._normalize_message(item) for item in messages if isinstance(item, dict)]
normalized_messages = [item for item in normalized_messages if item]
self.memory.replace_negotiation_messages(negotiation_hash, normalized_messages, mark_read=mark_read)
if mark_read:
self.memory.mark_negotiation_read(negotiation_hash)
return NegotiationRefreshResult(
hash=negotiation_hash,
refreshed=True,
summary=self.memory.get_negotiation(negotiation_hash),
messages_count=len(normalized_messages),
)
async def handle_notifications(self, notifications: list[dict[str, Any]]) -> list[dict[str, Any]]:
if not notifications:
return []
grouped: dict[str, list[dict[str, Any]]] = {}
passthrough: list[dict[str, Any]] = []
for item in notifications:
negotiation_hash = extract_negotiation_hash(item.get("redir"))
if not negotiation_hash:
passthrough.append(item)
continue
grouped.setdefault(negotiation_hash, []).append(item)
for negotiation_hash, items in grouped.items():
latest = max(items, key=lambda item: int(item.get("date_added") or 0))
await self.refresh_negotiation(negotiation_hash, mark_read=False)
self.memory.mark_negotiation_notified(
negotiation_hash,
notification_id=self._int_or_none(latest.get("id")),
notification_at=unix_to_iso(latest.get("date_added")) or iso_now(),
)
for item in passthrough:
self.memory.add_outbox(self._notification_text(item))
self.memory.set_negotiation_sync_state("last_notification_sync_at", iso_now())
self.memory.set_negotiation_sync_state(
"last_seen_notification_ids",
sorted(self._int_or_none(item.get("id")) for item in notifications if self._int_or_none(item.get("id")) is not None),
)
return notifications
async def manual_send_message(self, negotiation_hash: str, message: str) -> dict[str, Any]:
result = await self.uex.send_negotiation_message(hash=negotiation_hash, message=message, is_production=1)
await self.refresh_negotiation(negotiation_hash, mark_read=True)
return result
async def manual_close_negotiation(self, negotiation_hash: str, payload: dict[str, Any]) -> dict[str, Any]:
result = await self.uex.close_negotiation(hash=negotiation_hash, **payload)
await self.refresh_negotiation(negotiation_hash, mark_read=True)
self.memory.store_negotiation_rating(negotiation_hash, payload, raw_json=result)
return result
def list_negotiations(self, *, status: str = "all", unread_only: bool = False, search: str = "", limit: int = 50) -> list[dict[str, Any]]:
return self.memory.list_negotiations(status=status, unread_only=unread_only, search=search, limit=limit)
def unread_count(self) -> int:
return sum(int(item.get("unread_count") or 0) for item in self.memory.list_negotiations(unread_only=True, limit=500))
def get_negotiation(self, negotiation_hash: str, *, mark_read: bool = True) -> dict[str, Any] | None:
negotiation = self.memory.get_negotiation(negotiation_hash)
if negotiation and mark_read:
self.memory.mark_negotiation_read(negotiation_hash)
negotiation["unread_count"] = 0
return negotiation
def search_messages(self, query: str, limit: int = 8) -> list[dict[str, Any]]:
return self.memory.search_negotiation_messages(query, limit=limit)
async def _fetch_summary_by_hash(self, negotiation_hash: str) -> dict[str, Any] | None:
response = await self.uex.list_negotiations(hash=negotiation_hash)
negotiations = response.get("negotiations") or response.get("data") or []
for item in negotiations:
normalized = self._normalize_negotiation_summary(item)
if normalized and normalized["negotiation_hash"] == negotiation_hash:
return normalized
return None
def _normalize_negotiation_summary(self, item: dict[str, Any]) -> dict[str, Any] | None:
negotiation_hash = str(item.get("hash") or item.get("negotiation_hash") or "").strip()
if not negotiation_hash:
return None
user = self.memory.get_profile().get("uex_user") or {}
current_username = str(user.get("username") or user.get("user_username") or "").strip().casefold()
advertiser_username = str(item.get("advertiser_username") or "").strip()
client_username = str(item.get("client_username") or "").strip()
is_listing_advertiser = bool(item.get("is_listing_advertiser"))
if current_username:
if advertiser_username.casefold() == current_username:
counterparty = client_username
elif client_username.casefold() == current_username:
counterparty = advertiser_username
else:
counterparty = client_username if is_listing_advertiser else advertiser_username
else:
counterparty = client_username if is_listing_advertiser else advertiser_username
closed_at = unix_to_iso(item.get("date_closed") or item.get("date_closed_client"))
metadata = {
"advertiser_name": item.get("advertiser_name"),
"advertiser_username": advertiser_username or None,
"client_name": item.get("client_name"),
"client_username": client_username or None,
"deal_value": item.get("deal_value"),
"deal_value_currency": item.get("deal_value_currency"),
"price": item.get("price"),
"unit": item.get("unit"),
"currency": item.get("currency"),
"raw": item,
}
return {
"negotiation_hash": negotiation_hash,
"uex_negotiation_id": self._int_or_none(item.get("id") or item.get("id_negotiation")),
"listing_id": self._int_or_none(item.get("id_listing")),
"listing_slug": str(item.get("listing_slug") or "").strip() or None,
"title": str(item.get("listing_title") or item.get("title") or "").strip() or None,
"counterparty_username": counterparty or None,
"status": "closed" if closed_at else "open",
"last_message_at": unix_to_iso(item.get("date_modified") or item.get("date_added")),
"last_synced_at": iso_now(),
"closed_at": closed_at,
"metadata": metadata,
}
def _normalize_message(self, item: dict[str, Any]) -> dict[str, Any] | None:
negotiation_hash = str(item.get("negotiation_hash") or "").strip()
if not negotiation_hash:
return None
user = self.memory.get_profile().get("uex_user") or {}
current_username = str(user.get("username") or user.get("user_username") or "").strip().casefold()
username = str(item.get("user_username") or "").strip()
normalized = dict(item)
normalized["is_me"] = bool(current_username and username.casefold() == current_username)
normalized["author"] = item.get("user_name") or username or "UEX"
normalized["source"] = item.get("api_name") or "uex"
normalized["body"] = item.get("message") or item.get("event") or ""
return normalized
def _should_keep_thread(self, normalized: dict[str, Any], cached: dict[str, Any] | None) -> bool:
if cached:
return True
if normalized["status"] == "open":
return True
last_message_at = normalized.get("last_message_at")
if not last_message_at:
return False
try:
age_seconds = max(0.0, (datetime.now(timezone.utc) - datetime.fromisoformat(last_message_at)).total_seconds())
except ValueError:
return False
return age_seconds <= self.recent_days * 24 * 60 * 60
@staticmethod
def _notification_text(item: dict[str, Any]) -> str:
message = item.get("message") or "You have a pending UEX notification."
redir = item.get("redir")
return f"UEX notification: {message}" + (f" (path `{redir}`)" if redir else "")
@staticmethod
def _int_or_none(value: Any) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
+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)
+9 -2
View File
@@ -23,6 +23,7 @@ class WakeScheduler:
self.agent = None
self.uex = None
self.plan_runner = None
self.negotiation_sync = None
self.notification_poll_seconds = 60
def bind_agent(self, agent: Any) -> None:
@@ -31,6 +32,9 @@ class WakeScheduler:
def bind_plan_runner(self, plan_runner: Any) -> None:
self.plan_runner = plan_runner
def bind_negotiation_sync(self, negotiation_sync: Any) -> None:
self.negotiation_sync = negotiation_sync
def bind_uex_notifications(self, uex: Any, poll_seconds: int = 60) -> None:
self.uex = uex
self.notification_poll_seconds = max(15, poll_seconds)
@@ -197,8 +201,11 @@ class WakeScheduler:
new_pending = [item for item in pending if self._notification_key(item) not in seen]
if new_pending:
for item in new_pending:
self.memory.add_outbox(self._notification_text(item))
if self.negotiation_sync is not None:
await self.negotiation_sync.handle_notifications(new_pending)
else:
for item in new_pending:
self.memory.add_outbox(self._notification_text(item))
seen.update(self._notification_key(item) for item in new_pending)
self.memory.set_profile("uex_seen_notification_keys", sorted(seen))
self.memory.set_profile("uex_last_notification_check", iso_now())
+226 -16
View File
@@ -24,6 +24,7 @@ from traderai.config import save_settings, settings_payload
from traderai.config import get_settings
from traderai.cornerstone_client import CornerstoneClient
from traderai.memory import DEFAULT_THREAD_ID, MemoryStore
from traderai.negotiations import NegotiationSyncService
from traderai.plans import ContinualPlanRunner, ContinualPlanStore
from traderai.scheduler import WakeScheduler
from traderai.scmdb_client import SCMDBClient
@@ -31,6 +32,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:
@@ -62,6 +64,21 @@ class DirectNegotiationMessageRequest(BaseModel):
message: str
class NegotiationDraftMessageRequest(BaseModel):
message: str
class NegotiationCloseRequest(BaseModel):
deal_closed: bool
deal_value: float | None = None
currency: str | None = None
clarity_rating: int | None = None
speed_rating: int | None = None
respect_rating: int | None = None
fairness_rating: int | None = None
comment: str | None = None
class ClearMemoryRequest(BaseModel):
include_memories: bool = True
include_conversations: bool = True
@@ -85,6 +102,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
@@ -111,20 +136,42 @@ def create_app() -> FastAPI:
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)
uex = UEXClient(
current_settings.uex_base_url,
current_settings.uex_secret_key,
current_settings.uex_bearer_token,
negotiation_close_endpoint=current_settings.uex_negotiation_close_endpoint,
)
negotiation_sync = NegotiationSyncService(memory, uex)
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,
)
wikelo = WikeloProjectsClient()
try:
tools = ToolRegistry(
uex,
current_settings.require_write_approval,
memory=memory,
scheduler=scheduler,
scmdb=scmdb,
cornerstone=cornerstone,
scwiki=scwiki,
wikelo=wikelo,
plan_store=plan_store,
negotiation_sync=negotiation_sync,
)
except TypeError:
tools = ToolRegistry(
uex,
current_settings.require_write_approval,
memory=memory,
scheduler=scheduler,
scmdb=scmdb,
cornerstone=cornerstone,
scwiki=scwiki,
wikelo=wikelo,
plan_store=plan_store,
)
plan_runner = ContinualPlanRunner(plan_store, tools, memory)
tools.plan_runner = plan_runner
provider_base_url, provider_model, provider_api_key = provider_settings(current_settings)
@@ -143,6 +190,8 @@ def create_app() -> FastAPI:
scheduler.bind_agent(agent)
scheduler.bind_plan_runner(plan_runner)
scheduler.bind_uex_notifications(uex, current_settings.uex_notification_poll_seconds)
if hasattr(scheduler, "bind_negotiation_sync"):
scheduler.bind_negotiation_sync(negotiation_sync)
runtime.update(
{
"settings": current_settings,
@@ -150,6 +199,7 @@ def create_app() -> FastAPI:
"tools": tools,
"plan_runner": plan_runner,
"agent": agent,
"negotiation_sync": negotiation_sync,
}
)
@@ -162,6 +212,10 @@ def create_app() -> FastAPI:
@app.on_event("startup")
async def startup() -> None:
await refresh_user_profile()
try:
await runtime["negotiation_sync"].startup_sync()
except Exception:
memory.set_profile("uex_last_negotiation_sync_error", "startup_sync_failed")
scheduler.start()
@app.on_event("shutdown")
@@ -457,8 +511,78 @@ def create_app() -> FastAPI:
deleted = memory.delete_outbox(inbox_id)
return {"deleted": deleted, "inbox": memory.list_outbox()}
@app.get("/api/negotiations")
async def negotiations(status: str = "all", unread_only: bool = False, search: str = "", limit: int = 50) -> dict:
negotiation_sync = runtime["negotiation_sync"]
return {
"negotiations": negotiation_sync.list_negotiations(
status=status,
unread_only=unread_only,
search=search,
limit=limit,
)
}
@app.get("/api/negotiations/unread-count")
async def negotiations_unread_count() -> dict:
return {"unread_count": runtime["negotiation_sync"].unread_count()}
@app.post("/api/negotiations/refresh-all")
async def negotiations_refresh_all() -> dict:
result = await runtime["negotiation_sync"].refresh_negotiations(seed_open_messages=True)
return result
@app.get("/api/negotiations/{identifier}")
async def negotiation_detail(identifier: str) -> dict:
negotiation_sync = runtime["negotiation_sync"]
negotiation = negotiation_sync.get_negotiation(identifier, mark_read=True)
if not negotiation:
raise HTTPException(status_code=404, detail="Negotiation not found.")
return {"negotiation": negotiation}
@app.post("/api/negotiations/{identifier}/refresh")
async def refresh_negotiation(identifier: str) -> dict:
negotiation_sync = runtime["negotiation_sync"]
result = await negotiation_sync.refresh_negotiation(identifier, mark_read=False)
return {"negotiation": negotiation_sync.get_negotiation(identifier, mark_read=False), "refreshed": result.refreshed}
@app.post("/api/negotiations/{identifier}/open-chat")
async def open_negotiation_chat(identifier: str) -> dict:
negotiation_sync = runtime["negotiation_sync"]
negotiation = negotiation_sync.get_negotiation(identifier, mark_read=False)
if not negotiation:
raise HTTPException(status_code=404, detail="Negotiation not found.")
thread = memory.create_thread(negotiation.get("title") or f"Negotiation {identifier}")
context = {
"hash": negotiation.get("hash"),
"title": negotiation.get("title"),
"counterparty_username": negotiation.get("counterparty_username"),
"status": negotiation.get("status"),
"unread_count": negotiation.get("unread_count"),
"last_message_at": negotiation.get("last_message_at"),
"recent_messages": [
{
"author_username": item.get("author_username"),
"is_me": item.get("is_me"),
"body": item.get("body"),
"sent_at": item.get("sent_at"),
}
for item in (negotiation.get("messages") or [])[-8:]
],
}
memory.add_conversation(
"assistant",
"Negotiation context loaded:\n" + json.dumps(context, ensure_ascii=True, indent=2),
thread["id"],
)
return {"chat": thread, "negotiation": negotiation}
@app.get("/api/negotiations/{identifier}/messages")
async def negotiation_messages(identifier: str) -> dict:
negotiation_sync = runtime["negotiation_sync"]
negotiation = negotiation_sync.get_negotiation(identifier, mark_read=True)
if negotiation:
return {"messages": negotiation.get("messages", []), "negotiation": negotiation}
uex = runtime["uex"]
params = negotiation_identifier_params(identifier)
return await uex.get("marketplace_negotiations_messages", params, authenticated=True)
@@ -470,6 +594,40 @@ def create_app() -> FastAPI:
payload = {**params, "message": request.message, "is_production": 1}
return await uex.post("marketplace_negotiations_messages", payload, authenticated=True)
@app.post("/api/negotiations/{identifier}/messages/manual")
async def send_negotiation_message_manual(identifier: str, request: DirectNegotiationMessageRequest) -> dict:
result = await runtime["negotiation_sync"].manual_send_message(identifier, request.message)
return {
**result,
"message": "Sent",
"negotiation": runtime["negotiation_sync"].get_negotiation(identifier, mark_read=True),
}
@app.post("/api/negotiations/{identifier}/messages/draft")
async def draft_negotiation_message(identifier: str, request: NegotiationDraftMessageRequest) -> dict:
tools = runtime["tools"]
result = await tools.draft_negotiation_message(hash=identifier, message=request.message)
if result.get("error"):
raise HTTPException(status_code=400, detail=result["error"])
return result
@app.post("/api/negotiations/{identifier}/close/draft")
async def draft_negotiation_close(identifier: str, request: NegotiationCloseRequest) -> dict:
tools = runtime["tools"]
result = await tools.draft_negotiation_close(hash=identifier, **request.model_dump())
if result.get("error"):
raise HTTPException(status_code=400, detail=result["error"])
return result
@app.post("/api/negotiations/{identifier}/close/manual")
async def close_negotiation_manual(identifier: str, request: NegotiationCloseRequest) -> dict:
result = await runtime["negotiation_sync"].manual_close_negotiation(identifier, request.model_dump())
return {
**result,
"message": "Deal submitted",
"negotiation": runtime["negotiation_sync"].get_negotiation(identifier, mark_read=True),
}
@app.get("/api/wake-jobs")
async def wake_jobs() -> dict:
return {"scheduled_jobs": scheduler.list_jobs()}
@@ -493,6 +651,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 +762,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 +774,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 +820,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 +831,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 +855,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 +884,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 +969,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 +1244,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"),
+306
View File
@@ -8,10 +8,12 @@ from typing import Any, Awaitable, Callable
from traderai.cornerstone_client import CornerstoneClient, parse_cornerstone_item_page
from traderai.memory import MemoryStore
from traderai.negotiations import UEX_NEGOTIATION_CLOSE_ENDPOINT
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]]]
@@ -149,6 +151,7 @@ UEX_RESOURCE_DESCRIPTIONS = {
UEX_PRODUCTION_WRITE_RESOURCES = {
"marketplace_advertise",
"marketplace_negotiations_messages",
UEX_NEGOTIATION_CLOSE_ENDPOINT,
}
@@ -172,18 +175,22 @@ 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,
negotiation_sync: Any | None = None,
) -> None:
self.uex = uex
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
self.plan_store = plan_store
self.plan_runner = plan_runner
self.negotiation_sync = negotiation_sync
self.pending_actions: dict[str, PendingAction] = {}
self._chat_images_var: ContextVar[list[dict[str, Any]]] = ContextVar("chat_images", default=[])
self.handlers: dict[str, ToolHandler] = {
@@ -193,6 +200,11 @@ class ToolRegistry:
"list_marketplace_negotiations": self.list_marketplace_negotiations,
"get_negotiation_messages": self.get_negotiation_messages,
"draft_negotiation_message": self.draft_negotiation_message,
"list_local_negotiations": self.list_local_negotiations,
"get_local_negotiation": self.get_local_negotiation,
"search_local_negotiation_messages": self.search_local_negotiation_messages,
"draft_negotiation_close": self.draft_negotiation_close,
"draft_negotiation_rating": self.draft_negotiation_rating,
"draft_marketplace_listing": self.draft_marketplace_listing,
"remember_user_fact": self.remember_user_fact,
"recall_memory": self.recall_memory,
@@ -214,6 +226,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 +258,7 @@ class ToolRegistry:
*self._uex_delete_schemas(),
*self._scmdb_schemas(),
*self._scwiki_schemas(),
*self._wikelo_schemas(),
*self._cornerstone_schemas(),
{
"type": "function",
@@ -348,6 +363,97 @@ class ToolRegistry:
},
},
},
{
"type": "function",
"function": {
"name": "list_local_negotiations",
"description": "List locally synced UEX negotiations with unread and status details.",
"parameters": {
"type": "object",
"properties": {
"status": {"type": "string", "enum": ["all", "open", "closed"]},
"unread_only": {"type": "boolean"},
"search": {"type": "string"},
"limit": {"type": "integer", "minimum": 1, "maximum": 50},
},
},
},
},
{
"type": "function",
"function": {
"name": "get_local_negotiation",
"description": "Get a locally synced UEX negotiation with compact metadata and recent messages.",
"parameters": {
"type": "object",
"properties": {
"hash": {"type": "string"},
},
"required": ["hash"],
},
},
},
{
"type": "function",
"function": {
"name": "search_local_negotiation_messages",
"description": "Search locally cached negotiation message text so the assistant can reference prior UEX conversations without re-fetching them.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer", "minimum": 1, "maximum": 20},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "draft_negotiation_close",
"description": "Draft closing or rating a UEX negotiation. This creates a pending action that must be approved before sending.",
"parameters": {
"type": "object",
"properties": {
"hash": {"type": "string"},
"id_negotiation": {"type": "integer"},
"deal_closed": {"type": "boolean"},
"deal_value": {"type": "number"},
"currency": {"type": "string"},
"clarity_rating": {"type": "integer", "minimum": 1, "maximum": 5},
"speed_rating": {"type": "integer", "minimum": 1, "maximum": 5},
"respect_rating": {"type": "integer", "minimum": 1, "maximum": 5},
"fairness_rating": {"type": "integer", "minimum": 1, "maximum": 5},
"comment": {"type": "string"},
},
"required": ["deal_closed"],
},
},
},
{
"type": "function",
"function": {
"name": "draft_negotiation_rating",
"description": "Alias for drafting a UEX negotiation close/rating action.",
"parameters": {
"type": "object",
"properties": {
"hash": {"type": "string"},
"id_negotiation": {"type": "integer"},
"deal_closed": {"type": "boolean"},
"deal_value": {"type": "number"},
"currency": {"type": "string"},
"clarity_rating": {"type": "integer", "minimum": 1, "maximum": 5},
"speed_rating": {"type": "integer", "minimum": 1, "maximum": 5},
"respect_rating": {"type": "integer", "minimum": 1, "maximum": 5},
"fairness_rating": {"type": "integer", "minimum": 1, "maximum": 5},
"comment": {"type": "string"},
},
"required": ["deal_closed"],
},
},
},
{
"type": "function",
"function": {
@@ -1071,6 +1177,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 [
@@ -1379,6 +1518,37 @@ class ToolRegistry:
async def get_negotiation_messages(self, hash: str | None = None, id_negotiation: int | None = None) -> dict[str, Any]:
return await self.uex.get("marketplace_negotiations_messages", {"hash": hash, "id_negotiation": id_negotiation}, authenticated=True)
async def list_local_negotiations(
self,
status: str = "all",
unread_only: bool = False,
search: str = "",
limit: int = 10,
) -> dict[str, Any]:
if self.negotiation_sync is None:
return {"error": "Negotiation sync is not configured."}
negotiations = self.negotiation_sync.list_negotiations(
status=status,
unread_only=unread_only,
search=search,
limit=limit,
)
return {"count": len(negotiations), "negotiations": negotiations}
async def get_local_negotiation(self, hash: str) -> dict[str, Any]:
if self.negotiation_sync is None:
return {"error": "Negotiation sync is not configured."}
negotiation = self.negotiation_sync.get_negotiation(hash, mark_read=False)
if not negotiation:
return {"error": f"Negotiation not found: {hash}"}
return {"negotiation": negotiation}
async def search_local_negotiation_messages(self, query: str, limit: int = 8) -> dict[str, Any]:
if self.negotiation_sync is None:
return {"error": "Negotiation sync is not configured."}
matches = self.negotiation_sync.search_messages(query, limit=limit)
return {"count": len(matches), "matches": matches}
async def draft_negotiation_message(
self,
message: str,
@@ -1414,6 +1584,41 @@ class ToolRegistry:
metadata=attached_image.get("metadata"),
)
async def draft_negotiation_close(
self,
deal_closed: bool,
hash: str | None = None,
id_negotiation: int | None = None,
deal_value: float | None = None,
currency: str | None = None,
clarity_rating: int | None = None,
speed_rating: int | None = None,
respect_rating: int | None = None,
fairness_rating: int | None = None,
comment: str | None = None,
) -> dict[str, Any]:
payload = {
"hash": hash,
"id_negotiation": id_negotiation,
"deal_closed": 1 if deal_closed else 0,
"deal_value": deal_value,
"currency": currency,
"clarity_rating": clarity_rating,
"speed_rating": speed_rating,
"respect_rating": respect_rating,
"fairness_rating": fairness_rating,
"comment": comment,
}
metadata = {
"hash": hash,
"id_negotiation": id_negotiation,
"kind": "negotiation_close",
}
return self._pending("Close negotiation", UEX_NEGOTIATION_CLOSE_ENDPOINT, payload, metadata=metadata)
async def draft_negotiation_rating(self, **payload: Any) -> dict[str, Any]:
return await self.draft_negotiation_close(**payload)
async def draft_marketplace_listing_with_cornerstone_image(
self,
item_query: str,
@@ -1740,6 +1945,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 +2742,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")
+96 -1
View File
@@ -10,10 +10,17 @@ class UEXError(RuntimeError):
class UEXClient:
def __init__(self, base_url: str, secret_key: str | None = None, bearer_token: str | None = None) -> None:
def __init__(
self,
base_url: str,
secret_key: str | None = None,
bearer_token: str | None = None,
negotiation_close_endpoint: str = "marketplace_negotiations_close",
) -> None:
self.base_url = base_url.rstrip("/")
self.secret_key = secret_key
self.bearer_token = bearer_token
self.negotiation_close_endpoint = negotiation_close_endpoint.strip().strip("/") or "marketplace_negotiations_close"
def _headers(self, authenticated: bool = False) -> dict[str, str]:
headers = {"Accept": "application/json"}
@@ -49,6 +56,94 @@ class UEXClient:
data = [data]
return {"status": body.get("status"), "notifications": data}
async def list_negotiations(
self,
id: int | None = None,
id_listing: int | None = None,
hash: str | None = None,
) -> dict[str, Any]:
body = await self.get(
"marketplace_negotiations",
{"id": id, "id_listing": id_listing, "hash": hash},
authenticated=True,
)
data = body.get("data") or []
if isinstance(data, dict):
data = [data]
return {"status": body.get("status"), "negotiations": data}
async def get_negotiation_messages(self, hash: str | None = None, id_negotiation: int | None = None) -> dict[str, Any]:
body = await self.get(
"marketplace_negotiations_messages",
{"hash": hash, "id_negotiation": id_negotiation},
authenticated=True,
)
data = body.get("data") or []
if isinstance(data, dict):
data = [data]
return {"status": body.get("status"), "messages": data}
async def send_negotiation_message(
self,
*,
message: str,
hash: str | None = None,
id_negotiation: int | None = None,
is_production: int = 1,
) -> dict[str, Any]:
return await self.post(
"marketplace_negotiations_messages",
{
"hash": hash,
"id_negotiation": id_negotiation,
"message": message,
"is_production": is_production,
},
authenticated=True,
)
async def close_negotiation(
self,
*,
hash: str | None = None,
id_negotiation: int | None = None,
deal_closed: bool,
deal_value: float | None = None,
currency: str | None = None,
clarity_rating: int | None = None,
speed_rating: int | None = None,
respect_rating: int | None = None,
fairness_rating: int | None = None,
comment: str | None = None,
is_production: int = 1,
) -> dict[str, Any]:
payload = {
"hash": hash,
"id_negotiation": id_negotiation,
"deal_closed": 1 if deal_closed else 0,
"deal_value": deal_value,
"currency": currency,
"clarity_rating": clarity_rating,
"speed_rating": speed_rating,
"respect_rating": respect_rating,
"fairness_rating": fairness_rating,
"comment": comment,
"is_production": is_production,
}
try:
return await self.post(
self.negotiation_close_endpoint,
payload,
authenticated=True,
)
except UEXError as exc:
raise UEXError(
"UEX negotiation close failed via endpoint "
f"`{self.negotiation_close_endpoint}`. If UEX changed this route, set "
"`UEX_NEGOTIATION_CLOSE_ENDPOINT` to the correct endpoint and retry. "
f"Original error: {exc}"
) from exc
async def post(self, path: str, payload: dict[str, Any], authenticated: bool = True) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
+4 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
__version__ = "0.0.6"
__version__ = "0.0.9"
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,6 @@ RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingCo
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from typing import Any
import httpx
class WikeloProjectsError(RuntimeError):
pass
class WikeloProjectsClient:
APP_ID = "695be2905c0b4866dfb21265"
def __init__(self, base_url: str = "https://wikelo-projects.com") -> None:
self.base_url = base_url.rstrip("/")
async def list_ship_projects(self) -> list[dict[str, Any]]:
body = await self._get_json(f"{self.base_url}/api/apps/{self.APP_ID}/entities/ShipProject")
if not isinstance(body, list):
raise WikeloProjectsError("Wikelo ship projects response was not a list.")
return [item for item in body if isinstance(item, dict)]
async def _get_json(self, url: str) -> Any:
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
response = await client.get(url, headers={"Accept": "application/json"})
try:
body = response.json()
except ValueError as exc:
raise WikeloProjectsError(f"Wikelo Projects returned non-JSON response: HTTP {response.status_code}") from exc
if response.status_code >= 400:
raise WikeloProjectsError(f"Wikelo Projects HTTP {response.status_code}: {body}")
return body
File diff suppressed because one or more lines are too long
Generated
+4 -1
View File
@@ -755,7 +755,7 @@ wheels = [
[[package]]
name = "traderai"
version = "0.0.6"
version = "0.0.9"
source = { virtual = "." }
dependencies = [
{ name = "apscheduler" },
@@ -1053,3 +1053,6 @@ wheels = [
+420 -114
View File
@@ -15,6 +15,7 @@ const configPathsEl = document.getElementById("config-paths");
const settingsToggle = document.getElementById("settings-toggle");
const memoryToggle = document.getElementById("memory-toggle");
const plansToggle = document.getElementById("plans-toggle");
const negotiationsToggle = document.getElementById("negotiations-toggle");
const ollamaToggle = document.getElementById("ollama-toggle");
const settingsPanel = document.getElementById("settings-panel");
const memoryPanel = document.getElementById("memory-panel");
@@ -26,9 +27,8 @@ const ollamaDownloadButton = document.getElementById("ollama-download");
const ollamaInstallButton = document.getElementById("ollama-install");
const ollamaLaunchButton = document.getElementById("ollama-launch");
const ollamaPullButton = document.getElementById("ollama-pull");
const codexLoginButton = document.getElementById("codex-login");
const openaiModelsRefreshButton = document.getElementById("openai-models-refresh");
const providerModelSelect = document.getElementById("provider-model-select");
const providerModelLabel = document.getElementById("provider-model-label");
const modelReasoningEffortSelect = document.getElementById("model-reasoning-effort");
const ollamaStatusEl = document.getElementById("ollama-status");
const ollamaMessageEl = document.getElementById("ollama-message");
@@ -49,6 +49,32 @@ const negotiationForm = document.getElementById("negotiation-form");
const negotiationInput = document.getElementById("negotiation-input");
const negotiationStatusEl = document.getElementById("negotiation-status");
const negotiationCloseButton = document.getElementById("negotiation-close");
const negotiationListEl = document.getElementById("negotiation-list");
const negotiationsRefreshAllButton = document.getElementById("negotiations-refresh-all");
const negotiationPanelListEl = document.getElementById("negotiation-panel-list");
const negotiationSearchEl = document.getElementById("negotiation-search");
const negotiationFilterEl = document.getElementById("negotiation-filter");
const negotiationThreadHeaderEl = document.getElementById("negotiation-thread-header");
const negotiationMetaCardEl = document.getElementById("negotiation-meta-card");
const negotiationUserCardEl = document.getElementById("negotiation-user-card");
const negotiationRefreshButton = document.getElementById("negotiation-refresh-button");
const negotiationDraftButton = document.getElementById("negotiation-draft-button");
const negotiationOpenChatButton = document.getElementById("negotiation-open-chat");
const negotiationEndDealButton = document.getElementById("negotiation-end-deal");
const negotiationSyncPillEl = document.getElementById("negotiation-sync-pill");
const negotiationCloseModal = document.getElementById("negotiation-close-modal");
const negotiationCloseModalClose = document.getElementById("negotiation-close-modal-close");
const negotiationCloseForm = document.getElementById("negotiation-close-form");
const negotiationCloseStatusEl = document.getElementById("negotiation-close-status");
const closeDealClosedEl = document.getElementById("close-deal-closed");
const closeDealValueEl = document.getElementById("close-deal-value");
const closeCurrencyEl = document.getElementById("close-currency");
const closeClarityEl = document.getElementById("close-clarity");
const closeSpeedEl = document.getElementById("close-speed");
const closeRespectEl = document.getElementById("close-respect");
const closeFairnessEl = document.getElementById("close-fairness");
const closeCommentEl = document.getElementById("close-comment");
const closeDraftButton = document.getElementById("close-draft-button");
const updateModal = document.getElementById("update-modal");
const updateModalCopy = document.getElementById("update-modal-copy");
const updateModalClose = document.getElementById("update-modal-close");
@@ -57,6 +83,7 @@ const updateModalReleases = document.getElementById("update-modal-releases");
const plansRefreshButton = document.getElementById("plans-refresh");
const plansCloseButton = document.getElementById("plans-close");
const planForm = document.getElementById("plan-form");
const planAutofillButton = document.getElementById("plan-autofill");
const plansStatusEl = document.getElementById("plans-status");
const plansDashboardEl = document.getElementById("plans-dashboard");
const plansRailListEl = document.getElementById("plans-rail-list");
@@ -66,6 +93,7 @@ let ollamaOnline = true;
let latestUpdate = null;
let currentThreadId = "default";
let currentNegotiationId = null;
let negotiationRows = [];
let latestOllamaStatus = null;
let composerImages = [];
const clickedOllamaActions = new Set();
@@ -164,6 +192,8 @@ function appendThinkingText(node, text) {
steps.appendChild(item);
}
item.textContent += text;
const thinking = node.querySelector(".thinking-log");
if (thinking && !thinking.open) thinking.open = true;
}
function createThinkTagParser(node) {
@@ -564,7 +594,8 @@ function renderComposerImages() {
function formatMetrics(event) {
const read = formatTokenMetric(event.reading_tokens, event.reading_tokens_per_second);
const wrote = formatTokenMetric(event.writing_tokens, event.writing_tokens_per_second);
return [read && `read ${read}`, wrote && `wrote ${wrote}`].filter(Boolean).join(" | ");
const cache = formatCacheMetric(event.cache_hit_tokens, event.cache_miss_tokens);
return [read && `read ${read}`, wrote && `wrote ${wrote}`, cache].filter(Boolean).join(" | ");
}
function formatTokenMetric(tokens, speed) {
@@ -573,6 +604,13 @@ function formatTokenMetric(tokens, speed) {
return `${tokens} tok${speedText}`;
}
function formatCacheMetric(hitTokens, missTokens) {
if (!hitTokens && !missTokens) return "";
const hit = Number(hitTokens || 0).toLocaleString();
const miss = Number(missTokens || 0).toLocaleString();
return `cache ${hit} hit / ${miss} miss`;
}
function setWarning(text) {
warningEl.hidden = !text;
warningEl.textContent = text || "";
@@ -597,15 +635,13 @@ const configFieldIds = {
const ollamaFieldIds = {
model_provider: "model-provider",
deepseek_base_url: "deepseek-base-url",
deepseek_api_key: "deepseek-api-key",
deepseek_model: "deepseek-model",
ollama_base_url: "ollama-base-url",
ollama_model: "ollama-model",
ollama_num_ctx: "ollama-num-ctx",
openai_base_url: "openai-base-url",
openai_api_key: "openai-api-key",
openai_model: "openai-model",
model_reasoning_effort: "model-reasoning-effort",
codex_command: "codex-command",
codex_model: "codex-model",
};
async function refreshConfig() {
@@ -667,7 +703,7 @@ async function saveConfig(event) {
const result = await response.json();
renderConfig(result);
configStatusEl.textContent = result.message || "Saved";
addMessage("assistant", "Config saved. Restart TraderAI for the new settings to fully apply.");
addMessage("assistant", result.message || "Config saved.");
} catch (error) {
configStatusEl.textContent = `Config save failed: ${fetchErrorMessage(error)}`;
}
@@ -715,18 +751,18 @@ function renderOllamaStatus(status) {
updateProviderFieldVisibility(status.provider || "ollama");
const provider = providerDisplayName(status.provider);
const models = status.models?.length ? status.models.join(", ") : "None detected";
const isOpenAIProvider = status.provider === "openai";
const isCodexProvider = status.provider === "codex";
const ready = isOpenAIProvider
const isDeepSeekProvider = status.provider === "deepseek";
const isCloudProvider = isDeepSeekProvider;
const ready = isCloudProvider
? Boolean(status.online && status.model_available)
: Boolean(status.installed && status.running && status.model_available);
const pillClass = ready ? "status-pill" : "status-pill warning";
const detailItems = [
ollamaStatusItem("Provider", provider),
ollamaStatusItem("Model", status.configured_model || ""),
ollamaStatusItem(isCodexProvider ? "Command" : "URL", status.base_url || ""),
ollamaStatusItem("URL", status.base_url || ""),
];
if (!isOpenAIProvider && !isCodexProvider) {
if (!isCloudProvider) {
detailItems.splice(1, 0, ollamaStatusItem("Installed", status.installed ? "Yes" : "No"));
detailItems.splice(2, 0, ollamaStatusItem("Running", status.running ? "Yes" : "No"));
detailItems.push(ollamaStatusItem("Pulled", status.model_available ? "Yes" : "No"));
@@ -740,30 +776,22 @@ function renderOllamaStatus(status) {
<div class="ollama-status-grid">
${detailItems.join("")}
</div>
${ollamaStatusItem(isOpenAIProvider || isCodexProvider ? "Available Models" : "Installed Models", models)}
${ollamaStatusItem(isCloudProvider ? "Available Models" : "Installed Models", models)}
${status.detail ? ollamaStatusItem("Detail", status.detail) : ""}
`;
if (ollamaDownloadButton) ollamaDownloadButton.hidden = isOpenAIProvider || isCodexProvider;
if (ollamaDownloadButton) ollamaDownloadButton.hidden = isCloudProvider;
if (ollamaInstallButton) {
ollamaInstallButton.hidden = isOpenAIProvider || isCodexProvider || !status.can_auto_install;
ollamaInstallButton.hidden = isCloudProvider || !status.can_auto_install;
ollamaInstallButton.disabled = Boolean(status.installed) || !status.can_auto_install;
}
if (ollamaLaunchButton) {
ollamaLaunchButton.hidden = isOpenAIProvider || isCodexProvider;
ollamaLaunchButton.hidden = isCloudProvider;
ollamaLaunchButton.disabled = !status.installed || Boolean(status.running);
}
if (ollamaPullButton) {
ollamaPullButton.hidden = isOpenAIProvider || isCodexProvider;
ollamaPullButton.hidden = isCloudProvider;
ollamaPullButton.disabled = !status.running || Boolean(status.model_available);
}
if (codexLoginButton) {
codexLoginButton.hidden = !isCodexProvider;
codexLoginButton.disabled = Boolean(status.online);
}
if (openaiModelsRefreshButton) {
openaiModelsRefreshButton.hidden = false;
openaiModelsRefreshButton.disabled = false;
}
renderProviderModelOptions(status.models || [], status);
renderReasoningEffortOptions(status.reasoning_efforts || [], status.configured_reasoning_effort || "medium");
updateOllamaAttention(status);
@@ -808,18 +836,16 @@ function setOllamaButtonAttention(button, action, active) {
function updateOllamaAttention(status = null) {
const currentStatus = status || latestOllamaStatus;
if (!currentStatus) return;
const isOpenAIProvider = currentStatus.provider === "openai";
const isCodexProvider = currentStatus.provider === "codex";
const ready = isOpenAIProvider
const isDeepSeekProvider = currentStatus.provider === "deepseek";
const isCloudProvider = isDeepSeekProvider;
const ready = isCloudProvider
? Boolean(currentStatus.online && currentStatus.model_available)
: Boolean(currentStatus.installed && currentStatus.running && currentStatus.model_available);
ollamaToggle?.classList.toggle("attention-pulse", !ready);
setOllamaButtonAttention(ollamaDownloadButton, "download", !isOpenAIProvider && !isCodexProvider && !currentStatus.installed);
setOllamaButtonAttention(ollamaInstallButton, "install", !isOpenAIProvider && !isCodexProvider && !currentStatus.installed && currentStatus.can_auto_install);
setOllamaButtonAttention(ollamaLaunchButton, "launch", !isOpenAIProvider && !isCodexProvider && currentStatus.installed && !currentStatus.running);
setOllamaButtonAttention(ollamaPullButton, "pull", !isOpenAIProvider && !isCodexProvider && currentStatus.running && !currentStatus.model_available);
setOllamaButtonAttention(codexLoginButton, "codex-login", isCodexProvider && !currentStatus.online);
setOllamaButtonAttention(openaiModelsRefreshButton, "openai-models", isOpenAIProvider && !currentStatus.model_available);
setOllamaButtonAttention(ollamaDownloadButton, "download", !isCloudProvider && !currentStatus.installed);
setOllamaButtonAttention(ollamaInstallButton, "install", !isCloudProvider && !currentStatus.installed && currentStatus.can_auto_install);
setOllamaButtonAttention(ollamaLaunchButton, "launch", !isCloudProvider && currentStatus.installed && !currentStatus.running);
setOllamaButtonAttention(ollamaPullButton, "pull", !isCloudProvider && currentStatus.running && !currentStatus.model_available);
if (ready) clickedOllamaActions.clear();
}
@@ -830,7 +856,11 @@ function configuredOllamaModel() {
function updateProviderFieldVisibility(provider) {
for (const field of providerScopedFields) {
const scope = field.dataset.providerScope;
field.hidden = scope !== provider;
const hiddenManualModel = field.dataset.manualModel === "true" && provider !== "ollama";
field.hidden = scope !== provider || hiddenManualModel;
}
if (providerModelLabel) {
providerModelLabel.textContent = provider === "ollama" ? "Available Models" : "Model";
}
}
@@ -867,54 +897,6 @@ function renderProviderModelOptions(models, status = latestOllamaStatus) {
}
}
async function refreshOpenAIModels() {
setOllamaMessage("Loading provider models");
try {
const provider = document.getElementById("model-provider")?.value || latestOllamaStatus?.provider || "openai";
const response = await fetch(`/api/provider/models?provider=${encodeURIComponent(provider)}`);
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
renderProviderModelOptions(result.models || [], {
provider: result.provider || provider,
configured_model: configuredProviderModel(result.provider || provider),
});
setOllamaMessage(result.message || "Loaded provider models");
await refreshOllamaStatus();
} catch (error) {
setOllamaMessage(`Provider model load failed: ${fetchErrorMessage(error)}`);
}
}
async function launchCodexLogin() {
markOllamaActionClicked("codex-login");
setOllamaMessage("Starting Codex sign-in");
try {
const response = await fetch("/api/codex/login", { method: "POST" });
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
if (result.auth_url) {
window.open(result.auth_url, "_blank", "noopener,noreferrer");
}
setOllamaMessage(result.message || "Opened Codex sign-in in your browser. Waiting for completion...");
await waitForCodexLogin();
} catch (error) {
setOllamaMessage(`Codex sign-in failed: ${fetchErrorMessage(error)}`);
}
}
async function waitForCodexLogin() {
for (let attempt = 0; attempt < 80; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, attempt < 8 ? 1500 : 3000));
await refreshOllamaStatus();
const provider = latestOllamaStatus?.provider || "";
if (provider === "codex" && latestOllamaStatus?.online) {
setOllamaMessage("Codex sign-in complete.");
return;
}
}
setOllamaMessage("Codex sign-in opened. If you completed it, click Load Provider Models or refresh provider status.");
}
function renderReasoningEffortOptions(efforts, configured) {
if (!modelReasoningEffortSelect) return;
const options = [...new Set([...(efforts || []), configured || "medium"].filter(Boolean))];
@@ -929,8 +911,7 @@ function renderReasoningEffortOptions(efforts, configured) {
}
function configuredProviderModel(provider) {
if (provider === "openai") return document.getElementById("openai-model")?.value || "";
if (provider === "codex") return document.getElementById("codex-model")?.value || "";
if (provider === "deepseek") return document.getElementById("deepseek-model")?.value || "";
return document.getElementById("ollama-model")?.value || "";
}
@@ -938,13 +919,8 @@ function syncSelectedProviderModel() {
const provider = document.getElementById("model-provider")?.value || "ollama";
const selectedModel = providerModelSelect?.value || "";
if (!selectedModel) return;
if (provider === "openai") {
const field = document.getElementById("openai-model");
if (field) field.value = selectedModel;
return;
}
if (provider === "codex") {
const field = document.getElementById("codex-model");
if (provider === "deepseek") {
const field = document.getElementById("deepseek-model");
if (field) field.value = selectedModel;
return;
}
@@ -953,8 +929,7 @@ function syncSelectedProviderModel() {
}
function providerDisplayName(provider) {
if (provider === "openai") return "OpenAI";
if (provider === "codex") return "Codex";
if (provider === "deepseek") return "DeepSeek";
return "Local Ollama";
}
@@ -1235,16 +1210,114 @@ async function deleteInboxItem(id) {
await refreshInbox();
}
async function refreshNegotiations(preserveCurrent = true) {
const status = negotiationFilterEl?.value || "open";
const search = negotiationSearchEl?.value?.trim() || "";
try {
const response = await fetch(`/api/negotiations?status=${encodeURIComponent(status)}&search=${encodeURIComponent(search)}&limit=100`);
const result = await response.json();
negotiationRows = result.negotiations || [];
renderNegotiationLists(negotiationRows);
if (!preserveCurrent) return;
if (!currentNegotiationId && negotiationRows.length) currentNegotiationId = negotiationRows[0].hash;
if (currentNegotiationId && negotiationRows.some((item) => item.hash === currentNegotiationId) && !negotiationPanel.hidden) {
await loadNegotiationDetail(currentNegotiationId, false);
}
} catch (error) {
const message = `Negotiations failed: ${fetchErrorMessage(error)}`;
if (negotiationListEl) negotiationListEl.textContent = message;
if (negotiationPanelListEl) negotiationPanelListEl.textContent = message;
}
}
async function refreshAllNegotiations() {
const previous = negotiationsRefreshAllButton?.disabled;
if (negotiationsRefreshAllButton) negotiationsRefreshAllButton.disabled = true;
if (negotiationStatusEl) negotiationStatusEl.textContent = "Refreshing all negotiations";
try {
const response = await fetch("/api/negotiations/refresh-all", { method: "POST" });
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
await refreshNegotiations(true);
if (negotiationStatusEl) {
negotiationStatusEl.textContent = `Refreshed ${result.count || 0} negotiations`;
}
} catch (error) {
if (negotiationStatusEl) negotiationStatusEl.textContent = `Refresh all failed: ${fetchErrorMessage(error)}`;
} finally {
if (negotiationsRefreshAllButton) negotiationsRefreshAllButton.disabled = Boolean(previous);
}
}
function renderNegotiationLists(items) {
renderNegotiationListInto(negotiationListEl, items.slice(0, 8));
renderNegotiationListInto(negotiationPanelListEl, items);
}
function renderNegotiationListInto(container, items) {
if (!container) return;
container.innerHTML = "";
if (!items.length) {
container.innerHTML = '<div class="pending-empty">No negotiations</div>';
return;
}
for (const item of items) {
const row = document.createElement("button");
row.type = "button";
row.className = `negotiation-row${item.hash === currentNegotiationId ? " active" : ""}`;
row.addEventListener("click", () => openNegotiationPanel(item.hash));
const top = document.createElement("div");
top.className = "negotiation-row-top";
const title = document.createElement("div");
title.className = "negotiation-row-title";
title.textContent = item.title || item.counterparty_username || item.hash;
const badge = document.createElement("span");
badge.className = `negotiation-row-badge ${item.status === "closed" ? "closed" : ""}`;
badge.textContent = item.status || "open";
top.append(title, badge);
const meta = document.createElement("div");
meta.className = "negotiation-row-meta";
meta.textContent = [
item.counterparty_username || "Unknown user",
item.last_message_at ? formatShortDate(item.last_message_at) : "No messages",
].join(" • ");
row.append(top, meta);
if (Number(item.unread_count || 0) > 0) {
const unread = document.createElement("span");
unread.className = "negotiation-row-unread";
unread.textContent = String(item.unread_count);
row.appendChild(unread);
}
container.appendChild(row);
}
}
async function openNegotiationPanel(identifier) {
if (!identifier) {
negotiationPanel.hidden = false;
negotiationsToggle?.setAttribute("aria-expanded", "true");
return;
}
currentNegotiationId = identifier;
negotiationPanel.hidden = false;
negotiationTitle.textContent = `Negotiation ${identifier}`;
negotiationsToggle?.setAttribute("aria-expanded", "true");
negotiationStatusEl.textContent = "";
negotiationSyncPillEl.textContent = "Local sync";
await loadNegotiationDetail(identifier, true);
}
async function loadNegotiationDetail(identifier, refreshList = true) {
negotiationTitle.textContent = `Negotiation ${identifier}`;
negotiationMessagesEl.textContent = "Loading";
negotiationThreadHeaderEl.innerHTML = '<div class="muted">Loading local thread...</div>';
negotiationMetaCardEl.innerHTML = "<h3>Deal</h3><div class='muted'>Loading</div>";
negotiationUserCardEl.innerHTML = "<h3>User</h3><div class='muted'>Loading</div>";
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(identifier)}/messages`);
const response = await fetch(`/api/negotiations/${encodeURIComponent(identifier)}`);
const result = await response.json();
renderNegotiationMessages(result.data || result.messages || result.notifications || []);
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
renderNegotiationDetail(result.negotiation);
if (refreshList) await refreshNegotiations(false);
} catch (error) {
negotiationMessagesEl.textContent = `Could not load negotiation: ${fetchErrorMessage(error)}`;
}
@@ -1255,6 +1328,7 @@ function closeNegotiationPanel() {
currentNegotiationId = null;
negotiationInput.value = "";
negotiationStatusEl.textContent = "";
negotiationsToggle?.setAttribute("aria-expanded", "false");
}
function openPlansPanel(openPlanId = null) {
@@ -1270,6 +1344,37 @@ function closePlansPanel() {
plansToggle?.setAttribute("aria-expanded", "false");
}
function renderNegotiationDetail(negotiation) {
if (!negotiation) return;
negotiationTitle.textContent = negotiation.title || negotiation.counterparty_username || negotiation.hash;
negotiationSyncPillEl.textContent = negotiation.last_synced_at ? `Synced ${formatShortDate(negotiation.last_synced_at)}` : "Local sync";
negotiationThreadHeaderEl.innerHTML = `
<div><strong>${escapeHtml(negotiation.title || "Negotiation")}</strong></div>
<div class="muted">${escapeHtml(negotiation.counterparty_username || "Unknown user")} ${escapeHtml(negotiation.status || "open")} ${escapeHtml(negotiation.hash || "")}</div>
`;
renderNegotiationMessages(negotiation.messages || []);
const raw = negotiation.metadata?.raw || {};
negotiationMetaCardEl.innerHTML = `
<h3>Deal</h3>
<div class="negotiation-detail-kv">
<div><strong>Listing</strong> ${escapeHtml(negotiation.title || raw.listing_title || negotiation.hash)}</div>
<div><strong>Status</strong> ${escapeHtml(negotiation.status || "open")}</div>
<div><strong>Slug</strong> ${escapeHtml(negotiation.listing_slug || raw.listing_slug || "Unknown")}</div>
<div><strong>Price</strong> ${escapeHtml(String(raw.price || raw.deal_value || "Unknown"))} ${escapeHtml(String(raw.currency || raw.deal_value_currency || ""))}</div>
<div><strong>Last message</strong> ${escapeHtml(negotiation.last_message_at ? formatShortDate(negotiation.last_message_at) : "Unknown")}</div>
</div>
`;
negotiationUserCardEl.innerHTML = `
<h3>User</h3>
<div class="negotiation-detail-kv">
<div><strong>Counterparty</strong> ${escapeHtml(negotiation.counterparty_username || raw.client_username || raw.advertiser_username || "Unknown")}</div>
<div><strong>Advertiser</strong> ${escapeHtml(String(raw.advertiser_username || raw.advertiser_name || "Unknown"))}</div>
<div><strong>Client</strong> ${escapeHtml(String(raw.client_username || raw.client_name || "Unknown"))}</div>
<div><strong>Unread</strong> ${escapeHtml(String(negotiation.unread_count || 0))}</div>
</div>
`;
}
function renderNegotiationMessages(data) {
negotiationMessagesEl.innerHTML = "";
const items = Array.isArray(data) ? data : [data].filter(Boolean);
@@ -1279,10 +1384,15 @@ function renderNegotiationMessages(data) {
}
for (const item of items) {
const card = document.createElement("div");
card.className = "negotiation-message";
const author = item.user_username || item.username || item.author || item.sender || "UEX";
const body = item.message || item.content || item.text || JSON.stringify(item, null, 2);
card.innerHTML = `<strong>${escapeHtml(String(author))}</strong><br>${inlineMarkdown(String(body))}`;
card.className = `negotiation-message${item.is_me ? " self" : ""}`;
const author = item.author_username || item.user_username || item.username || item.author || item.sender || "UEX";
const body = item.body || item.message || item.content || item.text || JSON.stringify(item, null, 2);
const meta = document.createElement("div");
meta.className = "negotiation-message-meta";
meta.innerHTML = `<strong>${escapeHtml(String(author))}</strong><span>${escapeHtml(item.sent_at ? formatShortDate(item.sent_at) : "")}</span>`;
const text = document.createElement("div");
text.innerHTML = inlineMarkdown(String(body));
card.append(meta, text);
negotiationMessagesEl.appendChild(card);
}
negotiationMessagesEl.scrollTop = negotiationMessagesEl.scrollHeight;
@@ -1294,7 +1404,7 @@ async function submitNegotiationMessage(event) {
if (!text || !currentNegotiationId) return;
negotiationStatusEl.textContent = "Sending";
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/messages`, {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/messages/manual`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
@@ -1303,12 +1413,125 @@ async function submitNegotiationMessage(event) {
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
negotiationInput.value = "";
negotiationStatusEl.textContent = result.message || "Sent";
await openNegotiationPanel(currentNegotiationId);
if (result.negotiation) renderNegotiationDetail(result.negotiation);
await refreshNegotiations(false);
} catch (error) {
negotiationStatusEl.textContent = `Send failed: ${fetchErrorMessage(error)}`;
}
}
async function draftNegotiationMessage() {
const text = negotiationInput.value.trim();
if (!text || !currentNegotiationId) return;
negotiationStatusEl.textContent = "Drafting";
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/messages/draft`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
});
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
negotiationStatusEl.textContent = "Draft ready for approval";
await refreshPending();
} catch (error) {
negotiationStatusEl.textContent = `Draft failed: ${fetchErrorMessage(error)}`;
}
}
async function refreshActiveNegotiation() {
if (!currentNegotiationId) return;
negotiationStatusEl.textContent = "Refreshing";
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/refresh`, { method: "POST" });
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
if (result.negotiation) renderNegotiationDetail(result.negotiation);
negotiationStatusEl.textContent = "Refreshed";
await refreshNegotiations(false);
} catch (error) {
negotiationStatusEl.textContent = `Refresh failed: ${fetchErrorMessage(error)}`;
}
}
async function openNegotiationInChat() {
if (!currentNegotiationId) return;
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/open-chat`, { method: "POST" });
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
if (result.chat?.id) {
currentThreadId = result.chat.id;
await loadChatMessages(currentThreadId);
await refreshChats();
}
} catch (error) {
negotiationStatusEl.textContent = `Open chat failed: ${fetchErrorMessage(error)}`;
}
}
function openNegotiationCloseModal() {
if (!currentNegotiationId) return;
negotiationCloseStatusEl.textContent = "";
negotiationCloseModal.hidden = false;
}
function closeNegotiationCloseModal() {
negotiationCloseModal.hidden = true;
}
function negotiationClosePayload() {
return {
deal_closed: closeDealClosedEl.value !== "false",
deal_value: closeDealValueEl.value ? Number(closeDealValueEl.value) : null,
currency: closeCurrencyEl.value.trim() || null,
clarity_rating: closeClarityEl.value ? Number(closeClarityEl.value) : null,
speed_rating: closeSpeedEl.value ? Number(closeSpeedEl.value) : null,
respect_rating: closeRespectEl.value ? Number(closeRespectEl.value) : null,
fairness_rating: closeFairnessEl.value ? Number(closeFairnessEl.value) : null,
comment: closeCommentEl.value.trim() || null,
};
}
async function submitNegotiationClose(event) {
event.preventDefault();
if (!currentNegotiationId) return;
negotiationCloseStatusEl.textContent = "Submitting";
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/close/manual`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(negotiationClosePayload()),
});
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
negotiationCloseStatusEl.textContent = result.message || "Submitted";
if (result.negotiation) renderNegotiationDetail(result.negotiation);
await refreshNegotiations(false);
closeNegotiationCloseModal();
} catch (error) {
negotiationCloseStatusEl.textContent = `Close failed: ${fetchErrorMessage(error)}`;
}
}
async function draftNegotiationClose() {
if (!currentNegotiationId) return;
negotiationCloseStatusEl.textContent = "Drafting";
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/close/draft`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(negotiationClosePayload()),
});
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
negotiationCloseStatusEl.textContent = "Draft ready for approval";
await refreshPending();
} catch (error) {
negotiationCloseStatusEl.textContent = `Draft failed: ${fetchErrorMessage(error)}`;
}
}
function parsePlanItems(text) {
return text
.split(/\r?\n/)
@@ -1323,6 +1546,72 @@ function parsePlanItems(text) {
});
}
function formatPlanItems(items) {
return (items || [])
.map((item) => {
const name = String(item.item_name || item.name || "").trim();
if (!name) return "";
const quantity = Number(item.desired_quantity || item.quantity || 1);
const maxUnitPrice = item.max_unit_price ?? item.max_price;
const parts = [name];
if (Number.isFinite(quantity) && quantity > 1) parts.push(String(quantity));
else if (maxUnitPrice !== null && maxUnitPrice !== undefined && maxUnitPrice !== "") parts.push("1");
if (maxUnitPrice !== null && maxUnitPrice !== undefined && maxUnitPrice !== "") parts.push(String(maxUnitPrice));
return parts.join(" | ");
})
.filter(Boolean)
.join("\n");
}
function applyPlanDraft(draft) {
if (!draft) return;
document.getElementById("plan-title").value = draft.title || "";
document.getElementById("plan-objective").value = draft.objective || "";
document.getElementById("plan-kind").value = draft.kind || "buying";
document.getElementById("plan-tone").value = draft.constraints?.message_tone || "";
document.getElementById("plan-instructions").value = draft.constraints?.instructions || "";
document.getElementById("plan-cadence").value = draft.cadence || "";
document.getElementById("plan-items").value = formatPlanItems(draft.items || []);
}
async function autofillPlanDraft() {
const title = document.getElementById("plan-title").value.trim();
const objective = document.getElementById("plan-objective").value.trim();
if (!title && !objective) {
plansStatusEl.textContent = "Add at least a title or objective first";
return;
}
const tone = document.getElementById("plan-tone").value.trim();
const instructions = document.getElementById("plan-instructions").value.trim();
const constraints = {};
if (tone) constraints.message_tone = tone;
if (instructions) constraints.instructions = instructions;
const payload = {
title,
objective,
kind: document.getElementById("plan-kind").value || "buying",
constraints,
items: parsePlanItems(document.getElementById("plan-items").value || ""),
};
plansStatusEl.textContent = "Drafting plan";
if (planAutofillButton) planAutofillButton.disabled = true;
try {
const response = await fetch("/api/plans/draft", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
applyPlanDraft(result.draft || {});
plansStatusEl.textContent = "Draft filled in. Review and edit anything you want.";
} catch (error) {
plansStatusEl.textContent = `Plan draft failed: ${fetchErrorMessage(error)}`;
} finally {
if (planAutofillButton) planAutofillButton.disabled = false;
}
}
async function createPlan(event) {
event.preventDefault();
const title = document.getElementById("plan-title").value.trim();
@@ -1664,8 +1953,8 @@ async function checkHealth() {
health = await fallbackResponse.json();
}
const provider = providerDisplayName(health.provider);
const isOpenAIProvider = health.provider === "openai";
const isCodexProvider = health.provider === "codex";
const isDeepSeekProvider = health.provider === "deepseek";
const isCloudProvider = isDeepSeekProvider;
ollamaOnline = Boolean(health.online);
if (!ollamaOnline) {
statusEl.textContent = "Offline";
@@ -1674,7 +1963,7 @@ async function checkHealth() {
return false;
}
if (health.model_available === false) {
const action = isOpenAIProvider ? "Load Provider Models." : isCodexProvider ? "Sign In to Codex." : "Install Model.";
const action = isCloudProvider ? "Save a working DeepSeek model." : "Install Model.";
setWarning(`${provider} needs the configured model "${health.model}". Open the model provider tab and use ${action}`);
ollamaToggle?.classList.add("attention-pulse");
} else {
@@ -1841,7 +2130,10 @@ async function pollNotifications() {
try {
const response = await fetch("/api/notifications");
const result = await response.json();
if ((result.notifications || []).length) await refreshInbox();
if ((result.notifications || []).length) {
await refreshInbox();
await refreshNegotiations(true);
}
} catch {
// Notification polling should never interrupt chat.
}
@@ -1886,10 +2178,16 @@ plansToggle?.addEventListener("click", () => {
if (plansPanel?.hidden) openPlansPanel();
else closePlansPanel();
});
negotiationsToggle?.addEventListener("click", () => {
if (negotiationPanel?.hidden) openNegotiationPanel(currentNegotiationId || negotiationRows[0]?.hash || "");
else closeNegotiationPanel();
});
negotiationsRefreshAllButton?.addEventListener("click", refreshAllNegotiations);
ollamaToggle?.addEventListener("click", () => toggleSidebarPanel("ollama"));
plansRefreshButton?.addEventListener("click", () => refreshPlans());
plansCloseButton?.addEventListener("click", closePlansPanel);
planForm?.addEventListener("submit", createPlan);
planAutofillButton?.addEventListener("click", autofillPlanDraft);
ollamaForm?.addEventListener("submit", saveOllamaConfig);
ollamaRefreshButton?.addEventListener("click", refreshOllamaStatus);
ollamaDownloadButton?.addEventListener("click", () => {
@@ -1908,17 +2206,12 @@ ollamaPullButton?.addEventListener("click", () => {
markOllamaActionClicked("pull");
postOllamaAction("/api/ollama/pull", { body: { model: configuredOllamaModel() } });
});
codexLoginButton?.addEventListener("click", launchCodexLogin);
providerModelSelect?.addEventListener("change", syncSelectedProviderModel);
document.getElementById("model-provider")?.addEventListener("change", () => {
const provider = document.getElementById("model-provider")?.value || "ollama";
updateProviderFieldVisibility(provider);
renderProviderModelOptions(latestOllamaStatus?.models || [], { ...latestOllamaStatus, provider });
});
openaiModelsRefreshButton?.addEventListener("click", () => {
markOllamaActionClicked("openai-models");
refreshOpenAIModels();
});
updateCheckButton?.addEventListener("click", checkForUpdate);
updateInstallButton?.addEventListener("click", installUpdate);
updateOpenReleasesButton?.addEventListener("click", openReleasesPage);
@@ -1926,6 +2219,15 @@ chatSidebarToggle?.addEventListener("click", toggleChatRail);
newChatButton?.addEventListener("click", () => createChat(true));
negotiationCloseButton?.addEventListener("click", closeNegotiationPanel);
negotiationForm?.addEventListener("submit", submitNegotiationMessage);
negotiationDraftButton?.addEventListener("click", draftNegotiationMessage);
negotiationRefreshButton?.addEventListener("click", refreshActiveNegotiation);
negotiationOpenChatButton?.addEventListener("click", openNegotiationInChat);
negotiationEndDealButton?.addEventListener("click", openNegotiationCloseModal);
negotiationSearchEl?.addEventListener("input", () => refreshNegotiations(false));
negotiationFilterEl?.addEventListener("change", () => refreshNegotiations(false));
negotiationCloseModalClose?.addEventListener("click", closeNegotiationCloseModal);
negotiationCloseForm?.addEventListener("submit", submitNegotiationClose);
closeDraftButton?.addEventListener("click", draftNegotiationClose);
updateModalClose?.addEventListener("click", closeUpdatePrompt);
updateModalReleases?.addEventListener("click", openReleasesPage);
updateModalInstall?.addEventListener("click", installUpdate);
@@ -1979,6 +2281,8 @@ async function sendMessage() {
const event = JSON.parse(line.slice(6));
if (event.type === "status") {
setMessageActivity(assistantNode, event.message, true);
} else if (event.type === "reasoning") {
appendThinkingText(assistantNode, event.content || "");
} else if (event.type === "metrics") {
setMessageMetrics(assistantNode, formatMetrics(event));
} else if (event.type === "warning") {
@@ -2027,8 +2331,10 @@ refreshConfig();
refreshOllamaStatus();
refreshChats().then(() => loadChatMessages(currentThreadId));
refreshInbox();
refreshNegotiations(false);
checkForUpdate(true);
pollNotifications();
checkHealth();
setInterval(checkHealth, 30000);
setInterval(pollNotifications, 15000);
setInterval(() => refreshNegotiations(true), 15000);
+123 -22
View File
@@ -25,6 +25,20 @@
<div class="rail-heading">Chats</div>
<div class="chat-list" id="chat-list"></div>
</section>
<section class="chat-nav-section">
<div class="rail-heading-row">
<div class="rail-heading">Negotiations</div>
<div class="rail-heading-actions">
<button class="rail-icon-button" id="negotiations-refresh-all" type="button" title="Refresh all negotiations">
<i data-lucide="refresh-cw" aria-hidden="true"></i>
</button>
<button class="rail-icon-button" id="negotiations-toggle" type="button" title="Negotiations" aria-expanded="false" aria-controls="negotiation-panel">
<i data-lucide="messages-square" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="plans-rail-list" id="negotiation-list"></div>
</section>
<section class="chat-nav-section">
<div class="rail-heading-row">
<div class="rail-heading">Plans</div>
@@ -82,6 +96,7 @@
<label>UEX API URL<input id="config-uex-base-url" name="uex_base_url" type="text"></label>
<label>UEX Secret Key<input id="config-uex-secret-key" name="uex_secret_key" type="password" autocomplete="off"></label>
<label>UEX Bearer Token<input id="config-uex-bearer-token" name="uex_bearer_token" type="password" autocomplete="off"></label>
<label>UEX Close Endpoint<input id="config-uex-negotiation-close-endpoint" name="uex_negotiation_close_endpoint" type="text"></label>
<label>UEX Username<input id="config-traderai-user-name" name="traderai_user_name" type="text"></label>
<label>Memory DB Path<input id="config-traderai-memory-path" name="traderai_memory_path" type="text"></label>
<label>Notification Poll Seconds<input id="config-uex-notification-poll-seconds" name="uex_notification_poll_seconds" type="number" min="15" step="15"></label>
@@ -125,20 +140,17 @@
<form class="config-form" id="ollama-config-form">
<label>Provider
<select id="model-provider" name="model_provider">
<option value="deepseek">DeepSeek V4 (Recommended)</option>
<option value="ollama">Local Ollama</option>
<option value="openai">OpenAI</option>
<option value="codex">Codex</option>
</select>
</label>
<label data-provider-scope="deepseek">DeepSeek URL<input id="deepseek-base-url" name="deepseek_base_url" type="text"></label>
<label data-provider-scope="deepseek">DeepSeek API Key<input id="deepseek-api-key" name="deepseek_api_key" type="password" autocomplete="off"></label>
<label data-provider-scope="deepseek" data-manual-model="true">DeepSeek Model<input id="deepseek-model" name="deepseek_model" type="text" list="provider-models"></label>
<label data-provider-scope="ollama">Ollama URL<input id="ollama-base-url" name="ollama_base_url" type="text"></label>
<label data-provider-scope="ollama">Ollama Model<input id="ollama-model" name="ollama_model" type="text" list="provider-models"></label>
<label data-provider-scope="ollama">Context Tokens<input id="ollama-num-ctx" name="ollama_num_ctx" type="number" min="1024" step="1024"></label>
<label data-provider-scope="openai">OpenAI URL<input id="openai-base-url" name="openai_base_url" type="text"></label>
<label data-provider-scope="openai">OpenAI API Key<input id="openai-api-key" name="openai_api_key" type="password" autocomplete="off"></label>
<label data-provider-scope="openai">OpenAI Model<input id="openai-model" name="openai_model" type="text" list="provider-models"></label>
<label data-provider-scope="codex">Codex Command<input id="codex-command" name="codex_command" type="text"></label>
<label data-provider-scope="codex">Codex Model<input id="codex-model" name="codex_model" type="text" list="provider-models"></label>
<label>Available Models<select id="provider-model-select"></select></label>
<label><span id="provider-model-label">Model</span><select id="provider-model-select"></select></label>
<label>Reasoning Effort<select id="model-reasoning-effort" name="model_reasoning_effort"></select></label>
<datalist id="provider-models"></datalist>
<button type="submit">Save Provider Config</button>
@@ -149,8 +161,6 @@
<button class="secondary small-button" id="ollama-install" type="button">Auto Install</button>
<button class="secondary small-button" id="ollama-launch" type="button">Launch</button>
<button class="small-button" id="ollama-pull" type="button">Install Model</button>
<button class="secondary small-button" id="codex-login" type="button">Sign In to Codex</button>
<button class="secondary small-button" id="openai-models-refresh" type="button">Load Provider Models</button>
</div>
<div class="config-status" id="ollama-message"></div>
</div>
@@ -175,19 +185,107 @@
<div class="floating-panel" id="negotiation-panel" hidden>
<div class="floating-panel-header">
<div>
<p class="eyebrow">UEX negotiation</p>
<h2 id="negotiation-title">Negotiation</h2>
<p class="eyebrow">UEX negotiations</p>
<h2 id="negotiation-title">Negotiation workspace</h2>
</div>
<div class="floating-panel-actions">
<div class="negotiation-sync-pill" id="negotiation-sync-pill">Local sync</div>
<button class="icon-button light" id="negotiation-close" type="button" title="Close">
<i data-lucide="x" aria-hidden="true"></i>
</button>
</div>
<button class="icon-button light" id="negotiation-close" type="button" title="Close">
<i data-lucide="x" aria-hidden="true"></i>
</button>
</div>
<div class="negotiation-messages" id="negotiation-messages"></div>
<form class="negotiation-composer" id="negotiation-form">
<textarea id="negotiation-input" rows="2" placeholder="Reply to the other party..."></textarea>
<button type="submit">Send</button>
</form>
<div class="config-status" id="negotiation-status"></div>
<div class="negotiation-workspace">
<aside class="negotiation-sidebar">
<div class="negotiation-sidebar-controls">
<input id="negotiation-search" type="text" placeholder="Search negotiations">
<select id="negotiation-filter">
<option value="open">Open</option>
<option value="all">All</option>
<option value="closed">Closed</option>
</select>
</div>
<div class="negotiation-list-panel" id="negotiation-panel-list"></div>
</aside>
<section class="negotiation-thread-shell">
<div class="negotiation-thread-header" id="negotiation-thread-header">
<div class="muted">Select a negotiation to load the local thread.</div>
</div>
<div class="negotiation-messages" id="negotiation-messages"></div>
<form class="negotiation-composer" id="negotiation-form">
<textarea id="negotiation-input" rows="2" placeholder="Reply to the other party..."></textarea>
<div class="negotiation-composer-actions">
<button class="secondary small-button" id="negotiation-draft-button" type="button">Ask AI to Draft</button>
<button type="submit">Send</button>
</div>
</form>
<div class="config-status" id="negotiation-status"></div>
</section>
<aside class="negotiation-detail-rail">
<div class="negotiation-detail-card" id="negotiation-meta-card">
<h3>Deal</h3>
<div class="muted">No negotiation selected.</div>
</div>
<div class="negotiation-detail-card" id="negotiation-user-card">
<h3>User</h3>
<div class="muted">No negotiation selected.</div>
</div>
<div class="negotiation-detail-card">
<h3>Actions</h3>
<div class="negotiation-action-stack">
<button class="secondary small-button" id="negotiation-open-chat" type="button">Open in AI Chat</button>
<button class="small-button" id="negotiation-refresh-button" type="button">Refresh Thread</button>
<button class="danger-button small-button" id="negotiation-end-deal" type="button">End Deal</button>
</div>
</div>
</aside>
</div>
</div>
<div class="modal-backdrop" id="negotiation-close-modal" hidden>
<section class="update-modal-card negotiation-close-card">
<div class="section-title-row">
<h2>End Deal</h2>
<button class="icon-button light" id="negotiation-close-modal-close" type="button" title="Close">
<i data-lucide="x" aria-hidden="true"></i>
</button>
</div>
<form class="config-form" id="negotiation-close-form">
<label>Did you close a deal?
<select id="close-deal-closed">
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</label>
<div class="plan-form-split">
<label>Deal value
<input id="close-deal-value" type="number" min="0" step="1" placeholder="1000000">
</label>
<label>Currency
<input id="close-currency" type="text" value="UEC">
</label>
</div>
<label>Clear, timely, and honest?
<input id="close-clarity" type="number" min="1" max="5" step="1" value="5">
</label>
<label>Delivery or response time?
<input id="close-speed" type="number" min="1" max="5" step="1" value="5">
</label>
<label>Respectful and easy to deal with?
<input id="close-respect" type="number" min="1" max="5" step="1" value="5">
</label>
<label>Price or offer fairness?
<input id="close-fairness" type="number" min="1" max="5" step="1" value="5">
</label>
<label>Comments
<textarea id="close-comment" rows="3" placeholder="Optional note"></textarea>
</label>
<div class="plan-form-actions">
<button class="secondary" id="close-draft-button" type="button">Draft for Approval</button>
<button type="submit">Rate Deal</button>
</div>
<div class="config-status" id="negotiation-close-status"></div>
</form>
</section>
</div>
<div class="floating-panel plans-floating-panel" id="plans-panel" hidden>
<div class="floating-panel-header">
@@ -233,7 +331,10 @@
<span>Buying plans work best with item lines. Custom plans can run with just instructions.</span>
</div>
</div>
<button type="submit">Create Plan</button>
<div class="plan-form-actions">
<button id="plan-autofill" type="button">AI Fill</button>
<button type="submit">Create Plan</button>
</div>
<div class="config-status" id="plans-status"></div>
</form>
</div>
+228 -2
View File
@@ -105,7 +105,7 @@ body::before {
.chat-rail-content {
display: grid;
grid-template-rows: minmax(0, 1fr) minmax(92px, 20%) minmax(130px, 30%);
grid-template-rows: minmax(0, 1.7fr) minmax(72px, 0.45fr) minmax(92px, 0.65fr) minmax(120px, 0.95fr);
gap: 16px;
min-height: 0;
padding-top: 16px;
@@ -139,6 +139,12 @@ body::before {
margin-bottom: 8px;
}
.rail-heading-actions {
display: inline-flex;
align-items: center;
gap: 6px;
}
.rail-heading-row .rail-heading {
margin-bottom: 0;
}
@@ -962,6 +968,26 @@ button {
box-shadow: var(--shadow);
}
#negotiation-panel {
grid-template-rows: auto minmax(0, 1fr);
width: min(1280px, calc(100vw - 28px));
max-height: min(860px, calc(100vh - 56px));
}
.negotiation-sync-pill {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 6px 10px;
border: 1px solid rgba(240, 214, 129, 0.32);
border-radius: 999px;
background: rgba(255, 250, 240, 0.12);
color: var(--gold-2);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.floating-panel-header {
display: flex;
align-items: flex-start;
@@ -977,6 +1003,114 @@ button {
color: var(--ivory);
}
.negotiation-workspace {
display: grid;
grid-template-columns: 280px minmax(0, 1fr) 260px;
min-height: 0;
height: 100%;
}
.negotiation-sidebar,
.negotiation-thread-shell,
.negotiation-detail-rail {
min-height: 0;
}
.negotiation-sidebar {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
border-right: 1px solid var(--line);
background: rgba(255, 250, 240, 0.58);
}
.negotiation-sidebar-controls {
display: grid;
gap: 10px;
padding: 14px;
border-bottom: 1px solid var(--line);
}
.negotiation-list-panel {
display: grid;
gap: 8px;
padding: 14px;
overflow: auto;
}
.negotiation-row {
display: grid;
gap: 6px;
padding: 11px 12px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(255, 253, 247, 0.86);
cursor: pointer;
}
.negotiation-row.active {
border-color: rgba(52, 83, 38, 0.42);
background: #edf3df;
}
.negotiation-row-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.negotiation-row-title {
color: var(--forest);
font-size: 13px;
font-weight: 800;
}
.negotiation-row-meta {
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.negotiation-row-badge {
padding: 3px 7px;
border-radius: 999px;
background: #edf3df;
color: var(--forest);
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
}
.negotiation-row-badge.closed {
background: #efe6ce;
color: var(--muted);
}
.negotiation-row-unread {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 999px;
background: var(--forest);
color: var(--ivory);
font-size: 11px;
font-weight: 800;
}
.negotiation-thread-shell {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto auto;
}
.negotiation-thread-header {
padding: 16px 18px;
border-bottom: 1px solid var(--line);
background: rgba(255, 253, 247, 0.82);
}
.negotiation-messages {
overflow: auto;
padding: 16px;
@@ -993,9 +1127,25 @@ button {
overflow-wrap: anywhere;
}
.negotiation-message.self {
border-color: rgba(52, 83, 38, 0.28);
background: #edf3df;
}
.negotiation-message-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
color: var(--muted);
font-size: 11px;
font-weight: 700;
}
.negotiation-composer {
display: grid;
grid-template-columns: 1fr auto;
grid-template-columns: 1fr;
gap: 10px;
padding: 12px;
border-top: 1px solid var(--line);
@@ -1006,6 +1156,64 @@ button {
border-radius: 12px;
}
.negotiation-composer-actions {
display: flex;
gap: 10px;
}
.negotiation-composer-actions button {
flex: 1;
}
.negotiation-detail-rail {
display: grid;
align-content: start;
gap: 12px;
padding: 14px;
border-left: 1px solid var(--line);
background: rgba(255, 250, 240, 0.52);
overflow: auto;
}
.negotiation-detail-card {
display: grid;
gap: 10px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255, 253, 247, 0.86);
}
.negotiation-detail-card h3 {
margin: 0;
color: var(--forest);
font-size: 14px;
}
.negotiation-detail-kv {
display: grid;
gap: 6px;
}
.negotiation-detail-kv div {
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.negotiation-detail-kv strong {
color: var(--brown);
}
.negotiation-action-stack {
display: grid;
gap: 10px;
}
.negotiation-close-card {
width: min(520px, 100%);
}
.modal-backdrop {
position: fixed;
inset: 0;
@@ -1302,6 +1510,15 @@ button.secondary {
min-height: 96px;
}
.plan-form-actions {
display: flex;
gap: 10px;
}
.plan-form-actions button {
flex: 1;
}
.plan-form-split {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -2068,4 +2285,13 @@ pre {
.plans-panel-body {
grid-template-columns: 1fr;
}
.negotiation-workspace {
grid-template-columns: 1fr;
}
.negotiation-sidebar,
.negotiation-detail-rail {
border: 0;
}
}
+175
View File
@@ -0,0 +1,175 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<link href="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/695be2905c0b4866dfb21265/62b39a568_Wikapp3.webp" rel="icon" type="image/svg+xml"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<link href="/manifest.json" rel="manifest"/>
<title>
Ships | Wikelo Project Tracker
</title>
<script crossorigin="" src="/assets/index-DWqdqkK8.js" type="module">
</script>
<link crossorigin="" href="/assets/index-BzxCYXI2.css" rel="stylesheet"/>
<meta content="Ships on Wikelo Project Tracker. Track materials needed and contributed for building your Star Citizen ships." name="description"/>
<meta content="Ships | Wikelo Project Tracker" property="og:title"/>
<meta content="Ships on Wikelo Project Tracker. Track materials needed and contributed for building your Star Citizen ships." property="og:description"/>
<meta content="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/render/image/public/base44-prod/public/695be2905c0b4866dfb21265/62b39a568_Wikapp3.webp?width=1200&amp;height=630&amp;resize=contain" property="og:image"/>
<meta content="https://wikelo-projects.com/Ships" property="og:url"/>
<meta content="website" property="og:type"/>
<meta content="Wikelo Project Tracker" property="og:site_name"/>
<meta content="Ships | Wikelo Project Tracker" name="twitter:title"/>
<meta content="Ships on Wikelo Project Tracker. Track materials needed and contributed for building your Star Citizen ships." name="twitter:description"/>
<meta content="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/render/image/public/base44-prod/public/695be2905c0b4866dfb21265/62b39a568_Wikapp3.webp?width=1200&amp;height=630&amp;resize=contain" name="twitter:image"/>
<meta content="summary_large_image" name="twitter:card"/>
<meta content="https://wikelo-projects.com/Ships" name="twitter:url"/>
<meta content="yes" name="mobile-web-app-capable"/>
<meta content="black" name="apple-mobile-web-app-status-bar-style"/>
<meta content="Wikelo Project Tracker" name="apple-mobile-web-app-title"/>
<link href="https://wikelo-projects.com/Ships" rel="canonical"/>
<script data-seo-source="builder" type="application/ld+json">
{"name": "Wikelo Project Tracker", "@context": "https://schema.org", "@type": "WebSite", "url": "https://wikelo-projects.com"}
</script>
<script data-seo-source="builder" type="application/ld+json">
{"name": "Wikelo Project Tracker", "logo": "https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/695be2905c0b4866dfb21265/62b39a568_Wikapp3.webp", "@context": "https://schema.org", "@type": "Organization", "url": "https://wikelo-projects.com"}
</script>
<script data-seo-source="builder" type="application/ld+json">
{"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [{"@type": "ListItem", "position": 1, "name": "Home", "item": "https://wikelo-projects.com/"}, {"@type": "ListItem", "position": 2, "name": "Ships | Wikelo Project Tracker", "item": "https://wikelo-projects.com/Ships"}]}
</script>
</head>
<body>
<div id="root">
<div data-seo-source="builder" id="seo-snapshot" style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;">
<h1>
Ships | Wikelo Project Tracker
</h1>
<p>
Ships on Wikelo Project Tracker. Track materials needed and contributed for building your Star Citizen ships.
</p>
<nav aria-label="Pages">
<h2>
Pages
</h2>
<ul>
<li>
<a href="/AdminAds">
Admin Ads
</a>
</li>
<li>
<a href="/AdvertiseWithUs">
Advertise With Us
</a>
</li>
<li>
<a href="/ArmorProjectDetails">
Armor Project Details
</a>
</li>
<li>
<a href="/Armors">
Armors
</a>
</li>
<li>
<a href="/CleanupMaterials">
Cleanup Materials
</a>
</li>
<li>
<a href="/Guide">
Guide
</a>
</li>
<li>
<a href="/">
Home
</a>
</li>
<li>
<a href="/Inventory">
Inventory
</a>
</li>
<li>
<a href="/Messages">
Messages
</a>
</li>
<li>
<a href="/NotificationSettings">
Notification Settings
</a>
</li>
<li>
<a href="/Notifications">
Notifications
</a>
</li>
<li>
<a href="/OrganizationDetails">
Organization Details
</a>
</li>
<li>
<a href="/Organizations">
Organizations
</a>
</li>
<li>
<a href="/Profile">
Profile
</a>
</li>
<li>
<a href="/ProjectDetails">
Project Details
</a>
</li>
<li>
<a href="/RecalculateContributionReputation">
Recalculate Contribution Reputation
</a>
</li>
<li>
<a href="/RecalculateMaterials">
Recalculate Materials
</a>
</li>
<li>
<a href="/RecalculateReputation">
Recalculate Reputation
</a>
</li>
<li>
<a href="/Reputation">
Reputation
</a>
</li>
<li>
<a href="/UpdateInfo">
Update Info
</a>
</li>
<li>
<a href="/WeaponProjectDetails">
Weapon Project Details
</a>
</li>
<li>
<a href="/Weapons">
Weapons
</a>
</li>
<li>
<a href="/WikeloProjectDetails">
Wikelo Project Details
</a>
</li>
</ul>
</nav>
</div>
</div>
</body>
</html>