4 Commits

23 changed files with 2960 additions and 118 deletions
+2
View File
@@ -2,6 +2,8 @@ OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=qwen3.5:9b
OLLAMA_NUM_CTX=64512
UEX_BASE_URL=https://api.uexcorp.space/2.0
SCMDB_BASE_URL=https://scmdb.net
CORNERSTONE_BASE_URL=https://finder.cstone.space
UEX_SECRET_KEY=
UEX_BEARER_TOKEN=
TRADERAI_USER_NAME=
+6
View File
@@ -5,6 +5,8 @@ Local Ollama-powered chat for UEX marketplace workflows.
## What It Does
- Searches active/current UEX marketplace listings through `GET /marketplace_listings/`.
- Searches SCMDB mission data so the assistant can answer what Star Citizen missions pay or reward, including UEC, reputation, item rewards, blueprint rewards, partial payouts, and hauling cargo.
- Searches Cornerstone Universal Item Finder data so the assistant can find where in-game items are sold, including store/location, base price, and verified date.
- Reads authenticated marketplace negotiations and negotiation messages when `UEX_SECRET_KEY` or `UEX_BEARER_TOKEN` is set.
- Drafts negotiation messages and marketplace listings as pending actions.
- Requires browser approval before sending authenticated write requests to UEX.
@@ -23,6 +25,8 @@ Local Ollama-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.
`SCMDB_BASE_URL` defaults to `https://scmdb.net`.
`CORNERSTONE_BASE_URL` defaults to `https://finder.cstone.space`.
4. Install and run:
```powershell
@@ -71,6 +75,8 @@ UEX notifications are checked every `UEX_NOTIFICATION_POLL_SECONDS` seconds by d
## Sources Used
- UEX SwaggerHub OpenAPI v2.1: https://app.swaggerhub.com/apis-docs/dolejska-daniel/UEX-API/v2.1
- SCMDB mission data: https://scmdb.net/
- Cornerstone Universal Item Finder: https://finder.cstone.space/
- UEX marketplace listings docs: https://uexcorp.space/api/documentation/id/get_marketplace_listings/?is_kiosk=1
- UEX negotiation message docs: https://uexcorp.space/api/documentation/id/post_marketplace_negotiations_messages/?is_kiosk=1
- Ollama tool calling docs: https://docs.ollama.com/capabilities/tool-calling
+76
View File
@@ -0,0 +1,76 @@
Add an option to use API: marketplace_trends as a tool for the AI from UEX
Closed negotiations (not open): All other negotiations show date_closed > 0 or were marked as closed by client or Riggs0. find a more relaiable way to mark them as closed/open negotations for the AI
some times when having a long tool call the assiant will not reply what it did/an awnser see ---
2026-05-06T19:41:17.786390+00:00 assistant
Looking at the historical price data for **Wikelo Favor**, here's what happened recently:
## Recent Historical Price Activity (Last Few Hours)
**Sell Orders (asking prices):**
- **1,500,000,000 UEC** (two listings at the same time)
- **1,000,000,000 UEC** (one listing)
**Buy Orders (bidding prices):**
- **300,000,000 UEC** (multiple buy orders)
**Current Active Listings:**
- **500,000,000 UEC** per unit (9 in stock)
- **1,000,000,000 UEC** per stack (5 in stock)
## Price Range Analysis
The market has been fluctuating significantly:
- **Lowest recent price:** 300M UEC (buy orders)
- **Highest recent price:** 1.5B UEC (sell orders)
- **Current active listings:** 500M - 1B UEC
**What This Means for Pricing:**
If you're looking to sell your Wikelo Favors competitively, I'd recommend pricing between **500M - 800M UEC** to match the current market, which is more realistic than the high-end 1.5B UEC listings. The 300M UEC buy orders suggest that's where many players are willing to bid, so anything between 300M-500M UEC might actually sell quickly.
Would you like me to search for more specific listings or help you create a listing at a particular price point?
2026-05-06T19:40:51.966297+00:00 user
What happened
2026-05-06T19:38:27.178306+00:00 assistant
2026-05-06T19:37:52.825713+00:00 user
I want you to use the history tools and show me what they were selling for recently so I know what to price mine at
----
I want the user to beable to see and directly respond to negotations in the app seperate the ai so it should work like this, when a negotation is refrenced the user should be able to click on it and a floating block wiht the chat messages and an area for the user to type one to the other party,
I want the message box for the user to be forced to the bottom of the viewport, right now when their are no messages if moves up to the top then its sticky on the bottom when messages happen
I want the users messages to right aligned and not have abunch of empty space to the right or left of it.
when I approve messages they dont send I see
Approval result:
{
"status": "ok",
"http_code": 200,
"data": {
"id_message": "0"
},
"message": "Sandbox mode"
}
and an error that a python dll is missing from the exe.
I want a default collapsed sidebar that has chats, they are to be like chat gpt chats where memories persist across them and the chat history is confied to them. I want the users to beable to delete them with a trash can icon, and I want them saved to disk.
the wake jobs may not be working properly, I scehudled one and
I see I'll check your UEX notifications and handle your open negotiations now!
but I dont see an output for this job. to fix this I want to have an Inbox that the AI can send messages to. It should be the output of the wake job and have the ablity to continue the coversation in a new chat.
make a popup that prompts a user to update on open when a new update is avaible.
+9 -1
View File
@@ -1,14 +1,22 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_all
import sysconfig
from pathlib import Path
webview_datas, webview_binaries, webview_hiddenimports = collect_all("webview")
python_binaries = []
for dll_name in ("python3.dll", f"python{sysconfig.get_python_version().replace('.', '')}.dll"):
dll_path = Path(sysconfig.get_config_var("BINDIR") or "") / dll_name
if dll_path.exists():
python_binaries.append((str(dll_path), "."))
a = Analysis(
["traderai\\desktop.py"],
pathex=[],
binaries=webview_binaries,
binaries=[*webview_binaries, *python_binaries],
datas=[("web", "web"), *webview_datas],
hiddenimports=[
*webview_hiddenimports,
+9 -1
View File
@@ -1,14 +1,22 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_all
import sysconfig
from pathlib import Path
webview_datas, webview_binaries, webview_hiddenimports = collect_all("webview")
python_binaries = []
for dll_name in ("python3.dll", f"python{sysconfig.get_python_version().replace('.', '')}.dll"):
dll_path = Path(sysconfig.get_config_var("BINDIR") or "") / dll_name
if dll_path.exists():
python_binaries.append((str(dll_path), "."))
a = Analysis(
["traderai\\desktop.py"],
pathex=[],
binaries=webview_binaries,
binaries=[*webview_binaries, *python_binaries],
datas=[("web", "web"), *webview_datas],
hiddenimports=[
*webview_hiddenimports,
+2 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "traderai"
version = "0.0.2"
version = "0.0.3"
description = "Local Ollama-powered assistant for UEX marketplace workflows."
requires-python = ">=3.11"
dependencies = [
@@ -36,3 +36,4 @@ include = ["traderai*"]
+165
View File
@@ -1,4 +1,5 @@
import pytest
import asyncio
from traderai.agent import OllamaAgent, SYSTEM_PROMPT
from traderai.memory import MemoryStore
@@ -12,6 +13,117 @@ class EmptyTools:
return {}
class WakeTools(EmptyTools):
def __init__(self):
self.calls = []
async def execute(self, name, arguments):
self.calls.append((name, arguments))
return {"count": 1, "notifications": [{"message": "Buyer replied"}]}
class WakeAgent(OllamaAgent):
def __init__(self, memory):
super().__init__("http://127.0.0.1:1", "missing-model", WakeTools(), memory=memory)
self.responses = [
{
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"function": {
"name": "check_uex_notifications",
"arguments": {},
}
}
],
}
},
{"message": {"role": "assistant", "content": "I checked notifications: Buyer replied."}},
]
async def ensure_available(self):
return None
async def _ollama_chat(self, *args, **kwargs):
return self.responses.pop(0)
class TitleAgent(OllamaAgent):
def __init__(self, memory):
super().__init__("http://127.0.0.1:1", "missing-model", EmptyTools(), memory=memory)
async def ensure_available(self):
return None
async def _generate_chat_title(self, first_message):
return "UEX Market Check"
async def _ollama_chat(self, *args, **kwargs):
return {"message": {"role": "assistant", "content": "Done"}}
class SlowToolTools(EmptyTools):
schemas = [
{
"type": "function",
"function": {
"name": "slow_tool",
"description": "Slow fake tool.",
"parameters": {"type": "object", "properties": {}},
},
}
]
def __init__(self):
self.calls = 0
async def execute(self, name, arguments):
self.calls += 1
await asyncio.sleep(0.01)
return {"status": "ok", "value": "slow result"}
class SlowStreamingAgent(OllamaAgent):
def __init__(self, memory):
super().__init__("http://127.0.0.1:1", "missing-model", SlowToolTools(), memory=memory)
self.stream_calls = 0
async def health(self):
return {"online": True, "model": "test", "base_url": self.base_url}
async def _ollama_chat_stream(self, *args, **kwargs):
self.stream_calls += 1
if self.stream_calls == 1:
yield {
"message": {
"role": "assistant",
"content": "",
"tool_calls": [{"function": {"name": "slow_tool", "arguments": {}}}],
},
"done": True,
}
return
yield {"message": {"role": "assistant", "content": ""}, "done": True}
class FailingAfterToolAgent(SlowStreamingAgent):
async def _ollama_chat_stream(self, *args, **kwargs):
self.stream_calls += 1
if self.stream_calls == 1:
yield {
"message": {
"role": "assistant",
"content": "",
"tool_calls": [{"function": {"name": "slow_tool", "arguments": {}}}],
},
"done": True,
}
return
raise RuntimeError("ollama timed out")
@pytest.mark.asyncio
async def test_chat_events_warns_when_ollama_offline():
agent = OllamaAgent("http://127.0.0.1:1", "missing-model", EmptyTools())
@@ -90,3 +202,56 @@ def test_ollama_options_include_num_ctx():
agent = OllamaAgent("http://127.0.0.1:1", "missing-model", EmptyTools(), num_ctx=64000)
assert agent._ollama_options() == {"num_ctx": 64000}
@pytest.mark.asyncio
async def test_wake_response_executes_tool_calls(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
agent = WakeAgent(memory)
response = await agent.generate_wake_response("Scheduled wake job fired. Check notifications.")
assert response == "I checked notifications: Buyer replied."
assert agent.tools.calls == [("check_uex_notifications", {})]
wake_rows = memory.recent_conversation(thread_id="wake")
assert wake_rows[-1]["content"] == "I checked notifications: Buyer replied."
@pytest.mark.asyncio
async def test_first_chat_message_generates_thread_title(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
thread = memory.create_thread()
agent = TitleAgent(memory)
result = await agent.chat("Check UEX market listings", thread_id=thread["id"])
assert result["message"] == "Done"
assert memory.get_thread(thread["id"])["title"] == "UEX Market Check"
@pytest.mark.asyncio
async def test_chat_events_returns_fallback_after_slow_tool_and_empty_final_response(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
agent = SlowStreamingAgent(memory)
events = [event async for event in agent.chat_events("run a slow tool")]
text = "".join(event.get("content", "") for event in events if event["type"] == "token")
assert agent.tools.calls == 1
assert "I completed the tool call" in text
assert "slow result" in text
assert events[-1]["type"] == "done"
@pytest.mark.asyncio
async def test_chat_events_returns_tool_result_when_model_fails_after_slow_tool(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
agent = FailingAfterToolAgent(memory)
events = [event async for event in agent.chat_events("run a slow tool")]
text = "".join(event.get("content", "") for event in events if event["type"] == "token")
assert agent.tools.calls == 1
assert "local model stopped after the tool call" in text
assert "slow result" in text
assert events[-1]["type"] == "done"
+30
View File
@@ -25,3 +25,33 @@ def test_memory_store_clear_selected_sections(tmp_path):
assert snapshot["memories"] == []
assert snapshot["conversations"] == []
assert snapshot["profile"][0]["key"] == "configured_name"
def test_memory_store_separates_chat_threads_but_keeps_shared_memories(tmp_path):
store = MemoryStore(str(tmp_path / "memory.sqlite3"))
first = store.create_thread("First")
second = store.create_thread("Second")
store.add_conversation("user", "first thread message", first["id"])
store.add_conversation("user", "second thread message", second["id"])
store.remember("preference", "Shared trading preference", importance=5)
first_rows = store.recent_conversation(thread_id=first["id"])
second_rows = store.recent_conversation(thread_id=second["id"])
assert [row["content"] for row in first_rows] == ["first thread message"]
assert [row["content"] for row in second_rows] == ["second thread message"]
assert store.recall("trading preference")[0]["content"] == "Shared trading preference"
def test_memory_store_renames_threads_and_deletes_outbox_items(tmp_path):
store = MemoryStore(str(tmp_path / "memory.sqlite3"))
thread = store.create_thread("New chat")
store.add_outbox("Wake job result")
inbox_id = store.list_outbox()[0]["id"]
renamed = store.rename_thread(thread["id"], " Market Check ")
deleted = store.delete_outbox(inbox_id)
assert renamed["title"] == "Market Check"
assert deleted is True
assert store.list_outbox() == []
+146
View File
@@ -1,5 +1,6 @@
import pytest
from traderai.agent import OllamaAgent
from traderai.memory import MemoryStore
from traderai.scheduler import WakeScheduler
@@ -31,6 +32,102 @@ class FakeUEXNotifications:
}
class FailingUEXNotifications:
async def get_user_notifications(self):
raise RuntimeError("bad token")
class FakeWakeAgent:
async def generate_wake_response(self, wake_message):
return f"Wake output: {wake_message}"
class ListingWakeTools:
schemas = [
{
"type": "function",
"function": {
"name": "search_marketplace_listings",
"description": "Search active UEX marketplace listings.",
"parameters": {"type": "object", "properties": {}},
},
}
]
def __init__(self):
self.calls = []
self.pending_actions = {}
async def execute(self, name, arguments):
self.calls.append((name, arguments))
return {
"count": 2,
"listings": [
{
"id": 100,
"title": "Wikelo Favor",
"operation": "sell",
"price": 500_000_000,
"currency": "UEC",
"in_stock": 9,
"advertiser": "pilot_a",
},
{
"id": 101,
"title": "Wikelo Favor stack",
"operation": "sell",
"price": 1_000_000_000,
"currency": "UEC",
"in_stock": 5,
"advertiser": "pilot_b",
},
],
}
class ListingWakeAgent(OllamaAgent):
def __init__(self, memory):
self.listing_tools = ListingWakeTools()
super().__init__("http://127.0.0.1:1", "missing-model", self.listing_tools, memory=memory)
self.responses = [
{
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"function": {
"name": "search_marketplace_listings",
"arguments": {
"query": "Wikelo Favor",
"operation": "sell",
"limit": 5,
},
}
}
],
}
},
{
"message": {
"role": "assistant",
"content": (
"Listing check complete: found 2 active Wikelo Favor sell listings. "
"Cheapest listing is 500,000,000 UEC with 9 in stock; the next listing is "
"1,000,000,000 UEC. Suggested next action: price near 500,000,000 UEC "
"if you want to move yours quickly."
),
}
},
]
async def ensure_available(self):
return None
async def _ollama_chat(self, *args, **kwargs):
return self.responses.pop(0)
@pytest.mark.asyncio
async def test_poll_uex_notifications_adds_unread_once(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
@@ -45,3 +142,52 @@ async def test_poll_uex_notifications_adds_unread_once(tmp_path):
assert second == []
assert len(outbox) == 1
assert "A buyer replied to your listing." in outbox[0]["content"]
@pytest.mark.asyncio
async def test_poll_uex_notifications_reports_failures_to_outbox(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
scheduler = WakeScheduler(memory)
scheduler.bind_uex_notifications(FailingUEXNotifications())
result = await scheduler.poll_uex_notifications()
assert result == []
assert "bad token" in memory.inspect()["outbox"][0]["content"]
@pytest.mark.asyncio
async def test_wake_job_writes_agent_output_to_outbox_and_disables_one_shot(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
scheduler = WakeScheduler(memory)
scheduler.bind_agent(FakeWakeAgent())
memory.add_job("wake-test", "check notifications", "date", "2099-01-01T00:00:00+00:00")
await scheduler._run_job("wake-test", "check notifications")
snapshot = memory.inspect()
assert "Wake output:" in snapshot["outbox"][0]["content"]
assert snapshot["scheduled_jobs"][0]["enabled"] == 0
@pytest.mark.asyncio
async def test_wake_job_checks_listings_and_writes_analysis_to_outbox(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
scheduler = WakeScheduler(memory)
agent = ListingWakeAgent(memory)
scheduler.bind_agent(agent)
memory.add_job("wake-listings", "check Wikelo Favor listings and analyze the market", "date", "2099-01-01T00:00:00+00:00")
await scheduler._run_job("wake-listings", "check Wikelo Favor listings and analyze the market")
snapshot = memory.inspect()
content = snapshot["outbox"][0]["content"]
assert agent.listing_tools.calls == [
(
"search_marketplace_listings",
{"query": "Wikelo Favor", "operation": "sell", "limit": 5},
)
]
assert "Listing check complete" in content
assert "500,000,000 UEC" in content
assert "Suggested next action" in content
+253
View File
@@ -2,11 +2,15 @@ import pytest
import respx
from httpx import Response
from traderai.cornerstone_client import CornerstoneClient, parse_cornerstone_item_page
from traderai.tools import ToolRegistry
from traderai.uex_client import UEXClient
class FakeUEX:
def __init__(self):
self.posts = []
async def get(self, path, params=None, authenticated=False):
if path == "commodities_prices_history":
return {
@@ -113,6 +117,135 @@ class FakeUEX:
async def delete(self, path, params=None, authenticated=True):
return {"status": "ok", "deleted": {"path": path, "params": params, "authenticated": authenticated}}
async def post(self, path, payload, authenticated=True):
self.posts.append({"path": path, "payload": payload, "authenticated": authenticated})
return {"status": "ok", "posted": self.posts[-1]}
class FakeSCMDB:
base_url = "https://scmdb.test"
async def list_versions(self):
return [
{"version": "4.8.0-ptu.1", "file": "merged-4.8.0-ptu.1.json"},
{"version": "4.7.2-live.1", "file": "merged-4.7.2-live.1.json"},
]
async def get_data(self, version=None, channel="live"):
return {
"version": version or "4.7.2-live.1",
"factions": {
"fac-haul": {"name": "Covalex"},
"fac-bounty": {"name": "Bounty Hunters Guild"},
},
"scopes": {
"scope-rep": {"scopeName": "FactionReputation"},
},
"factionRewardsPools": [
[{"factionGuid": "fac-haul", "scopeGuid": "scope-rep", "amount": 125}],
[{"factionGuid": "fac-bounty", "scopeGuid": "scope-rep", "amount": 250}],
],
"partialRewardPayoutPools": [
[],
[{"minPercentage": 50, "maxPercentage": 99, "currencyRewardMultiplier": 0.75, "reputationMultipliers": None}],
],
"resourcePools": {
"res-tungsten": {"name": "Tungsten"},
},
"blueprintPools": {
"bp-pool": {
"name": "Ship Salvage Rewards",
"blueprints": [{"name": "Abrade Scraper Module"}],
},
},
"locationPools": {
"loc-a18": {"name": "Area18"},
"loc-baijini": {"name": "Baijini Point"},
},
"contracts": [
{
"id": "mission-haul",
"debugName": "Haul_Tungsten_Test",
"title": "Move Tungsten",
"description": "Move Tungsten to Baijini Point.",
"missionType": "Hauling",
"category": "career",
"factionGuid": "fac-haul",
"rewardUEC": 50250,
"factionRewardsIndex": 0,
"partialRewardPayoutIndex": 1,
"haulingOrders": [{"resource": "res-tungsten", "minSCU": 6, "maxSCU": 6, "maxContainerSize": 1}],
"locations": ["loc-a18"],
"destinations": ["loc-baijini"],
"systems": ["Stanton"],
"illegal": False,
"canBeShared": False,
},
{
"id": "mission-bounty",
"debugName": "Bounty_Blueprint_Test",
"title": "Ambush Op",
"description": "Clean out targets.",
"missionType": "Bounty Hunter",
"factionGuid": "fac-bounty",
"rewardUEC": 120000,
"factionRewardsIndex": 1,
"partialRewardPayoutIndex": 0,
"itemRewards": [{"name": "Council Scrip", "amount": 5}],
"blueprintRewards": [{"blueprintPool": "bp-pool", "chance": 1, "trigger": "complete"}],
"systems": ["Pyro"],
"illegal": True,
"canBeShared": True,
},
],
"legacyContracts": [
{
"id": "legacy-delivery",
"debugName": "Legacy_Delivery_Test",
"title": "Old Box Run",
"missionType": "Delivery",
"factionGuid": "fac-haul",
"rewardUEC": 1000,
"factionRewardsIndex": 0,
"partialRewardPayoutIndex": 0,
"systems": ["Stanton"],
}
],
}
class FakeCornerstone:
base_url = "https://finder.cstone.test"
async def list_items(self):
return [
{"id": "item-abrade", "name": "Abrade Scraper Module", "sold": True},
{"id": "item-cinch", "name": "Cinch Scraper Module", "sold": True},
{"id": "item-poster", "name": "Zeus 2955 Ship Showdown Poster", "sold": False},
]
async def get_item_page(self, item_id):
assert item_id == "item-abrade"
return {
"url": f"{self.base_url}/ShipSalvageMods1/{item_id}",
"html": """
<html>
<head><title>Star Citizen - Salvage modifier - Abrade Scraper Module</title></head>
<body>
<table>
<tr><td>NAME</td><td>Abrade Scraper Module</td></tr>
<tr><td>MANUFACTURER</td><td>Greycat Industrial</td></tr>
</table>
<table>
<tr><th>LOCATION</th><th>BASE PRICE</th><th>VERIFIED</th></tr>
<tr><td>Stanton - ArcCorp - Area18 - Dumper's Depot</td><td>21 250</td><td>2956-01-29</td></tr>
<tr><td>Stanton - microTech - Port Tressler - Platinum Bay</td><td>21 250</td><td>2956-01-04</td></tr>
</table>
</body>
</html>
""",
}
@pytest.mark.asyncio
async def test_search_marketplace_listings_filters_locally():
@@ -145,6 +278,19 @@ async def test_decline_pending_action_removes_without_sending():
assert action_id not in registry.pending_actions
@pytest.mark.asyncio
async def test_approve_negotiation_message_forces_production_send():
fake = FakeUEX()
registry = ToolRegistry(fake)
result = await registry.draft_negotiation_message(hash="abc", message="Ready to close", is_production=0)
action_id = result["pending_action"]["id"]
approved = await registry.approve(action_id)
assert approved["posted"]["path"] == "marketplace_negotiations_messages"
assert approved["posted"]["payload"]["is_production"] == 1
def test_uex_client_uses_bearer_and_secret_headers():
client = UEXClient("https://api.uexcorp.space/2.0", secret_key="secret", bearer_token="bearer")
@@ -216,6 +362,113 @@ def test_schemas_expose_specific_uex_tools_instead_of_generic_api_tool():
assert "uex_draft_post" not in names
def test_schemas_expose_scmdb_mission_tools():
registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB())
names = {schema["function"]["name"] for schema in registry.schemas}
assert "list_scmdb_versions" in names
assert "search_scmdb_missions" in names
assert "get_scmdb_mission_rewards" in names
def test_schemas_expose_cornerstone_item_tools():
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
names = {schema["function"]["name"] for schema in registry.schemas}
assert "search_cornerstone_items" in names
assert "get_cornerstone_item_locations" in names
@pytest.mark.asyncio
async def test_search_scmdb_missions_returns_reward_summary():
registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB())
result = await registry.search_scmdb_missions(query="tungsten", mission_type="hauling")
assert result["version"] == "4.7.2-live.1"
assert result["matched"] == 1
mission = result["missions"][0]
assert mission["title"] == "Move Tungsten"
assert mission["rewards"]["uec"] == 50250
assert mission["rewards"]["reputation"] == [{"faction": "Covalex", "scope": "FactionReputation", "amount": 125}]
assert mission["rewards"]["hauling"] == [
{"resource": "Tungsten", "min_scu": 6, "max_scu": 6, "max_container_size_scu": 1}
]
@pytest.mark.asyncio
async def test_get_scmdb_mission_rewards_enriches_items_blueprints_and_locations():
registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB())
result = await registry.get_scmdb_mission_rewards(debug_name="Bounty_Blueprint_Test")
mission = result["mission"]
assert mission["title"] == "Ambush Op"
assert mission["faction"] == "Bounty Hunters Guild"
assert mission["rewards"]["items"] == [{"name": "Council Scrip", "amount": 5}]
assert mission["rewards"]["blueprints"][0]["blueprints"] == ["Abrade Scraper Module"]
@pytest.mark.asyncio
async def test_search_cornerstone_items_filters_sold_items():
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
result = await registry.search_cornerstone_items(query="scraper", sold_only=True)
assert result["matched"] == 2
assert {item["name"] for item in result["items"]} == {"Abrade Scraper Module", "Cinch Scraper Module"}
assert result["items"][0]["url"].startswith("https://finder.cstone.test/Search/item-")
@pytest.mark.asyncio
async def test_get_cornerstone_item_locations_parses_store_prices():
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
result = await registry.get_cornerstone_item_locations(query="abrade", location="Area18")
assert result["item"]["name"] == "Abrade Scraper Module"
assert result["item"]["general"]["manufacturer"] == "Greycat Industrial"
assert result["matched_locations"] == 1
assert result["locations"] == [
{
"location": "Stanton - ArcCorp - Area18 - Dumper's Depot",
"base_price": 21250,
"base_price_display": "21 250",
"verified": "2956-01-29",
}
]
def test_parse_cornerstone_item_page_extracts_locations():
parsed = parse_cornerstone_item_page(
"""
<html><head><title>Star Citizen - Food - Whamburger</title></head>
<body><table><tr><td>NAME</td><td>Whamburger</td></tr></table>
<table><tr><th>LOCATION</th><th>BASE PRICE</th><th>VERIFIED</th></tr>
<tr><td>Stanton - Area18 - Cubby Blast</td><td>9</td><td>2956-01-01</td></tr></table></body></html>
"""
)
assert parsed["name"] == "Whamburger"
assert parsed["locations"][0]["base_price"] == 9
@pytest.mark.asyncio
@respx.mock
async def test_cornerstone_client_accepts_json_encoded_string_payload():
respx.get("https://finder.cstone.space/GetSearch").mock(
return_value=Response(
200,
json='[{"id":"item-1","name":"Abrade Scraper Module","Sold":1}]',
)
)
client = CornerstoneClient("https://finder.cstone.space")
assert await client.list_items() == [{"id": "item-1", "name": "Abrade Scraper Module", "sold": True}]
@pytest.mark.asyncio
async def test_search_uex_api_index_finds_history_tools():
registry = ToolRegistry(FakeUEX())
+279 -59
View File
@@ -1,13 +1,14 @@
from __future__ import annotations
import json
import re
from collections.abc import AsyncIterator
from typing import Any
import httpx
from tzlocal import get_localzone
from traderai.memory import MemoryStore, iso_now, iso_now_in_zone, time_since
from traderai.memory import DEFAULT_THREAD_ID, MemoryStore, iso_now, iso_now_in_zone, time_since
from traderai.tools import ToolRegistry
@@ -16,9 +17,12 @@ Use tools when the user asks about UEX data, open/current listings, active negot
UEX credentials are configured server-side when available. Never ask the user to provide UEX_SECRET_KEY or UEX_BEARER_TOKEN in chat; call the authenticated UEX tool and only mention credential configuration if the tool returns an authentication error.
Use the specific UEX tool for the needed endpoint, such as get_uex_commodities_prices or get_uex_vehicles. Use fields, limit, and summary mode so tool results stay compact.
When the user asks for history, trends, changes over time, or past prices, prefer the summarize_uex_*_history tools when available; use search_uex_api_index(history_only=true) if you need to discover history endpoints.
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 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.
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 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."""
@@ -38,7 +42,7 @@ class OllamaAgent:
self.memory = memory
self.user_name = user_name
self.num_ctx = num_ctx
self.messages: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}]
self.thread_messages: dict[str, list[dict[str, Any]]] = {}
async def health(self) -> dict[str, Any]:
try:
@@ -51,7 +55,7 @@ class OllamaAgent:
"online": False,
"model": self.model,
"base_url": self.base_url,
"message": f"Ollama is offline or unreachable at {self.base_url}. Start Ollama and make sure the model is pulled.",
"message": f"Ollama is offline or unreachable at {self.base_url}. Open the Ollama tab and use the recommended action.",
"detail": str(exc),
}
@@ -70,107 +74,214 @@ class OllamaAgent:
if not health["online"]:
raise OllamaUnavailable(health["message"])
async def chat(self, content: str) -> dict[str, Any]:
async def chat(self, content: str, thread_id: str | None = DEFAULT_THREAD_ID) -> dict[str, Any]:
await self.ensure_available()
previous_interaction = self.memory.last_interaction() if self.memory else None
resolved_thread_id = self._thread_id(thread_id)
messages = self._messages_for_thread(resolved_thread_id)
previous_interaction = self.memory.last_interaction(resolved_thread_id) if self.memory else None
if self.memory:
self.memory.add_conversation("user", content)
self.messages.append({"role": "user", "content": content})
self.memory.add_conversation("user", content, resolved_thread_id)
await self._title_first_message(resolved_thread_id, content, previous_interaction)
messages.append({"role": "user", "content": content})
last_tool_results: list[dict[str, Any]] = []
for _ in range(5):
response = await self._ollama_chat(content, previous_interaction=previous_interaction)
try:
response = await self._ollama_chat(
content,
messages,
previous_interaction=previous_interaction,
thread_id=resolved_thread_id,
)
except Exception as exc:
if not last_tool_results:
raise
answer = self._tool_result_fallback(
last_tool_results,
f"The local model stopped after the tool call: {exc}",
)
messages.append({"role": "assistant", "content": answer})
if self.memory:
self.memory.add_conversation("assistant", answer, resolved_thread_id)
return {"message": answer, "pending_actions": self._pending_payloads(), "thread_id": resolved_thread_id}
message = response.get("message") or {}
tool_calls = message.get("tool_calls") or []
if not tool_calls:
self.messages.append({"role": "assistant", "content": message.get("content", "")})
answer = message.get("content", "")
if not answer.strip():
answer = self._empty_response_fallback(last_tool_results)
messages.append({"role": "assistant", "content": answer})
if self.memory:
self.memory.add_conversation("assistant", message.get("content", ""))
return {"message": message.get("content", ""), "pending_actions": self._pending_payloads()}
self.memory.add_conversation("assistant", answer, resolved_thread_id)
return {"message": answer, "pending_actions": self._pending_payloads(), "thread_id": resolved_thread_id}
self.messages.append(message)
messages.append(message)
for call in tool_calls:
name, arguments = self._extract_call(call)
result = await self.tools.execute(name, arguments)
self.messages.append({"role": "tool", "tool_name": name, "content": json.dumps(result)})
last_tool_results.append({"tool": name, "result": result})
messages.append({"role": "tool", "tool_name": name, "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."
self.messages.append({"role": "assistant", "content": fallback})
messages.append({"role": "assistant", "content": fallback})
if self.memory:
self.memory.add_conversation("assistant", fallback)
return {"message": fallback, "pending_actions": self._pending_payloads()}
self.memory.add_conversation("assistant", fallback, resolved_thread_id)
return {"message": fallback, "pending_actions": self._pending_payloads(), "thread_id": resolved_thread_id}
async def chat_events(self, content: str) -> AsyncIterator[dict[str, Any]]:
async def chat_events(self, content: str, thread_id: str | None = DEFAULT_THREAD_ID) -> AsyncIterator[dict[str, Any]]:
health = await self.health()
if not health["online"]:
yield {"type": "warning", "message": health["message"]}
yield {"type": "done", "pending_actions": self._pending_payloads()}
return
previous_interaction = self.memory.last_interaction() if self.memory else None
resolved_thread_id = self._thread_id(thread_id)
messages = self._messages_for_thread(resolved_thread_id)
previous_interaction = self.memory.last_interaction(resolved_thread_id) if self.memory else None
if self.memory:
self.memory.add_conversation("user", content)
self.messages.append({"role": "user", "content": content})
self.memory.add_conversation("user", content, resolved_thread_id)
await self._title_first_message(resolved_thread_id, content, previous_interaction)
messages.append({"role": "user", "content": content})
yield {"type": "status", "message": "Thinking"}
last_tool_results: list[dict[str, Any]] = []
for _ in range(5):
assistant_message: dict[str, Any] = {"role": "assistant", "content": ""}
tool_calls: list[dict[str, Any]] = []
async for event in self._ollama_chat_stream(content, previous_interaction=previous_interaction):
message = event.get("message") or {}
chunk = message.get("content") or ""
if chunk:
assistant_message["content"] += chunk
yield {"type": "token", "content": chunk}
if message.get("tool_calls"):
tool_calls.extend(message["tool_calls"])
if event.get("done"):
metrics = self._stream_metrics(event)
if metrics:
yield {"type": "metrics", **metrics}
try:
async for event in self._ollama_chat_stream(
content,
messages,
previous_interaction=previous_interaction,
thread_id=resolved_thread_id,
):
message = event.get("message") or {}
chunk = message.get("content") or ""
if chunk:
assistant_message["content"] += chunk
yield {"type": "token", "content": chunk}
if message.get("tool_calls"):
tool_calls.extend(message["tool_calls"])
if event.get("done"):
metrics = self._stream_metrics(event)
if metrics:
yield {"type": "metrics", **metrics}
except Exception as exc:
if not last_tool_results:
yield {"type": "warning", "message": f"Chat failed before any tool result was available: {exc}"}
yield {"type": "done", "pending_actions": self._pending_payloads(), "thread_id": resolved_thread_id}
return
fallback = self._tool_result_fallback(
last_tool_results,
f"The local model stopped after the tool call: {exc}",
)
assistant_message["content"] = fallback
messages.append(assistant_message)
if self.memory:
self.memory.add_conversation("assistant", fallback, resolved_thread_id)
yield {"type": "token", "content": fallback}
yield {"type": "done", "pending_actions": self._pending_payloads(), "thread_id": resolved_thread_id}
return
if not tool_calls:
self.messages.append(assistant_message)
if not assistant_message.get("content", "").strip():
fallback = self._empty_response_fallback(last_tool_results)
assistant_message["content"] = fallback
yield {"type": "token", "content": fallback}
messages.append(assistant_message)
if self.memory:
self.memory.add_conversation("assistant", assistant_message.get("content", ""))
yield {"type": "done", "pending_actions": self._pending_payloads()}
self.memory.add_conversation("assistant", assistant_message.get("content", ""), resolved_thread_id)
yield {"type": "done", "pending_actions": self._pending_payloads(), "thread_id": resolved_thread_id}
return
assistant_message["tool_calls"] = tool_calls
self.messages.append(assistant_message)
messages.append(assistant_message)
for call in tool_calls:
name, arguments = self._extract_call(call)
yield {"type": "status", "message": self._tool_status(name)}
result = await self.tools.execute(name, arguments)
self.messages.append({"role": "tool", "tool_name": name, "content": json.dumps(result)})
last_tool_results.append({"tool": name, "result": result})
messages.append({"role": "tool", "tool_name": name, "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."
self.messages.append({"role": "assistant", "content": fallback})
messages.append({"role": "assistant", "content": fallback})
if self.memory:
self.memory.add_conversation("assistant", fallback)
self.memory.add_conversation("assistant", fallback, resolved_thread_id)
yield {"type": "token", "content": fallback}
yield {"type": "done", "pending_actions": self._pending_payloads()}
yield {"type": "done", "pending_actions": self._pending_payloads(), "thread_id": resolved_thread_id}
async def generate_wake_response(self, wake_message: str) -> str:
await self.ensure_available()
self.messages.append({"role": "user", "content": wake_message})
response = await self._ollama_chat(wake_message)
message = response.get("message") or {}
content = message.get("content", "")
self.messages.append({"role": "assistant", "content": content})
if self.memory:
self.memory.add_conversation("system", wake_message)
self.memory.add_conversation("assistant", content)
return content or wake_message
messages = self._messages_for_thread("wake")
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(5):
try:
response = await self._ollama_chat(
wake_message,
messages,
previous_interaction=previous_interaction,
thread_id="wake",
)
except Exception as exc:
if not last_tool_results:
raise
content = self._tool_result_fallback(
last_tool_results,
f"The local model stopped after the wake-job tool call: {exc}",
)
messages.append({"role": "assistant", "content": content})
if self.memory:
self.memory.add_conversation("system", wake_message, "wake")
self.memory.add_conversation("assistant", content, "wake")
return content
message = response.get("message") or {}
tool_calls = message.get("tool_calls") or []
if not tool_calls:
content = message.get("content", "")
if not content.strip():
content = self._empty_response_fallback(last_tool_results)
messages.append({"role": "assistant", "content": content})
if self.memory:
self.memory.add_conversation("system", wake_message, "wake")
self.memory.add_conversation("assistant", content, "wake")
return content
async def _ollama_chat(self, query: str = "", previous_interaction: dict[str, Any] | None = None) -> dict[str, Any]:
messages.append(message)
for call in tool_calls:
name, arguments = self._extract_call(call)
result = await self.tools.execute(name, arguments)
last_tool_results.append({"tool": name, "result": result})
messages.append({"role": "tool", "tool_name": name, "content": json.dumps(result)})
content = "I hit the tool-call limit while running this scheduled wake job. Check the job prompt or pending approvals."
messages.append({"role": "assistant", "content": content})
if self.memory:
self.memory.add_conversation("system", wake_message, "wake")
self.memory.add_conversation("assistant", content, "wake")
return content
async def _ollama_chat(
self,
query: str = "",
messages: list[dict[str, Any]] | None = None,
previous_interaction: dict[str, Any] | None = None,
thread_id: str | None = DEFAULT_THREAD_ID,
) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=120) as client:
response = await client.post(
f"{self.base_url}/api/chat",
json={
"model": self.model,
"messages": self._messages_with_context(query, previous_interaction=previous_interaction),
"messages": self._messages_with_context(
query,
messages or self._messages_for_thread(thread_id),
previous_interaction=previous_interaction,
thread_id=thread_id,
),
"tools": self.tools.schemas,
"options": self._ollama_options(),
"stream": False,
@@ -182,7 +293,9 @@ class OllamaAgent:
async def _ollama_chat_stream(
self,
query: str = "",
messages: list[dict[str, Any]] | None = None,
previous_interaction: dict[str, Any] | None = None,
thread_id: str | None = DEFAULT_THREAD_ID,
) -> AsyncIterator[dict[str, Any]]:
async with httpx.AsyncClient(timeout=120) as client:
async with client.stream(
@@ -190,7 +303,12 @@ class OllamaAgent:
f"{self.base_url}/api/chat",
json={
"model": self.model,
"messages": self._messages_with_context(query, previous_interaction=previous_interaction),
"messages": self._messages_with_context(
query,
messages or self._messages_for_thread(thread_id),
previous_interaction=previous_interaction,
thread_id=thread_id,
),
"tools": self.tools.schemas,
"options": self._ollama_options(),
"stream": True,
@@ -204,14 +322,21 @@ class OllamaAgent:
def _messages_with_context(
self,
query: str,
messages: list[dict[str, Any]],
previous_interaction: dict[str, Any] | None = None,
thread_id: str | None = DEFAULT_THREAD_ID,
) -> list[dict[str, Any]]:
context = self._runtime_context(query, previous_interaction=previous_interaction)
context = self._runtime_context(query, previous_interaction=previous_interaction, thread_id=thread_id)
if not context:
return self.messages
return [self.messages[0], {"role": "system", "content": context}, *self.messages[1:]]
return messages
return [messages[0], {"role": "system", "content": context}, *messages[1:]]
def _runtime_context(self, query: str, previous_interaction: dict[str, Any] | None = None) -> str:
def _runtime_context(
self,
query: str,
previous_interaction: dict[str, Any] | None = None,
thread_id: str | None = DEFAULT_THREAD_ID,
) -> str:
local_zone = get_localzone()
parts = [
f"Current local date/time: {iso_now()} UTC; {iso_now_in_zone(local_zone)} {local_zone}.",
@@ -244,7 +369,7 @@ class OllamaAgent:
parts.append(identity)
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()
last = previous_interaction if previous_interaction is not None else self.memory.last_interaction(thread_id)
if last:
parts.append(
f"Previous interaction before this message: {last['created_at']} "
@@ -261,16 +386,85 @@ class OllamaAgent:
)
parts.append(f"Relevant long-term memories:\n{memory_text}")
recent = self.memory.recent_conversation(limit=6)
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:\n{recent_text}")
parts.append(f"Recent conversation excerpts from this chat:\n{recent_text}")
return "\n".join(parts)
def _messages_for_thread(self, thread_id: str | None) -> list[dict[str, Any]]:
resolved_thread_id = self._thread_id(thread_id)
if resolved_thread_id not in self.thread_messages:
messages: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}]
if self.memory:
self.memory.ensure_thread(resolved_thread_id)
for item in self.memory.recent_conversation(limit=30, thread_id=resolved_thread_id):
role = item.get("role")
if role in {"user", "assistant"} and item.get("content"):
messages.append({"role": role, "content": item["content"]})
self.thread_messages[resolved_thread_id] = messages
return self.thread_messages[resolved_thread_id]
async def _title_first_message(
self,
thread_id: str,
first_message: str,
previous_interaction: dict[str, Any] | None,
) -> None:
if self.memory is None or previous_interaction is not None:
return
thread = self.memory.get_thread(thread_id)
if not thread or thread.get("title") != "New chat":
return
title = await self._generate_chat_title(first_message)
self.memory.rename_thread(thread_id, title or MemoryStore._thread_title(first_message))
async def _generate_chat_title(self, first_message: str) -> str:
prompt = (
"Create a concise chat title for this first user message. "
"Use 2 to 6 words. No quotes, no punctuation at the end, no preamble.\n\n"
f"Message: {first_message[:800]}"
)
try:
async with httpx.AsyncClient(timeout=20) as client:
response = await client.post(
f"{self.base_url}/api/chat",
json={
"model": self.model,
"messages": [
{"role": "system", "content": "You write short chat titles."},
{"role": "user", "content": prompt},
],
"options": self._ollama_options(),
"stream": False,
},
)
response.raise_for_status()
message = response.json().get("message") or {}
return self._clean_generated_title(message.get("content", ""))
except Exception:
return ""
@staticmethod
def _thread_id(thread_id: str | None) -> str:
return (thread_id or DEFAULT_THREAD_ID).strip() or DEFAULT_THREAD_ID
@staticmethod
def _clean_generated_title(title: str) -> str:
text = re.sub(r"[\r\n]+", " ", title).strip().strip('"').strip("'")
text = re.sub(r"^(title|chat title)\s*:\s*", "", text, flags=re.IGNORECASE).strip()
text = text.rstrip(".!?;:-").strip()
if not text:
return ""
words = text.split()
if len(words) > 8:
text = " ".join(words[:8])
return text[:64]
def _pending_payloads(self) -> list[dict[str, Any]]:
return [
{
@@ -288,6 +482,27 @@ class OllamaAgent:
return {}
return {"num_ctx": self.num_ctx}
@staticmethod
def _empty_response_fallback(tool_results: list[dict[str, Any]]) -> str:
if not tool_results:
return "I did not get a usable response from the local model. Please try again, or narrow the request a bit."
return OllamaAgent._tool_result_fallback(
tool_results,
"I completed the tool call, but the local model did not write a final answer.",
)
@staticmethod
def _tool_result_fallback(tool_results: list[dict[str, Any]], reason: str) -> str:
last = tool_results[-1]
text = json.dumps(last, indent=2, ensure_ascii=True)
if len(text) > 1800:
text = text[:1800] + "\n..."
return (
f"{reason} "
"Here is the last tool result so you are not left staring at a blank response:\n\n"
f"```json\n{text}\n```"
)
@staticmethod
def _tool_status(name: str) -> str:
if name.startswith("get_uex_"):
@@ -301,6 +516,11 @@ class OllamaAgent:
"summarize_uex_commodity_price_history": "Summarizing commodity price history",
"summarize_uex_marketplace_price_history": "Summarizing marketplace price history",
"summarize_uex_currency_index_history": "Summarizing currency index history",
"list_scmdb_versions": "Checking SCMDB versions",
"search_scmdb_missions": "Searching SCMDB missions",
"get_scmdb_mission_rewards": "Fetching SCMDB mission rewards",
"search_cornerstone_items": "Searching Cornerstone items",
"get_cornerstone_item_locations": "Fetching Cornerstone item locations",
"uex_api_catalog": "Checking UEX API catalog",
"uex_get": "Fetching UEX data",
"uex_draft_post": "Drafting UEX write for approval",
+4
View File
@@ -15,6 +15,8 @@ CONFIG_FIELDS: dict[str, dict[str, Any]] = {
"ollama_model": {"env": "OLLAMA_MODEL", "type": "string", "secret": False},
"ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False},
"uex_base_url": {"env": "UEX_BASE_URL", "type": "string", "secret": False},
"scmdb_base_url": {"env": "SCMDB_BASE_URL", "type": "string", "secret": False},
"cornerstone_base_url": {"env": "CORNERSTONE_BASE_URL", "type": "string", "secret": False},
"uex_secret_key": {"env": "UEX_SECRET_KEY", "type": "string", "secret": True},
"uex_bearer_token": {"env": "UEX_BEARER_TOKEN", "type": "string", "secret": True},
"traderai_user_name": {"env": "TRADERAI_USER_NAME", "type": "string", "secret": False},
@@ -64,6 +66,8 @@ class Settings(BaseSettings):
ollama_model: str = "qwen3.5:9b"
ollama_num_ctx: int = 64512
uex_base_url: str = "https://api.uexcorp.space/2.0"
scmdb_base_url: str = "https://scmdb.net"
cornerstone_base_url: str = "https://finder.cstone.space"
uex_secret_key: str | None = Field(default=None)
uex_bearer_token: str | None = Field(default=None)
traderai_user_name: str | None = Field(default=None)
+159
View File
@@ -0,0 +1,159 @@
from __future__ import annotations
from html.parser import HTMLParser
import json
from typing import Any
import httpx
class CornerstoneError(RuntimeError):
pass
class CornerstoneClient:
def __init__(self, base_url: str = "https://finder.cstone.space") -> None:
self.base_url = base_url.rstrip("/")
self._items: list[dict[str, Any]] | None = None
async def list_items(self) -> list[dict[str, Any]]:
if self._items is not None:
return self._items
body = await self._get_json("GetSearch")
if isinstance(body, str):
body = json.loads(body)
if not isinstance(body, list):
raise CornerstoneError("Cornerstone search response was not a list.")
self._items = [
{"id": item.get("id"), "name": item.get("name"), "sold": bool(item.get("Sold"))}
for item in body
if isinstance(item, dict) and item.get("id") and item.get("name")
]
return self._items
async def get_item_page(self, item_id: str) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
response = await client.get(
f"{self.base_url}/Search/{item_id.strip()}",
headers={"Accept": "text/html,application/xhtml+xml"},
)
if response.status_code >= 400:
raise CornerstoneError(f"Cornerstone HTTP {response.status_code}: {response.text[:240]}")
return {"url": str(response.url), "html": response.text}
async def _get_json(self, path: str) -> Any:
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
response = await client.get(f"{self.base_url}/{path.lstrip('/')}", headers={"Accept": "application/json"})
try:
body = response.json()
except ValueError as exc:
raise CornerstoneError(f"Cornerstone returned non-JSON response: HTTP {response.status_code}") from exc
if response.status_code >= 400:
raise CornerstoneError(f"Cornerstone HTTP {response.status_code}: {body}")
return body
class CornerstonePageParser(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.title = ""
self.tables: list[list[list[str]]] = []
self._skip_depth = 0
self._in_title = False
self._current_table: list[list[str]] | None = None
self._current_row: list[str] | None = None
self._current_cell: list[str] | None = None
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
tag = tag.casefold()
if tag in {"script", "style"}:
self._skip_depth += 1
return
if self._skip_depth:
return
if tag == "title":
self._in_title = True
elif tag == "table":
self._current_table = []
elif tag == "tr" and self._current_table is not None:
self._current_row = []
elif tag in {"td", "th"} and self._current_row is not None:
self._current_cell = []
def handle_endtag(self, tag: str) -> None:
tag = tag.casefold()
if tag in {"script", "style"} and self._skip_depth:
self._skip_depth -= 1
return
if self._skip_depth:
return
if tag == "title":
self._in_title = False
elif tag in {"td", "th"} and self._current_cell is not None and self._current_row is not None:
text = " ".join("".join(self._current_cell).split())
self._current_row.append(text)
self._current_cell = None
elif tag == "tr" and self._current_row is not None and self._current_table is not None:
if any(cell for cell in self._current_row):
self._current_table.append(self._current_row)
self._current_row = None
elif tag == "table" and self._current_table is not None:
if self._current_table:
self.tables.append(self._current_table)
self._current_table = None
def handle_data(self, data: str) -> None:
if self._skip_depth:
return
if self._in_title:
self.title += data
if self._current_cell is not None:
self._current_cell.append(data)
def parse_cornerstone_item_page(html: str) -> dict[str, Any]:
parser = CornerstonePageParser()
parser.feed(html)
info: dict[str, Any] = {"page_title": " ".join(parser.title.split())}
general: dict[str, str] = {}
locations = []
for table in parser.tables:
if not table:
continue
header = [cell.casefold() for cell in table[0]]
if len(header) >= 3 and "location" in header[0] and "price" in header[1] and "verified" in header[2]:
for row in table[1:]:
if len(row) < 3:
continue
locations.append(
{
"location": row[0],
"base_price": _parse_cornerstone_price(row[1]),
"base_price_display": row[1],
"verified": row[2],
}
)
elif all(len(row) >= 2 for row in table):
for row in table:
key = row[0].strip().lower().replace(" ", "_")
value = row[1].strip()
if key and value and key not in general:
general[key] = value
info["name"] = general.get("name") or _name_from_title(info["page_title"])
if general:
info["general"] = general
info["locations"] = locations
return info
def _parse_cornerstone_price(value: str) -> int | None:
digits = "".join(char for char in value if char.isdigit())
return int(digits) if digits else None
def _name_from_title(title: str) -> str | None:
if " - " not in title:
return title or None
return title.rsplit(" - ", 1)[-1].strip() or None
+195 -16
View File
@@ -8,6 +8,9 @@ from typing import Any
from zoneinfo import ZoneInfo
DEFAULT_THREAD_ID = "default"
def utc_now() -> datetime:
return datetime.now(timezone.utc)
@@ -65,8 +68,16 @@ class MemoryStore:
with self._connect() as db:
db.executescript(
"""
CREATE TABLE IF NOT EXISTS chat_threads (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id TEXT,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL
@@ -129,27 +140,152 @@ class MemoryStore:
);
"""
)
def add_conversation(self, role: str, content: str) -> None:
with self._connect() as db:
self._ensure_column(db, "conversations", "thread_id", "TEXT")
now = iso_now()
db.execute(
"INSERT INTO conversations(role, content, created_at) VALUES (?, ?, ?)",
(role, content, iso_now()),
"""
INSERT INTO chat_threads(id, title, created_at, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING
""",
(DEFAULT_THREAD_ID, "New chat", now, now),
)
db.execute(
"UPDATE conversations SET thread_id = ? WHERE thread_id IS NULL OR thread_id = ''",
(DEFAULT_THREAD_ID,),
)
def last_interaction(self) -> dict[str, Any] | None:
@staticmethod
def _ensure_column(db: sqlite3.Connection, table: str, column: str, definition: str) -> None:
columns = {row["name"] for row in db.execute(f"PRAGMA table_info({table})").fetchall()}
if column not in columns:
db.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
def ensure_thread(self, thread_id: str | None = None, title: str | None = None) -> dict[str, Any]:
now = iso_now()
resolved_id = (thread_id or DEFAULT_THREAD_ID).strip() or DEFAULT_THREAD_ID
resolved_title = (title or "New chat").strip() or "New chat"
with self._connect() as db:
db.execute(
"""
INSERT INTO chat_threads(id, title, created_at, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET updated_at=excluded.updated_at
""",
(resolved_id, resolved_title, now, now),
)
row = db.execute(
"SELECT role, content, created_at FROM conversations ORDER BY id DESC LIMIT 1"
"SELECT id, title, created_at, updated_at FROM chat_threads WHERE id = ?",
(resolved_id,),
).fetchone()
return dict(row)
def create_thread(self, title: str | None = None) -> dict[str, Any]:
import uuid
thread_id = f"chat-{uuid.uuid4()}"
return self.ensure_thread(thread_id, title or "New chat")
def list_threads(self) -> list[dict[str, Any]]:
with self._connect() as db:
rows = db.execute(
"""
SELECT
t.id,
t.title,
t.created_at,
t.updated_at,
COUNT(c.id) AS message_count,
MAX(c.created_at) AS last_message_at
FROM chat_threads t
LEFT JOIN conversations c ON c.thread_id = t.id
GROUP BY t.id
ORDER BY COALESCE(MAX(c.created_at), t.updated_at) DESC
"""
).fetchall()
return [dict(row) for row in rows]
def delete_thread(self, thread_id: str) -> bool:
with self._connect() as db:
db.execute("DELETE FROM conversations WHERE thread_id = ?", (thread_id,))
cursor = db.execute("DELETE FROM chat_threads WHERE id = ?", (thread_id,))
return cursor.rowcount > 0
def rename_thread(self, thread_id: str, title: str) -> dict[str, Any] | None:
clean_title = self._clean_thread_title(title)
if not clean_title:
return None
now = iso_now()
with self._connect() as db:
db.execute(
"UPDATE chat_threads SET title = ?, updated_at = ? WHERE id = ?",
(clean_title, now, thread_id),
)
row = db.execute(
"SELECT id, title, created_at, updated_at FROM chat_threads WHERE id = ?",
(thread_id,),
).fetchone()
return dict(row) if row else None
def recent_conversation(self, limit: int = 8) -> list[dict[str, Any]]:
def get_thread(self, thread_id: str) -> dict[str, Any] | None:
with self._connect() as db:
rows = db.execute(
"SELECT role, content, created_at FROM conversations ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
row = db.execute(
"SELECT id, title, created_at, updated_at FROM chat_threads WHERE id = ?",
(thread_id,),
).fetchone()
return dict(row) if row else None
def add_conversation(self, role: str, content: str, thread_id: str | None = DEFAULT_THREAD_ID) -> None:
resolved_thread_id = (thread_id or DEFAULT_THREAD_ID).strip() or DEFAULT_THREAD_ID
self.ensure_thread(resolved_thread_id)
now = iso_now()
with self._connect() as db:
db.execute(
"INSERT INTO conversations(thread_id, role, content, created_at) VALUES (?, ?, ?, ?)",
(resolved_thread_id, role, content, now),
)
db.execute(
"UPDATE chat_threads SET updated_at = ? WHERE id = ?",
(now, resolved_thread_id),
)
def last_interaction(self, thread_id: str | None = None) -> dict[str, Any] | None:
with self._connect() as db:
if thread_id:
row = db.execute(
"""
SELECT thread_id, role, content, created_at
FROM conversations
WHERE thread_id = ?
ORDER BY id DESC
LIMIT 1
""",
(thread_id,),
).fetchone()
else:
row = db.execute(
"SELECT thread_id, role, content, created_at FROM conversations ORDER BY id DESC LIMIT 1"
).fetchone()
return dict(row) if row else None
def recent_conversation(self, limit: int = 8, thread_id: str | None = None) -> list[dict[str, Any]]:
with self._connect() as db:
if thread_id:
rows = db.execute(
"""
SELECT id, thread_id, role, content, created_at
FROM conversations
WHERE thread_id = ?
ORDER BY id DESC
LIMIT ?
""",
(thread_id, limit),
).fetchall()
else:
rows = db.execute(
"SELECT id, thread_id, role, content, created_at FROM conversations ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(row) for row in reversed(rows)]
def remember(self, kind: str, content: str, importance: int = 3, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
@@ -222,13 +358,22 @@ class MemoryStore:
).fetchall()
conversations = db.execute(
"""
SELECT id, role, content, created_at
SELECT id, thread_id, role, content, created_at
FROM conversations
ORDER BY id DESC
LIMIT ?
""",
(limit,),
).fetchall()
threads = db.execute(
"""
SELECT id, title, created_at, updated_at
FROM chat_threads
ORDER BY updated_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
profile_rows = db.execute(
"SELECT key, value, updated_at FROM user_profile ORDER BY key"
).fetchall()
@@ -252,6 +397,7 @@ class MemoryStore:
return {
"path": str(self.path),
"memories": [self._memory_row(row) for row in memories],
"chat_threads": [dict(row) for row in threads],
"conversations": [dict(row) for row in conversations],
"profile": profile,
"scheduled_jobs": [dict(row) for row in jobs],
@@ -339,17 +485,38 @@ class MemoryStore:
).fetchall()
return [dict(row) for row in rows]
def mark_job_run(self, job_id: str, next_run_at: str | None = None) -> None:
def mark_job_run(self, job_id: str, next_run_at: str | None = None, enabled: bool = True) -> None:
with self._connect() as db:
db.execute(
"UPDATE scheduled_jobs SET last_run_at = ?, next_run_at = ? WHERE id = ?",
(iso_now(), next_run_at, job_id),
"UPDATE scheduled_jobs SET last_run_at = ?, next_run_at = ?, enabled = ? WHERE id = ?",
(iso_now(), next_run_at, 1 if enabled else 0, job_id),
)
def add_outbox(self, content: str) -> None:
with self._connect() as db:
db.execute("INSERT INTO outbox(content, created_at) VALUES (?, ?)", (content, iso_now()))
def list_outbox(self, limit: int = 100) -> list[dict[str, Any]]:
with self._connect() as db:
rows = db.execute(
"SELECT id, content, created_at, delivered_at FROM outbox ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(row) for row in rows]
def get_outbox(self, inbox_id: int) -> dict[str, Any] | None:
with self._connect() as db:
row = db.execute(
"SELECT id, content, created_at, delivered_at FROM outbox WHERE id = ?",
(inbox_id,),
).fetchone()
return dict(row) if row else None
def delete_outbox(self, inbox_id: int) -> bool:
with self._connect() as db:
cursor = db.execute("DELETE FROM outbox WHERE id = ?", (inbox_id,))
return cursor.rowcount > 0
def undelivered_outbox(self) -> list[dict[str, Any]]:
now = iso_now()
with self._connect() as db:
@@ -362,6 +529,18 @@ class MemoryStore:
)
return [dict(row) for row in rows]
@staticmethod
def _thread_title(content: str) -> str:
text = " ".join(content.strip().split())
if not text:
return "New chat"
return text[:42] + ("..." if len(text) > 42 else "")
@staticmethod
def _clean_thread_title(title: str) -> str:
text = " ".join(title.strip().strip('"').strip("'").split())
return text[:64]
@staticmethod
def _fts_query(query: str) -> str:
tokens = [token.replace('"', "") for token in query.split() if token.strip()]
+17 -3
View File
@@ -83,11 +83,20 @@ class WakeScheduler:
)
if self.agent is None:
self.memory.add_outbox(wake_message)
self._mark_job_finished(job_id)
return
text = await self.agent.generate_wake_response(wake_message)
try:
text = await self.agent.generate_wake_response(wake_message)
except Exception as exc:
text = f"Wake job failed: {exc}. Job instruction: {prompt}"
self.memory.add_outbox(text)
self.memory.mark_job_run(job_id)
self._mark_job_finished(job_id)
def _mark_job_finished(self, job_id: str) -> None:
job = self.scheduler.get_job(job_id)
next_run = job.next_run_time if job else None
self.memory.mark_job_run(job_id, next_run.isoformat() if next_run else None, enabled=bool(next_run))
def _schedule_notification_poll(self) -> None:
if self.uex is None:
@@ -104,7 +113,12 @@ class WakeScheduler:
if self.uex is None:
return []
response = await self.uex.get_user_notifications()
try:
response = await self.uex.get_user_notifications()
except Exception as exc:
self.memory.add_outbox(f"UEX notification poll failed: {exc}")
self.memory.set_profile("uex_last_notification_error", str(exc))
return []
notifications = response.get("notifications") or []
pending = [item for item in notifications if not item.get("date_read")]
profile = self.memory.get_profile()
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
from typing import Any
import httpx
class SCMDBError(RuntimeError):
pass
class SCMDBClient:
def __init__(self, base_url: str = "https://scmdb.net") -> None:
self.base_url = base_url.rstrip("/")
self._versions: list[dict[str, Any]] | None = None
self._data_cache: dict[str, dict[str, Any]] = {}
async def list_versions(self) -> list[dict[str, Any]]:
if self._versions is not None:
return self._versions
body = await self._get_json("data/versions.json")
if not isinstance(body, list):
raise SCMDBError("SCMDB versions response was not a list.")
self._versions = [
item
for item in body
if isinstance(item, dict) and item.get("version") and item.get("file")
]
return self._versions
async def get_data(self, version: str | None = None, channel: str = "live") -> dict[str, Any]:
selected = await self.resolve_version(version=version, channel=channel)
cache_key = str(selected["version"])
if cache_key not in self._data_cache:
body = await self._get_json(f"data/{selected['file']}")
if not isinstance(body, dict):
raise SCMDBError(f"SCMDB data for {cache_key} was not an object.")
self._data_cache[cache_key] = body
return self._data_cache[cache_key]
async def resolve_version(self, version: str | None = None, channel: str = "live") -> dict[str, Any]:
versions = await self.list_versions()
if not versions:
raise SCMDBError("SCMDB did not return any data versions.")
if version:
needle = version.casefold().strip()
for item in versions:
item_version = str(item["version"])
if item_version.casefold() == needle or needle in item_version.casefold():
return item
raise SCMDBError(f"SCMDB version not found: {version}")
channel = (channel or "live").casefold().strip()
if channel in {"latest", "any", "all"}:
return versions[0]
if channel not in {"live", "ptu"}:
raise SCMDBError("SCMDB channel must be live, ptu, or latest.")
for item in versions:
if f"-{channel}." in str(item["version"]).casefold():
return item
return versions[0]
async def _get_json(self, path: str) -> Any:
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
response = await client.get(f"{self.base_url}/{path.lstrip('/')}", headers={"Accept": "application/json"})
try:
body = response.json()
except ValueError as exc:
raise SCMDBError(f"SCMDB returned non-JSON response: HTTP {response.status_code}") from exc
if response.status_code >= 400:
raise SCMDBError(f"SCMDB HTTP {response.status_code}: {body}")
return body
+91 -4
View File
@@ -21,8 +21,10 @@ from pydantic import BaseModel
from traderai.agent import OllamaAgent, OllamaUnavailable
from traderai.config import save_settings, settings_payload
from traderai.config import get_settings
from traderai.memory import MemoryStore
from traderai.cornerstone_client import CornerstoneClient
from traderai.memory import DEFAULT_THREAD_ID, MemoryStore
from traderai.scheduler import WakeScheduler
from traderai.scmdb_client import SCMDBClient
from traderai.tools import ToolRegistry
from traderai.uex_client import UEXClient
from traderai.version import RELEASES_API_URL, RELEASES_URL, __version__
@@ -35,6 +37,19 @@ def resource_path(*parts: str) -> Path:
class ChatRequest(BaseModel):
message: str
thread_id: str | None = DEFAULT_THREAD_ID
class ChatThreadRequest(BaseModel):
title: str | None = None
class RenameChatThreadRequest(BaseModel):
title: str
class DirectNegotiationMessageRequest(BaseModel):
message: str
class ClearMemoryRequest(BaseModel):
@@ -62,7 +77,16 @@ def create_app() -> FastAPI:
memory = MemoryStore(settings.traderai_memory_path)
scheduler = WakeScheduler(memory)
uex = UEXClient(settings.uex_base_url, settings.uex_secret_key, settings.uex_bearer_token)
tools = ToolRegistry(uex, settings.require_write_approval, memory=memory, scheduler=scheduler)
scmdb = SCMDBClient(settings.scmdb_base_url)
cornerstone = CornerstoneClient(settings.cornerstone_base_url)
tools = ToolRegistry(
uex,
settings.require_write_approval,
memory=memory,
scheduler=scheduler,
scmdb=scmdb,
cornerstone=cornerstone,
)
agent = OllamaAgent(
settings.ollama_base_url,
settings.ollama_model,
@@ -246,18 +270,43 @@ def create_app() -> FastAPI:
@app.post("/api/chat")
async def chat(request: ChatRequest) -> dict:
try:
return await agent.chat(request.message)
return await agent.chat(request.message, thread_id=request.thread_id)
except OllamaUnavailable as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
@app.post("/api/chat/stream")
async def chat_stream(request: ChatRequest) -> StreamingResponse:
async def events():
async for event in agent.chat_events(request.message):
async for event in agent.chat_events(request.message, thread_id=request.thread_id):
yield f"data: {json.dumps(event)}\n\n"
return StreamingResponse(events(), media_type="text/event-stream")
@app.get("/api/chats")
async def chats() -> dict:
return {"chats": memory.list_threads()}
@app.post("/api/chats")
async def create_chat(request: ChatThreadRequest) -> dict:
return {"chat": memory.create_thread(request.title)}
@app.get("/api/chats/{thread_id}/messages")
async def chat_messages(thread_id: str) -> dict:
memory.ensure_thread(thread_id)
return {"thread_id": thread_id, "messages": memory.recent_conversation(limit=200, thread_id=thread_id)}
@app.delete("/api/chats/{thread_id}")
async def delete_chat(thread_id: str) -> dict:
deleted = memory.delete_thread(thread_id)
return {"deleted": deleted, "chats": memory.list_threads()}
@app.patch("/api/chats/{thread_id}")
async def rename_chat(thread_id: str, request: RenameChatThreadRequest) -> dict:
chat = memory.rename_thread(thread_id, request.title)
if not chat:
raise HTTPException(status_code=400, detail="A non-empty chat title is required.")
return {"chat": chat, "chats": memory.list_threads()}
@app.get("/api/pending-actions")
async def pending_actions() -> dict:
return {"pending_actions": agent._pending_payloads()}
@@ -266,6 +315,35 @@ def create_app() -> FastAPI:
async def notifications() -> dict:
return {"notifications": memory.undelivered_outbox()}
@app.get("/api/inbox")
async def inbox() -> dict:
return {"inbox": memory.list_outbox()}
@app.post("/api/inbox/{inbox_id}/continue")
async def continue_inbox(inbox_id: int) -> dict:
item = memory.get_outbox(inbox_id)
if not item:
raise HTTPException(status_code=404, detail="Inbox item not found.")
thread = memory.create_thread("Inbox follow-up")
memory.add_conversation("assistant", item["content"], thread["id"])
return {"chat": thread, "message": item}
@app.delete("/api/inbox/{inbox_id}")
async def delete_inbox(inbox_id: int) -> dict:
deleted = memory.delete_outbox(inbox_id)
return {"deleted": deleted, "inbox": memory.list_outbox()}
@app.get("/api/negotiations/{identifier}/messages")
async def negotiation_messages(identifier: str) -> dict:
params = negotiation_identifier_params(identifier)
return await uex.get("marketplace_negotiations_messages", params, authenticated=True)
@app.post("/api/negotiations/{identifier}/messages")
async def send_negotiation_message(identifier: str, request: DirectNegotiationMessageRequest) -> dict:
params = negotiation_identifier_params(identifier)
payload = {**params, "message": request.message, "is_production": 1}
return await uex.post("marketplace_negotiations_messages", payload, authenticated=True)
@app.get("/api/wake-jobs")
async def wake_jobs() -> dict:
return {"scheduled_jobs": scheduler.list_jobs()}
@@ -300,6 +378,15 @@ def create_app() -> FastAPI:
return app
def negotiation_identifier_params(identifier: str) -> dict[str, Any]:
value = identifier.strip()
if not value:
raise HTTPException(status_code=400, detail="Negotiation id or hash is required.")
if value.isdigit():
return {"id_negotiation": int(value)}
return {"hash": value}
async def inspect_ollama() -> dict[str, Any]:
settings = get_settings()
executable = find_ollama_executable()
+664 -5
View File
@@ -4,8 +4,10 @@ import uuid
from dataclasses import dataclass
from typing import Any, Awaitable, Callable
from traderai.cornerstone_client import CornerstoneClient, parse_cornerstone_item_page
from traderai.memory import MemoryStore
from traderai.scheduler import WakeScheduler
from traderai.scmdb_client import SCMDBClient
from traderai.uex_client import UEXClient
@@ -129,9 +131,15 @@ UEX_DELETE_RESOURCES = {
UEX_RESOURCE_DESCRIPTIONS = {
"commodities_prices_history": "Historical commodity prices at a terminal. Requires id_terminal and id_commodity; accepts game_version. UEX limits this to 500 rows.",
"marketplace_prices_history": "Historical marketplace price snapshots, one row per listing per price change. Requires at least one filter; supports date_start/date_end and up to 1000 records.",
"marketplace_trends": "Current UEX marketplace trend metrics for an item. Use this when the user asks for trends, price movement, demand, or what the market is doing now.",
"currencies_index_history": "Historical UEX currency index snapshots with basket component detail. Supports currency, date_from, and date_to timestamps.",
}
UEX_PRODUCTION_WRITE_RESOURCES = {
"marketplace_advertise",
"marketplace_negotiations_messages",
}
@dataclass
class PendingAction:
@@ -149,8 +157,12 @@ class ToolRegistry:
require_write_approval: bool = True,
memory: MemoryStore | None = None,
scheduler: WakeScheduler | None = None,
scmdb: SCMDBClient | None = None,
cornerstone: CornerstoneClient | None = None,
) -> None:
self.uex = uex
self.scmdb = scmdb or SCMDBClient()
self.cornerstone = cornerstone or CornerstoneClient()
self.require_write_approval = require_write_approval
self.memory = memory
self.scheduler = scheduler
@@ -167,6 +179,11 @@ class ToolRegistry:
"schedule_wake_job": self.schedule_wake_job,
"list_wake_jobs": self.list_wake_jobs,
"check_uex_notifications": self.check_uex_notifications,
"list_scmdb_versions": self.list_scmdb_versions,
"search_scmdb_missions": self.search_scmdb_missions,
"get_scmdb_mission_rewards": self.get_scmdb_mission_rewards,
"search_cornerstone_items": self.search_cornerstone_items,
"get_cornerstone_item_locations": self.get_cornerstone_item_locations,
}
self.handlers["uex_api_catalog"] = self.uex_api_catalog
self.handlers["uex_get"] = self.uex_get
@@ -191,6 +208,8 @@ class ToolRegistry:
*self._history_summary_schemas(),
*self._uex_post_schemas(),
*self._uex_delete_schemas(),
*self._scmdb_schemas(),
*self._cornerstone_schemas(),
{
"type": "function",
"function": {
@@ -266,7 +285,7 @@ class ToolRegistry:
"message": {"type": "string"},
"hash": {"type": "string"},
"id_negotiation": {"type": "integer"},
"is_production": {"type": "integer", "enum": [0, 1], "default": 0},
"is_production": {"type": "integer", "enum": [0, 1], "default": 1},
},
},
},
@@ -298,7 +317,7 @@ class ToolRegistry:
"in_stock": {"type": "integer"},
"hours_expiration": {"type": "integer"},
"is_hidden": {"type": "integer", "enum": [0, 1]},
"is_production": {"type": "integer", "enum": [0, 1], "default": 0},
"is_production": {"type": "integer", "enum": [0, 1], "default": 1},
},
},
},
@@ -382,7 +401,7 @@ class ToolRegistry:
return {"error": f"Pending action not found: {action_id}"}
if action.method == "DELETE":
return await self.uex.delete(action.endpoint, action.payload, authenticated=True)
return await self.uex.post(action.endpoint, action.payload, authenticated=True)
return await self.uex.post(action.endpoint, self._production_payload(action.endpoint, action.payload), authenticated=True)
async def decline(self, action_id: str) -> dict[str, Any]:
action = self.pending_actions.pop(action_id, None)
@@ -761,6 +780,102 @@ class ToolRegistry:
},
]
@classmethod
def _scmdb_schemas(cls) -> list[dict[str, Any]]:
version_controls = {
"version": {"type": "string", "description": "SCMDB game-data version, such as 4.7.2-live.11715810."},
"channel": {"type": "string", "enum": ["live", "ptu", "latest"], "default": "live"},
}
return [
{
"type": "function",
"function": {
"name": "list_scmdb_versions",
"description": "List SCMDB mission-data versions. Use this when the user asks which Star Citizen game versions are available.",
"parameters": {
"type": "object",
"properties": {
"channel": {"type": "string", "enum": ["live", "ptu", "latest"]},
},
},
},
},
{
"type": "function",
"function": {
"name": "search_scmdb_missions",
"description": "Search SCMDB Star Citizen missions/contracts and return compact reward summaries: UEC, reputation, item, blueprint, and hauling rewards.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Text to search in title, debug name, description, faction, mission type, or reward names."},
"mission_type": {"type": "string", "description": "Mission type such as Hauling, Delivery, Bounty Hunter, Mercenary, Racing, Salvage, or Mining."},
"category": {"type": "string"},
"faction": {"type": "string"},
"system": {"type": "string", "description": "Star system such as Stanton or Pyro."},
"illegal": {"type": "boolean"},
"include_legacy": {"type": "boolean", "default": True},
"limit": {"type": "integer", "minimum": 1, "maximum": 25, "default": 10},
**version_controls,
},
},
},
},
{
"type": "function",
"function": {
"name": "get_scmdb_mission_rewards",
"description": "Fetch detailed SCMDB rewards and requirements for one Star Citizen mission/contract by id, debug name, or title.",
"parameters": {
"type": "object",
"properties": {
"id": {"type": "string"},
"debug_name": {"type": "string"},
"title": {"type": "string"},
"include_legacy": {"type": "boolean", "default": True},
**version_controls,
},
},
},
},
]
@classmethod
def _cornerstone_schemas(cls) -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": "search_cornerstone_items",
"description": "Search Cornerstone Universal Item Finder items. Use this to find exact item names and ids before asking where an item is sold.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Item name to search for."},
"sold_only": {"type": "boolean", "default": False, "description": "Only return items marked as sold in-game by Cornerstone."},
"limit": {"type": "integer", "minimum": 1, "maximum": 25, "default": 10},
},
},
},
},
{
"type": "function",
"function": {
"name": "get_cornerstone_item_locations",
"description": "Fetch where a Star Citizen item is sold using Cornerstone Universal Item Finder, including store/location, base price, and verified date.",
"parameters": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "Cornerstone item id from search_cornerstone_items."},
"query": {"type": "string", "description": "Item name if id is not known."},
"location": {"type": "string", "description": "Optional local filter for system, planet, station, city, or shop name."},
"limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 20},
},
},
},
},
]
@classmethod
def _uex_post_schemas(cls) -> list[dict[str, Any]]:
return [
@@ -915,7 +1030,13 @@ class ToolRegistry:
id_listing: int | None = None,
hash: str | None = None,
) -> dict[str, Any]:
return await self.uex.get("marketplace_negotiations", {"id": id, "id_listing": id_listing, "hash": hash}, authenticated=True)
response = await self.uex.get("marketplace_negotiations", {"id": id, "id_listing": id_listing, "hash": hash}, authenticated=True)
negotiations = [
self._summarize_negotiation(item)
for item in self._as_list(response.get("data"))
if isinstance(item, dict)
]
return {**response, "data": negotiations, "negotiations": negotiations}
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)
@@ -925,7 +1046,7 @@ class ToolRegistry:
message: str,
hash: str | None = None,
id_negotiation: int | None = None,
is_production: int = 0,
is_production: int = 1,
) -> dict[str, Any]:
payload = {"message": message, "hash": hash, "id_negotiation": id_negotiation, "is_production": is_production}
return self._pending("Send negotiation message", "marketplace_negotiations_messages", payload)
@@ -968,9 +1089,201 @@ class ToolRegistry:
pending = [item for item in notifications if not item.get("date_read")]
return {"count": len(pending), "notifications": pending}
async def list_scmdb_versions(self, channel: str | None = None) -> dict[str, Any]:
versions = await self.scmdb.list_versions()
channel_filter = (channel or "").casefold().strip()
if channel_filter in {"live", "ptu"}:
versions = [
item
for item in versions
if f"-{channel_filter}." in str(item.get("version", "")).casefold()
]
elif channel_filter not in {"", "latest"}:
return {"error": "SCMDB channel must be live, ptu, or latest."}
return {
"source": self.scmdb.base_url,
"count": len(versions),
"versions": versions,
"default_channel": "live",
}
async def search_scmdb_missions(
self,
query: str = "",
mission_type: str | None = None,
category: str | None = None,
faction: str | None = None,
system: str | None = None,
illegal: bool | None = None,
include_legacy: bool = True,
limit: int = 10,
version: str | None = None,
channel: str = "live",
) -> dict[str, Any]:
data = await self.scmdb.get_data(version=version, channel=channel)
q = (query or "").casefold().strip()
mission_type_filter = (mission_type or "").casefold().strip()
category_filter = (category or "").casefold().strip()
faction_filter = (faction or "").casefold().strip()
system_filter = (system or "").casefold().strip()
matched = []
for source, mission in self._scmdb_contracts(data, include_legacy=include_legacy):
summary = self._summarize_scmdb_mission(data, mission, source=source)
if mission_type_filter and mission_type_filter not in str(summary.get("mission_type") or "").casefold():
continue
if category_filter and category_filter not in str(summary.get("category") or "").casefold():
continue
if faction_filter and faction_filter not in str(summary.get("faction") or "").casefold():
continue
if system_filter and system_filter not in " ".join(summary.get("systems") or []).casefold():
continue
if illegal is not None and bool(summary.get("illegal")) != illegal:
continue
if q and q not in self._scmdb_search_text(data, mission, summary):
continue
matched.append(summary)
limit = max(1, min(limit, 25))
return {
"source": self.scmdb.base_url,
"version": data.get("version"),
"matched": len(matched),
"returned": min(len(matched), limit),
"truncated": len(matched) > limit,
"missions": matched[:limit],
}
async def get_scmdb_mission_rewards(
self,
id: str | None = None,
debug_name: str | None = None,
title: str | None = None,
include_legacy: bool = True,
version: str | None = None,
channel: str = "live",
) -> dict[str, Any]:
if not any([id, debug_name, title]):
return {"error": "Provide id, debug_name, or title."}
data = await self.scmdb.get_data(version=version, channel=channel)
exact = []
fuzzy = []
id_filter = (id or "").casefold().strip()
debug_filter = (debug_name or "").casefold().strip()
title_filter = (title or "").casefold().strip()
for source, mission in self._scmdb_contracts(data, include_legacy=include_legacy):
mission_id = str(mission.get("id") or "").casefold()
mission_debug = str(mission.get("debugName") or "").casefold()
mission_title = str(mission.get("title") or "").casefold()
if id_filter and mission_id == id_filter:
exact.append((source, mission))
elif debug_filter and mission_debug == debug_filter:
exact.append((source, mission))
elif title_filter and mission_title == title_filter:
exact.append((source, mission))
elif title_filter and title_filter in mission_title:
fuzzy.append((source, mission))
elif debug_filter and debug_filter in mission_debug:
fuzzy.append((source, mission))
candidates = exact or fuzzy
if len(candidates) != 1:
return {
"source": self.scmdb.base_url,
"version": data.get("version"),
"matched": len(candidates),
"error": "No SCMDB mission matched." if not candidates else "Multiple SCMDB missions matched; refine by id or debug_name.",
"matches": [
self._summarize_scmdb_mission(data, mission, source=source)
for source, mission in candidates[:10]
],
}
source, mission = candidates[0]
return {
"source": self.scmdb.base_url,
"version": data.get("version"),
"mission": self._summarize_scmdb_mission(data, mission, source=source, detailed=True),
}
async def search_cornerstone_items(
self,
query: str = "",
sold_only: bool = False,
limit: int = 10,
) -> dict[str, Any]:
items = await self.cornerstone.list_items()
q = (query or "").casefold().strip()
matches = []
for item in items:
if sold_only and not item.get("sold"):
continue
score = self._cornerstone_match_score(q, str(item.get("name") or ""))
if q and score <= 0:
continue
matches.append((score, item))
matches.sort(key=lambda match: (-match[0], str(match[1].get("name") or "").casefold()))
limit = max(1, min(limit, 25))
compacted = [
{
"id": item.get("id"),
"name": item.get("name"),
"sold": bool(item.get("sold")),
"url": f"{self.cornerstone.base_url}/Search/{item.get('id')}",
}
for _, item in matches[:limit]
]
return {
"source": self.cornerstone.base_url,
"matched": len(matches),
"returned": len(compacted),
"truncated": len(matches) > limit,
"items": compacted,
}
async def get_cornerstone_item_locations(
self,
id: str | None = None,
query: str | None = None,
location: str | None = None,
limit: int = 20,
) -> dict[str, Any]:
item = await self._resolve_cornerstone_item(id=id, query=query)
if not item:
return {"error": "No Cornerstone item matched. Provide an id or a more specific query."}
page = await self.cornerstone.get_item_page(str(item["id"]))
parsed = parse_cornerstone_item_page(page["html"])
locations = parsed.get("locations") or []
location_filter = (location or "").casefold().strip()
if location_filter:
locations = [
entry
for entry in locations
if location_filter in str(entry.get("location") or "").casefold()
]
limit = max(1, min(limit, 50))
return {
"source": self.cornerstone.base_url,
"url": page["url"],
"item": {
"id": item.get("id"),
"name": parsed.get("name") or item.get("name"),
"sold": bool(item.get("sold")),
"general": parsed.get("general") or {},
},
"matched_locations": len(locations),
"returned": min(len(locations), limit),
"truncated": len(locations) > limit,
"locations": locations[:limit],
}
def _pending(self, label: str, endpoint: str, payload: dict[str, Any], method: str = "POST") -> dict[str, Any]:
action_id = str(uuid.uuid4())
payload = {key: value for key, value in payload.items() if value is not None}
payload = self._production_payload(endpoint, payload)
self.pending_actions[action_id] = PendingAction(action_id, label, endpoint, payload, method)
return {
"pending_action": {
@@ -983,6 +1296,14 @@ class ToolRegistry:
}
}
@staticmethod
def _production_payload(endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
if endpoint not in UEX_PRODUCTION_WRITE_RESOURCES:
return payload
next_payload = dict(payload)
next_payload["is_production"] = 1
return next_payload
@staticmethod
def _validate_resource(resource: str, allowed: dict[str, Any] | set[str]) -> str:
normalized = resource.strip().strip("/").casefold()
@@ -1150,6 +1471,290 @@ class ToolRegistry:
def _is_number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool)
@staticmethod
def _scmdb_contracts(data: dict[str, Any], include_legacy: bool = True) -> list[tuple[str, dict[str, Any]]]:
contracts = [
("contracts", mission)
for mission in data.get("contracts") or []
if isinstance(mission, dict)
]
if include_legacy:
contracts.extend(
("legacyContracts", mission)
for mission in data.get("legacyContracts") or []
if isinstance(mission, dict)
)
return contracts
@classmethod
def _summarize_scmdb_mission(
cls,
data: dict[str, Any],
mission: dict[str, Any],
source: str,
detailed: bool = False,
) -> dict[str, Any]:
summary: dict[str, Any] = {
"id": mission.get("id"),
"debug_name": mission.get("debugName"),
"source": source,
"title": mission.get("title"),
"mission_type": mission.get("missionType"),
"category": mission.get("category"),
"faction": cls._scmdb_faction_name(data, mission.get("factionGuid")),
"systems": mission.get("systems") or [],
"illegal": bool(mission.get("illegal")),
"can_be_shared": bool(mission.get("canBeShared")),
"once_only": bool(mission.get("onceOnly")),
"time_to_complete_minutes": mission.get("timeToComplete"),
"max_players": mission.get("maxPlayersPerInstance"),
"cooldown_minutes": mission.get("personalCooldownTime"),
"min_standing": cls._scmdb_standing(mission.get("minStanding")),
"max_standing": cls._scmdb_standing(mission.get("maxStanding")),
"rewards": cls._scmdb_rewards(data, mission, detailed=detailed),
}
if detailed:
summary.update(
{
"description": cls._compact_scalar(mission.get("description")),
"locations": cls._scmdb_pool_names(data, "locationPools", mission.get("locations"), limit=20),
"destinations": cls._scmdb_pool_names(data, "locationPools", mission.get("destinations"), limit=20),
"prerequisites": cls._compact_scalar(mission.get("prerequisites")),
"available_in_prison": bool(mission.get("availableInPrison")),
"reaccept_after_abandoning": bool(mission.get("canReacceptAfterAbandoning")),
"reaccept_after_failing": bool(mission.get("canReacceptAfterFailing")),
"hide_in_mobiglas": bool(mission.get("hideInMobiGlas")),
}
)
return {key: value for key, value in summary.items() if value not in (None, "", [], {})}
@classmethod
def _scmdb_rewards(cls, data: dict[str, Any], mission: dict[str, Any], detailed: bool = False) -> dict[str, Any]:
rewards: dict[str, Any] = {
"uec": mission.get("rewardUEC"),
"buy_in": mission.get("buyIn"),
"dynamic_uec": mission.get("rewardIsDynamic"),
"reputation": cls._scmdb_indexed_reputation(data, mission.get("factionRewardsIndex")),
"failure_reputation": cls._scmdb_reputation_rewards(data, mission.get("factionRewards_fail")),
"items": cls._scmdb_item_rewards(mission.get("itemRewards")),
"blueprints": cls._scmdb_blueprint_rewards(data, mission.get("blueprintRewards"), detailed=detailed),
"hauling": cls._scmdb_hauling_orders(data, mission.get("haulingOrders")),
"partial_payouts": cls._scmdb_partial_payouts(data, mission.get("partialRewardPayoutIndex")),
}
return {key: value for key, value in rewards.items() if value not in (None, "", [], {})}
@classmethod
def _scmdb_indexed_reputation(cls, data: dict[str, Any], index: Any) -> list[dict[str, Any]]:
if not isinstance(index, int):
return []
pools = data.get("factionRewardsPools") or []
if index < 0 or index >= len(pools):
return []
return cls._scmdb_reputation_rewards(data, pools[index])
@classmethod
def _scmdb_reputation_rewards(cls, data: dict[str, Any], rewards: Any) -> list[dict[str, Any]]:
if not isinstance(rewards, list):
return []
result = []
for reward in rewards:
if not isinstance(reward, dict):
continue
result.append(
{
"faction": cls._scmdb_faction_name(data, reward.get("factionGuid")),
"scope": cls._scmdb_scope_name(data, reward.get("scopeGuid")),
"amount": reward.get("amount"),
}
)
return [item for item in result if item.get("amount") not in (None, "")]
@classmethod
def _scmdb_item_rewards(cls, rewards: Any) -> list[dict[str, Any]]:
if not isinstance(rewards, list):
return []
return [
{
"name": reward.get("name"),
"amount": reward.get("amount"),
}
for reward in rewards
if isinstance(reward, dict)
]
@classmethod
def _scmdb_blueprint_rewards(cls, data: dict[str, Any], rewards: Any, detailed: bool = False) -> list[dict[str, Any]]:
if not isinstance(rewards, list):
return []
result = []
for reward in rewards:
if not isinstance(reward, dict):
continue
pool_id = reward.get("blueprintPool")
pool = (data.get("blueprintPools") or {}).get(pool_id) or {}
blueprints = pool.get("blueprints") or []
result.append(
{
"pool": pool.get("name") or reward.get("poolName"),
"chance": reward.get("chance"),
"trigger": reward.get("trigger"),
"blueprints": [
cls._compact_scalar(item.get("name"))
for item in blueprints[: 20 if detailed else 5]
if isinstance(item, dict) and item.get("name")
],
}
)
return result
@classmethod
def _scmdb_hauling_orders(cls, data: dict[str, Any], orders: Any) -> list[dict[str, Any]]:
if not isinstance(orders, list):
return []
result = []
resources = data.get("resourcePools") or {}
for order in orders:
if not isinstance(order, dict):
continue
resource = resources.get(order.get("resource")) or {}
result.append(
{
"resource": resource.get("name") or order.get("resource"),
"min_scu": order.get("minSCU"),
"max_scu": order.get("maxSCU"),
"max_container_size_scu": order.get("maxContainerSize"),
}
)
return result
@classmethod
def _scmdb_partial_payouts(cls, data: dict[str, Any], index: Any) -> list[dict[str, Any]]:
if not isinstance(index, int):
return []
pools = data.get("partialRewardPayoutPools") or []
if index < 0 or index >= len(pools):
return []
payouts = pools[index]
if not isinstance(payouts, list):
return []
return [
{
"min_percent": payout.get("minPercentage"),
"max_percent": payout.get("maxPercentage"),
"currency_multiplier": payout.get("currencyRewardMultiplier"),
"reputation_multipliers": payout.get("reputationMultipliers"),
}
for payout in payouts
if isinstance(payout, dict)
]
@staticmethod
def _scmdb_faction_name(data: dict[str, Any], guid: Any) -> str | None:
if not guid:
return None
faction = (data.get("factions") or {}).get(guid)
if isinstance(faction, dict):
return faction.get("name") or guid
return str(guid)
@staticmethod
def _scmdb_scope_name(data: dict[str, Any], guid: Any) -> str | None:
if not guid:
return None
scope = (data.get("scopes") or {}).get(guid)
if isinstance(scope, dict):
return scope.get("scopeName") or guid
return str(guid)
@staticmethod
def _scmdb_standing(value: Any) -> dict[str, Any] | None:
if not isinstance(value, dict):
return None
return {
key: value.get(key)
for key in ("name", "minReputation", "scopeName")
if value.get(key) not in (None, "")
}
@staticmethod
def _scmdb_pool_names(data: dict[str, Any], pool_key: str, keys: Any, limit: int = 10) -> list[str]:
if not isinstance(keys, list):
return []
pool = data.get(pool_key) or {}
names = []
for key in keys[:limit]:
item = pool.get(key)
if isinstance(item, dict):
names.append(str(item.get("name") or key))
else:
names.append(str(key))
return names
@classmethod
def _scmdb_search_text(cls, data: dict[str, Any], mission: dict[str, Any], summary: dict[str, Any]) -> str:
pieces = [
summary.get("title"),
summary.get("debug_name"),
summary.get("mission_type"),
summary.get("category"),
summary.get("faction"),
mission.get("description"),
" ".join(summary.get("systems") or []),
]
rewards = summary.get("rewards") or {}
pieces.append(str(rewards.get("uec") or ""))
for item in rewards.get("reputation") or []:
pieces.extend([item.get("faction"), item.get("scope"), item.get("amount")])
for item in rewards.get("items") or []:
pieces.extend([item.get("name"), item.get("amount")])
for item in rewards.get("blueprints") or []:
pieces.extend([item.get("pool"), " ".join(item.get("blueprints") or [])])
for item in rewards.get("hauling") or []:
pieces.extend([item.get("resource"), item.get("min_scu"), item.get("max_scu")])
return " ".join(str(piece) for piece in pieces if piece not in (None, "")).casefold()
async def _resolve_cornerstone_item(self, id: str | None = None, query: str | None = None) -> dict[str, Any] | None:
items = await self.cornerstone.list_items()
id_filter = (id or "").casefold().strip()
if id_filter:
for item in items:
if str(item.get("id") or "").casefold() == id_filter:
return item
return {"id": id, "name": id, "sold": True}
q = (query or "").casefold().strip()
if not q:
return None
exact = [item for item in items if str(item.get("name") or "").casefold() == q]
if exact:
exact.sort(key=lambda item: not bool(item.get("sold")))
return exact[0]
scored = [
(self._cornerstone_match_score(q, str(item.get("name") or "")), item)
for item in items
]
scored = [match for match in scored if match[0] > 0]
if not scored:
return None
scored.sort(key=lambda match: (-match[0], not bool(match[1].get("sold")), str(match[1].get("name") or "").casefold()))
return scored[0][1]
@staticmethod
def _cornerstone_match_score(query: str, name: str) -> int:
if not query:
return 1
normalized = name.casefold()
if normalized == query:
return 10000
if normalized.startswith(query):
return 9000 - len(normalized)
if query in normalized:
return 8000 - normalized.index(query)
tokens = [token for token in query.split() if token]
if tokens and all(token in normalized for token in tokens):
return 7000 - len(normalized)
return 0
@staticmethod
def _summarize_listing(listing: dict[str, Any]) -> dict[str, Any]:
return {
@@ -1167,3 +1772,57 @@ class ToolRegistry:
"advertiser": listing.get("user_username"),
"expires_at": listing.get("date_expiration"),
}
@classmethod
def _summarize_negotiation(cls, negotiation: dict[str, Any]) -> dict[str, Any]:
summary = cls._project_item(negotiation, mode="summary")
state = cls._negotiation_state(negotiation)
summary.update(
{
"state": state["state"],
"is_open": state["is_open"],
"state_reason": state["reason"],
}
)
for key in ("hash", "id_listing", "id_user", "id_user_seller", "id_user_buyer", "date_closed"):
if key in negotiation and key not in summary:
summary[key] = negotiation.get(key)
return summary
@staticmethod
def _negotiation_state(negotiation: dict[str, Any]) -> dict[str, Any]:
closed_flags = [
"is_closed",
"closed",
"is_cancelled",
"is_canceled",
"is_archived",
"marked_closed",
]
for key in closed_flags:
value = negotiation.get(key)
if value in (True, 1, "1", "true", "True", "yes", "closed"):
return {"state": "closed", "is_open": False, "reason": f"{key} is set"}
closed_dates = [
"date_closed",
"date_completed",
"date_cancelled",
"date_canceled",
"closed_at",
"completed_at",
"cancelled_at",
"canceled_at",
]
for key in closed_dates:
value = negotiation.get(key)
if value not in (None, "", 0, "0", False):
return {"state": "closed", "is_open": False, "reason": f"{key} is populated"}
status = str(negotiation.get("status") or negotiation.get("state") or "").casefold()
if status in {"closed", "cancelled", "canceled", "completed", "declined", "accepted", "rejected"}:
return {"state": "closed", "is_open": False, "reason": f"status is {status}"}
if status in {"open", "active", "pending", "new"}:
return {"state": "open", "is_open": True, "reason": f"status is {status}"}
return {"state": "open", "is_open": True, "reason": "no closed flag, closed date, or closed status was present"}
+2 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
__version__ = "0.0.2"
__version__ = "0.0.3"
RELEASES_URL = "https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases"
RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingConglomerate/TraderAI/releases"
@@ -8,3 +8,4 @@ RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingCo
Generated
+2 -1
View File
@@ -755,7 +755,7 @@ wheels = [
[[package]]
name = "traderai"
version = "0.0.2"
version = "0.0.3"
source = { virtual = "." }
dependencies = [
{ name = "apscheduler" },
@@ -1048,3 +1048,4 @@ wheels = [
+386 -18
View File
@@ -29,9 +29,31 @@ const updateCheckButton = document.getElementById("update-check");
const updateInstallButton = document.getElementById("update-install");
const updateOpenReleasesButton = document.getElementById("update-open-releases");
const updateStatusEl = document.getElementById("update-status");
const shellEl = document.querySelector(".shell");
const chatRailEl = document.getElementById("chat-rail");
const chatSidebarToggle = document.getElementById("chat-sidebar-toggle");
const newChatButton = document.getElementById("new-chat");
const chatListEl = document.getElementById("chat-list");
const inboxListEl = document.getElementById("inbox-list");
const negotiationPanel = document.getElementById("negotiation-panel");
const negotiationTitle = document.getElementById("negotiation-title");
const negotiationMessagesEl = document.getElementById("negotiation-messages");
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 updateModal = document.getElementById("update-modal");
const updateModalCopy = document.getElementById("update-modal-copy");
const updateModalClose = document.getElementById("update-modal-close");
const updateModalInstall = document.getElementById("update-modal-install");
const updateModalReleases = document.getElementById("update-modal-releases");
let ollamaOnline = true;
let latestUpdate = null;
let currentThreadId = "default";
let currentNegotiationId = null;
let latestOllamaStatus = null;
const clickedOllamaActions = new Set();
if (window.lucide) {
window.lucide.createIcons();
@@ -49,6 +71,7 @@ function addMessage(role, text) {
function setMessageMarkdown(node, text) {
const body = node.querySelector(".message-body") || node;
body.innerHTML = renderMarkdown(text);
enhanceNegotiationLinks(body);
}
function setMessageActivity(node, text, active = false) {
@@ -342,6 +365,43 @@ function inlineMarkdown(text) {
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
}
function enhanceNegotiationLinks(root) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
if (node.parentElement?.closest("a, button, code, pre")) return NodeFilter.FILTER_REJECT;
return /(?:negotiation|id_negotiation|marketplace\/(?:negotiations|negotiate\/hash))/i.test(node.textContent || "")
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
},
});
const textNodes = [];
while (walker.nextNode()) textNodes.push(walker.currentNode);
const pattern = /((?:negotiation|id_negotiation)\s*(?:#|id|:)?\s*)([A-Za-z0-9_-]{3,})|(\/marketplace\/(?:negotiations|negotiate\/hash)\/)([A-Za-z0-9_-]+)/gi;
for (const textNode of textNodes) {
const text = textNode.textContent || "";
let lastIndex = 0;
const fragment = document.createDocumentFragment();
for (const match of text.matchAll(pattern)) {
const matchIndex = match.index || 0;
const identifier = match[2] || match[4];
if (!identifier) continue;
fragment.appendChild(document.createTextNode(text.slice(lastIndex, matchIndex)));
if (match[1]) fragment.appendChild(document.createTextNode(match[1]));
if (match[3]) fragment.appendChild(document.createTextNode(match[3]));
const button = document.createElement("button");
button.type = "button";
button.className = "negotiation-link";
button.dataset.negotiationId = identifier;
button.textContent = identifier;
button.addEventListener("click", () => openNegotiationPanel(identifier));
fragment.appendChild(button);
lastIndex = matchIndex + match[0].length;
}
fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
textNode.replaceWith(fragment);
}
}
function isTableRow(line) {
const trimmed = line.trim();
return trimmed.includes("|") && /^\|?.+\|.+\|?$/.test(trimmed);
@@ -527,6 +587,7 @@ async function refreshOllamaStatus() {
function renderOllamaStatus(status) {
if (!ollamaStatusEl) return;
latestOllamaStatus = status;
const models = status.models?.length ? status.models.join(", ") : "None detected";
const pillClass = status.installed && status.running && status.model_available ? "status-pill" : "status-pill warning";
ollamaStatusEl.innerHTML = `
@@ -537,14 +598,18 @@ function renderOllamaStatus(status) {
${ollamaStatusItem("Model", status.configured_model || "")}
${ollamaStatusItem("Pulled", status.model_available ? "Yes" : "No")}
${ollamaStatusItem("URL", status.base_url || "")}
${ollamaStatusItem("Auto Install", status.can_auto_install ? "Available" : "Unavailable")}
${status.can_auto_install ? ollamaStatusItem("Auto Install", "Available") : ""}
</div>
${ollamaStatusItem("Installed Models", models)}
${status.detail ? ollamaStatusItem("Detail", status.detail) : ""}
`;
if (ollamaInstallButton) ollamaInstallButton.disabled = Boolean(status.installed);
if (ollamaInstallButton) {
ollamaInstallButton.hidden = !status.can_auto_install;
ollamaInstallButton.disabled = Boolean(status.installed) || !status.can_auto_install;
}
if (ollamaLaunchButton) ollamaLaunchButton.disabled = !status.installed || Boolean(status.running);
if (ollamaPullButton) ollamaPullButton.disabled = !status.running || Boolean(status.model_available);
updateOllamaAttention(status);
}
function ollamaStatusItem(label, value) {
@@ -572,11 +637,34 @@ async function postOllamaAction(endpoint, options = {}) {
}
}
function markOllamaActionClicked(action) {
if (action) clickedOllamaActions.add(action);
updateOllamaAttention();
}
function setOllamaButtonAttention(button, action, active) {
if (!button) return;
const shouldPulse = active && !clickedOllamaActions.has(action) && !button.disabled && !button.hidden;
button.classList.toggle("attention-pulse", shouldPulse);
}
function updateOllamaAttention(status = null) {
const currentStatus = status || latestOllamaStatus;
if (!currentStatus) return;
const ready = Boolean(currentStatus.installed && currentStatus.running && currentStatus.model_available);
ollamaToggle?.classList.toggle("attention-pulse", !ready);
setOllamaButtonAttention(ollamaDownloadButton, "download", !currentStatus.installed);
setOllamaButtonAttention(ollamaInstallButton, "install", !currentStatus.installed && currentStatus.can_auto_install);
setOllamaButtonAttention(ollamaLaunchButton, "launch", currentStatus.installed && !currentStatus.running);
setOllamaButtonAttention(ollamaPullButton, "pull", currentStatus.running && !currentStatus.model_available);
if (ready) clickedOllamaActions.clear();
}
function configuredOllamaModel() {
return document.getElementById("ollama-model")?.value || "";
}
async function checkForUpdate() {
async function checkForUpdate(promptUser = false) {
if (!updateStatusEl) return;
updateStatusEl.textContent = "Checking releases";
try {
@@ -584,6 +672,7 @@ async function checkForUpdate() {
const result = await response.json();
latestUpdate = result;
renderUpdateStatus(result);
if (promptUser) maybeShowUpdatePrompt(result);
} catch (error) {
updateStatusEl.textContent = `Update check failed: ${error.message}`;
if (updateInstallButton) updateInstallButton.disabled = true;
@@ -627,6 +716,22 @@ function openReleasesPage() {
window.open(url, "_blank", "noreferrer");
}
function maybeShowUpdatePrompt(update) {
if (!update?.available || !updateModal) return;
const dismissedVersion = localStorage.getItem("traderai.dismissedUpdateVersion");
if (dismissedVersion === update.latest_version) return;
updateModalCopy.textContent = update.message || `TraderAI ${update.latest_version} is available.`;
updateModalInstall.disabled = !update.asset_download_url || !update.packaged;
updateModal.hidden = false;
}
function closeUpdatePrompt() {
if (latestUpdate?.latest_version) {
localStorage.setItem("traderai.dismissedUpdateVersion", latestUpdate.latest_version);
}
updateModal.hidden = true;
}
function toggleSidebarPanel(panelName) {
const panels = {
settings: { panel: settingsPanel, button: settingsToggle },
@@ -658,6 +763,245 @@ function toggleSidebarPanel(panelName) {
}
}
function toggleChatRail() {
const isCollapsed = chatRailEl?.classList.toggle("collapsed");
shellEl?.classList.toggle("chat-open", !isCollapsed);
chatSidebarToggle?.setAttribute("aria-expanded", String(!isCollapsed));
}
async function refreshChats() {
if (!chatListEl) return;
try {
const response = await fetch("/api/chats");
const result = await response.json();
const chats = result.chats || [];
if (!chats.length) {
const created = await createChat(false);
currentThreadId = created?.id || "default";
return;
}
if (!chats.some((chat) => chat.id === currentThreadId)) {
currentThreadId = chats[0].id;
}
renderChats(chats);
} catch (error) {
chatListEl.textContent = `Chats failed: ${fetchErrorMessage(error)}`;
}
}
function renderChats(chats) {
chatListEl.innerHTML = "";
for (const chat of chats) {
const item = document.createElement("div");
item.className = `chat-item${chat.id === currentThreadId ? " active" : ""}`;
const title = document.createElement("button");
title.type = "button";
title.className = "chat-title";
title.textContent = chat.title || "New chat";
title.addEventListener("click", () => switchChat(chat.id));
const rename = document.createElement("button");
rename.type = "button";
rename.className = "icon-button";
rename.title = "Rename chat";
rename.innerHTML = '<i data-lucide="pen" aria-hidden="true"></i>';
rename.addEventListener("click", (event) => {
event.stopPropagation();
renameChat(chat.id, chat.title || "New chat");
});
const remove = document.createElement("button");
remove.type = "button";
remove.className = "icon-button";
remove.title = "Delete chat";
remove.innerHTML = '<i data-lucide="trash-2" aria-hidden="true"></i>';
remove.addEventListener("click", (event) => {
event.stopPropagation();
deleteChat(chat.id);
});
item.append(title, rename, remove);
chatListEl.appendChild(item);
}
if (window.lucide) window.lucide.createIcons();
}
async function createChat(shouldSwitch = true) {
const response = await fetch("/api/chats", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "New chat" }),
});
const result = await response.json();
const chat = result.chat;
if (shouldSwitch && chat?.id) {
currentThreadId = chat.id;
await loadChatMessages(chat.id);
}
await refreshChats();
return chat;
}
async function switchChat(threadId) {
if (!threadId || threadId === currentThreadId) return;
currentThreadId = threadId;
await loadChatMessages(threadId);
await refreshChats();
}
async function deleteChat(threadId) {
await fetch(`/api/chats/${encodeURIComponent(threadId)}`, { method: "DELETE" });
if (threadId === currentThreadId) currentThreadId = "";
await refreshChats();
if (currentThreadId) await loadChatMessages(currentThreadId);
}
async function renameChat(threadId, currentTitle) {
const title = window.prompt("Rename chat", currentTitle || "New chat");
if (title === null) return;
const cleanTitle = title.trim();
if (!cleanTitle) return;
await fetch(`/api/chats/${encodeURIComponent(threadId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: cleanTitle }),
});
await refreshChats();
}
async function loadChatMessages(threadId) {
messages.innerHTML = "";
try {
const response = await fetch(`/api/chats/${encodeURIComponent(threadId)}/messages`);
const result = await response.json();
const rows = result.messages || [];
if (!rows.length) {
addMessage("assistant", "Tell me what to find or draft on UEX. I will ask for approval before sending anything.");
return;
}
for (const row of rows) {
if (row.role === "user" || row.role === "assistant") addMessage(row.role, row.content || "");
}
} catch (error) {
addMessage("assistant warning-message", `Could not load chat: ${fetchErrorMessage(error)}`);
}
}
async function refreshInbox() {
if (!inboxListEl) return;
try {
const response = await fetch("/api/inbox");
const result = await response.json();
renderInbox(result.inbox || []);
} catch (error) {
inboxListEl.textContent = `Inbox failed: ${fetchErrorMessage(error)}`;
}
}
function renderInbox(items) {
inboxListEl.innerHTML = "";
if (!items.length) {
inboxListEl.innerHTML = '<div class="pending-empty">No inbox messages</div>';
return;
}
for (const item of items) {
const row = document.createElement("div");
row.className = "inbox-item";
const title = document.createElement("div");
title.className = "inbox-title";
title.textContent = item.content || "";
enhanceNegotiationLinks(title);
const open = document.createElement("button");
open.type = "button";
open.className = "icon-button";
open.title = "Continue in new chat";
open.innerHTML = '<i data-lucide="message-square-plus" aria-hidden="true"></i>';
open.addEventListener("click", () => continueInbox(item.id));
const remove = document.createElement("button");
remove.type = "button";
remove.className = "icon-button";
remove.title = "Delete inbox alert";
remove.innerHTML = '<i data-lucide="trash-2" aria-hidden="true"></i>';
remove.addEventListener("click", () => deleteInboxItem(item.id));
row.append(title, open, remove);
inboxListEl.appendChild(row);
}
if (window.lucide) window.lucide.createIcons();
}
async function continueInbox(id) {
const response = await fetch(`/api/inbox/${id}/continue`, { method: "POST" });
const result = await response.json();
if (result.chat?.id) {
currentThreadId = result.chat.id;
await loadChatMessages(currentThreadId);
await refreshChats();
}
}
async function deleteInboxItem(id) {
await fetch(`/api/inbox/${id}`, { method: "DELETE" });
await refreshInbox();
}
async function openNegotiationPanel(identifier) {
currentNegotiationId = identifier;
negotiationPanel.hidden = false;
negotiationTitle.textContent = `Negotiation ${identifier}`;
negotiationStatusEl.textContent = "";
negotiationMessagesEl.textContent = "Loading";
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(identifier)}/messages`);
const result = await response.json();
renderNegotiationMessages(result.data || result.messages || result.notifications || []);
} catch (error) {
negotiationMessagesEl.textContent = `Could not load negotiation: ${fetchErrorMessage(error)}`;
}
}
function closeNegotiationPanel() {
negotiationPanel.hidden = true;
currentNegotiationId = null;
negotiationInput.value = "";
negotiationStatusEl.textContent = "";
}
function renderNegotiationMessages(data) {
negotiationMessagesEl.innerHTML = "";
const items = Array.isArray(data) ? data : [data].filter(Boolean);
if (!items.length) {
negotiationMessagesEl.textContent = "No messages returned for this negotiation.";
return;
}
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))}`;
negotiationMessagesEl.appendChild(card);
}
negotiationMessagesEl.scrollTop = negotiationMessagesEl.scrollHeight;
}
async function submitNegotiationMessage(event) {
event.preventDefault();
const text = negotiationInput.value.trim();
if (!text || !currentNegotiationId) return;
negotiationStatusEl.textContent = "Sending";
try {
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/messages`, {
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}`);
negotiationInput.value = "";
negotiationStatusEl.textContent = result.message || "Sent";
await openNegotiationPanel(currentNegotiationId);
} catch (error) {
negotiationStatusEl.textContent = `Send failed: ${fetchErrorMessage(error)}`;
}
}
async function checkHealth() {
try {
const response = await fetch("/api/health");
@@ -666,20 +1010,24 @@ async function checkHealth() {
ollamaOnline = Boolean(health.online);
if (!ollamaOnline) {
statusEl.textContent = "Offline";
setWarning(health.message || "Ollama is offline. Start Ollama before chatting.");
setWarning("Ollama needs attention. Open the Ollama tab and use the pulsing action button.");
ollamaToggle?.classList.add("attention-pulse");
return false;
}
if (health.model_available === false) {
setWarning(`Ollama is online, but model "${health.model}" is not pulled. Run: ollama pull ${health.model}`);
setWarning(`Ollama needs the configured model "${health.model}". Open the Ollama tab and use Install Model.`);
ollamaToggle?.classList.add("attention-pulse");
} else {
setWarning("");
ollamaToggle?.classList.remove("attention-pulse");
}
statusEl.textContent = "Ready";
return true;
} catch (error) {
ollamaOnline = false;
statusEl.textContent = "Offline";
setWarning(`Could not check Ollama health: ${error.message}`);
setWarning("Could not check Ollama health. Open the Ollama tab and use the pulsing action button.");
ollamaToggle?.classList.add("attention-pulse");
return false;
}
}
@@ -833,9 +1181,7 @@ async function pollNotifications() {
try {
const response = await fetch("/api/notifications");
const result = await response.json();
for (const notification of result.notifications || []) {
addMessage("assistant", notification.content);
}
if ((result.notifications || []).length) await refreshInbox();
} catch {
// Notification polling should never interrupt chat.
}
@@ -862,20 +1208,39 @@ memoryToggle?.addEventListener("click", () => toggleSidebarPanel("memory"));
ollamaToggle?.addEventListener("click", () => toggleSidebarPanel("ollama"));
ollamaForm?.addEventListener("submit", saveOllamaConfig);
ollamaRefreshButton?.addEventListener("click", refreshOllamaStatus);
ollamaDownloadButton?.addEventListener("click", () => postOllamaAction("/api/ollama/download"));
ollamaInstallButton?.addEventListener("click", () => postOllamaAction("/api/ollama/install"));
ollamaLaunchButton?.addEventListener("click", () => postOllamaAction("/api/ollama/launch"));
ollamaPullButton?.addEventListener("click", () => postOllamaAction("/api/ollama/pull", { body: { model: configuredOllamaModel() } }));
ollamaDownloadButton?.addEventListener("click", () => {
markOllamaActionClicked("download");
postOllamaAction("/api/ollama/download");
});
ollamaInstallButton?.addEventListener("click", () => {
markOllamaActionClicked("install");
postOllamaAction("/api/ollama/install");
});
ollamaLaunchButton?.addEventListener("click", () => {
markOllamaActionClicked("launch");
postOllamaAction("/api/ollama/launch");
});
ollamaPullButton?.addEventListener("click", () => {
markOllamaActionClicked("pull");
postOllamaAction("/api/ollama/pull", { body: { model: configuredOllamaModel() } });
});
updateCheckButton?.addEventListener("click", checkForUpdate);
updateInstallButton?.addEventListener("click", installUpdate);
updateOpenReleasesButton?.addEventListener("click", openReleasesPage);
chatSidebarToggle?.addEventListener("click", toggleChatRail);
newChatButton?.addEventListener("click", () => createChat(true));
negotiationCloseButton?.addEventListener("click", closeNegotiationPanel);
negotiationForm?.addEventListener("submit", submitNegotiationMessage);
updateModalClose?.addEventListener("click", closeUpdatePrompt);
updateModalReleases?.addEventListener("click", openReleasesPage);
updateModalInstall?.addEventListener("click", installUpdate);
async function sendMessage() {
const message = input.value.trim();
if (!message || input.disabled) return;
const healthy = await checkHealth();
if (!healthy) {
addMessage("assistant warning-message", "Ollama is offline. Start Ollama, then try again.");
addMessage("assistant warning-message", "Ollama needs attention before chat can continue. Open the Ollama tab and press the pulsing action button, then try again.");
return;
}
input.value = "";
@@ -892,7 +1257,7 @@ async function sendMessage() {
const response = await fetch("/api/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
body: JSON.stringify({ message, thread_id: currentThreadId }),
});
if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status}`);
@@ -926,6 +1291,7 @@ async function sendMessage() {
}
messages.scrollTop = messages.scrollHeight;
} else if (event.type === "done") {
if (event.thread_id) currentThreadId = event.thread_id;
const visibleContent = thinkParser.flush();
if (visibleContent) {
assistantText += visibleContent;
@@ -937,7 +1303,7 @@ async function sendMessage() {
}
} catch (error) {
const message = error.message.includes("503")
? "Ollama is offline or unreachable. Start Ollama, then try again."
? "Ollama needs attention before chat can continue. Open the Ollama tab and press the pulsing action button, then try again."
: `Chat failed: ${error.message}`;
setWarning(message);
setMessageMarkdown(assistantNode, message);
@@ -948,15 +1314,17 @@ async function sendMessage() {
statusEl.textContent = "Ready";
finishThinking(assistantNode);
setMessageActivity(assistantNode, "");
await refreshChats();
}
}
addMessage("assistant", "Tell me what to find or draft on UEX. I will ask for approval before sending anything.");
refreshPending();
refreshMemory();
refreshConfig();
refreshOllamaStatus();
checkForUpdate();
refreshChats().then(() => loadChatMessages(currentThreadId));
refreshInbox();
checkForUpdate(true);
pollNotifications();
checkHealth();
setInterval(checkHealth, 30000);
+54
View File
@@ -9,6 +9,28 @@
</head>
<body>
<main class="shell">
<nav class="chat-rail collapsed" id="chat-rail" aria-label="Chats and inbox">
<div class="chat-rail-top">
<button class="icon-button" id="chat-sidebar-toggle" type="button" title="Chats" aria-expanded="false">
<i data-lucide="panel-left" aria-hidden="true"></i>
<span>Menu</span>
</button>
<button class="icon-button" id="new-chat" type="button" title="New chat">
<i data-lucide="square-pen" aria-hidden="true"></i>
<span>New chat</span>
</button>
</div>
<div class="chat-rail-content">
<section class="chat-nav-section">
<div class="rail-heading">Chats</div>
<div class="chat-list" id="chat-list"></div>
</section>
<section class="chat-nav-section">
<div class="rail-heading">Inbox</div>
<div class="inbox-list" id="inbox-list"></div>
</section>
</div>
</nav>
<section class="workspace">
<header class="topbar">
<div class="brand-block">
@@ -120,6 +142,38 @@
</section>
</aside>
</main>
<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>
</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>
<div class="modal-backdrop" id="update-modal" hidden>
<section class="update-modal-card">
<div class="section-title-row">
<h2>Update Available</h2>
<button class="icon-button light" id="update-modal-close" type="button" title="Close">
<i data-lucide="x" aria-hidden="true"></i>
</button>
</div>
<p id="update-modal-copy"></p>
<div class="update-actions">
<button class="secondary small-button" id="update-modal-releases" type="button">Releases</button>
<button class="small-button" id="update-modal-install" type="button">Update</button>
</div>
</section>
</div>
<script src="https://unpkg.com/lucide@0.562.0/dist/umd/lucide.min.js"></script>
<script src="/static/app.js"></script>
</body>
+336 -8
View File
@@ -22,6 +22,10 @@
box-sizing: border-box;
}
[hidden] {
display: none !important;
}
html,
body {
height: 100%;
@@ -50,7 +54,7 @@ body::before {
.shell {
display: grid;
grid-template-columns: minmax(0, 1fr) 380px;
grid-template-columns: 64px minmax(0, 1fr) 380px;
gap: 24px;
height: 100vh;
min-height: 0;
@@ -58,8 +62,13 @@ body::before {
padding: 24px;
}
.shell.chat-open {
grid-template-columns: 280px minmax(0, 1fr) 380px;
}
.workspace,
.actions {
.actions,
.chat-rail {
position: relative;
overflow: hidden;
border: 1px solid rgba(240, 214, 129, 0.34);
@@ -69,12 +78,126 @@ body::before {
}
.workspace {
display: grid;
grid-template-rows: auto auto 1fr auto;
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
min-height: 0;
}
.chat-rail {
display: grid;
grid-template-rows: auto 1fr;
min-width: 0;
min-height: 0;
padding: 12px;
background: linear-gradient(180deg, #fffaf0 0%, #f7f1dc 100%);
}
.chat-rail-top {
display: grid;
gap: 10px;
}
.shell.chat-open .chat-rail-top {
grid-template-columns: 1fr 1fr;
}
.chat-rail-content {
display: grid;
grid-template-rows: minmax(0, 1fr) minmax(140px, 34%);
gap: 16px;
min-height: 0;
padding-top: 16px;
opacity: 1;
transition: opacity 160ms ease;
}
.chat-rail.collapsed .chat-rail-content {
pointer-events: none;
opacity: 0;
}
.chat-nav-section {
min-height: 0;
overflow: hidden;
}
.rail-heading {
margin-bottom: 8px;
color: var(--forest);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.chat-list,
.inbox-list {
display: grid;
gap: 8px;
max-height: calc(100% - 26px);
overflow: auto;
}
.chat-item,
.inbox-item {
display: grid;
align-items: center;
gap: 6px;
min-height: 38px;
padding: 7px 8px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(255, 250, 240, 0.78);
}
.chat-item {
grid-template-columns: minmax(0, 1fr) auto auto;
}
.inbox-item {
grid-template-columns: minmax(0, 1fr) auto auto;
}
.chat-item.active {
border-color: rgba(52, 83, 38, 0.42);
background: #edf3df;
}
.chat-title,
.inbox-title {
min-width: 0;
overflow: hidden;
color: var(--brown);
font-size: 13px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-title {
min-width: 0;
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
font-family: Inter, "Segoe UI", Arial, sans-serif;
text-align: left;
}
.chat-title:hover {
background: transparent;
box-shadow: none;
transform: none;
}
.inbox-title {
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.actions {
padding: 28px;
overflow: auto;
@@ -203,6 +326,7 @@ h2 {
}
.messages {
flex: 1 1 auto;
overflow: auto;
min-height: 0;
padding: 28px 28px 18px;
@@ -212,13 +336,15 @@ h2 {
}
.message {
max-width: 920px;
width: fit-content;
max-width: min(920px, 88%);
margin-bottom: 16px;
padding: 17px 18px;
border: 1px solid rgba(221, 206, 176, 0.9);
border-radius: 18px;
color: var(--brown);
line-height: 1.55;
overflow-wrap: anywhere;
box-shadow: 0 16px 38px rgba(38, 58, 27, 0.11);
}
@@ -346,6 +472,7 @@ h2 {
.message.user {
margin-left: auto;
max-width: min(720px, 76%);
border-color: rgba(52, 83, 38, 0.28);
background: linear-gradient(180deg, #edf3df, #e5efd4);
}
@@ -360,7 +487,9 @@ h2 {
}
.composer-wrap {
position: sticky;
position: relative;
flex: 0 0 auto;
margin-top: auto;
bottom: 0;
z-index: 5;
border-top: 1px solid var(--line);
@@ -582,6 +711,162 @@ button {
transition: transform 180ms ease, box-shadow 180ms ease, background 180ms ease;
}
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 40px;
min-width: 40px;
height: 40px;
padding: 0;
border: 1px solid var(--line-strong);
border-radius: 12px;
background: #fff9e9;
color: var(--forest);
box-shadow: 0 10px 22px rgba(38, 58, 27, 0.08);
}
.shell.chat-open .chat-rail-top .icon-button {
width: 100%;
min-width: 0;
padding: 0 10px;
}
.icon-button span {
display: none;
overflow: hidden;
font-family: Inter, "Segoe UI", Arial, sans-serif;
font-size: 12px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.shell.chat-open .chat-rail-top .icon-button span {
display: inline;
}
.icon-button svg {
width: 18px;
height: 18px;
flex: 0 0 18px;
}
.icon-button.light {
background: rgba(255, 250, 240, 0.86);
}
.chat-item .icon-button,
.inbox-item .icon-button {
width: 30px;
min-width: 30px;
height: 30px;
border-radius: 8px;
}
.negotiation-link {
display: inline;
min-width: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--forest-2);
font-family: inherit;
font-weight: 800;
text-decoration: underline;
box-shadow: none;
}
.negotiation-link:hover {
background: transparent;
box-shadow: none;
}
.floating-panel {
position: fixed;
right: 28px;
bottom: 28px;
z-index: 20;
display: grid;
grid-template-rows: auto minmax(180px, 360px) auto auto;
width: min(520px, calc(100vw - 28px));
max-height: min(760px, calc(100vh - 56px));
border: 1px solid rgba(240, 214, 129, 0.42);
border-radius: 18px;
background: linear-gradient(180deg, var(--ivory) 0%, var(--cream) 100%);
box-shadow: var(--shadow);
}
.floating-panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 18px;
border-bottom: 1px solid var(--line);
background: linear-gradient(90deg, rgba(20, 33, 15, 0.96), rgba(38, 58, 27, 0.94));
}
.floating-panel-header h2 {
margin: 3px 0 0;
color: var(--ivory);
}
.negotiation-messages {
overflow: auto;
padding: 16px;
}
.negotiation-message {
margin-bottom: 10px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(255, 250, 240, 0.86);
font-size: 13px;
line-height: 1.42;
overflow-wrap: anywhere;
}
.negotiation-composer {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
padding: 12px;
border-top: 1px solid var(--line);
}
.negotiation-composer textarea {
min-height: 48px;
border-radius: 12px;
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 30;
display: grid;
place-items: center;
padding: 20px;
background: rgba(20, 33, 15, 0.42);
}
.update-modal-card {
width: min(440px, 100%);
padding: 22px;
border: 1px solid rgba(240, 214, 129, 0.42);
border-radius: 18px;
background: var(--ivory);
box-shadow: var(--shadow);
}
.update-modal-card p {
margin: 6px 0 18px;
color: var(--muted);
line-height: 1.45;
}
button:hover {
background: linear-gradient(180deg, #3d612c, #263e1b);
box-shadow: 0 18px 34px rgba(31, 52, 22, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.16);
@@ -793,6 +1078,31 @@ button.secondary {
width: 100%;
}
.attention-pulse {
position: relative;
border-color: rgba(47, 125, 50, 0.72) !important;
box-shadow: 0 0 0 0 rgba(55, 148, 61, 0.54);
animation: green-attention-pulse 1.35s ease-out infinite;
}
.attention-pulse:disabled,
.attention-pulse[hidden] {
animation: none;
box-shadow: none;
}
@keyframes green-attention-pulse {
0% {
box-shadow: 0 0 0 0 rgba(55, 148, 61, 0.58);
}
70% {
box-shadow: 0 0 0 10px rgba(55, 148, 61, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(55, 148, 61, 0);
}
}
.update-box {
display: grid;
gap: 10px;
@@ -950,7 +1260,19 @@ pre {
@media (max-width: 960px) {
.shell {
grid-template-columns: 1fr;
grid-template-rows: minmax(0, 1fr) minmax(220px, 34vh);
grid-template-rows: auto minmax(0, 1fr) minmax(220px, 34vh);
}
.shell.chat-open {
grid-template-columns: 1fr;
}
.chat-rail {
min-height: 64px;
}
.chat-rail.collapsed {
max-height: 64px;
}
}
@@ -997,7 +1319,8 @@ pre {
}
.messages,
.actions {
.actions,
.chat-rail {
padding: 22px;
}
@@ -1015,6 +1338,11 @@ pre {
display: none;
}
.message,
.message.user {
max-width: 100%;
}
.message-activity {
grid-template-columns: 1fr;
}