9 Commits

Author SHA1 Message Date
HRiggs a5a718b3e4 versioning: 0.0.5
Build Release EXE / build-windows-exe (release) Successful in 50s
2026-05-08 00:37:31 -04:00
HRiggs 7b65b62f58 ux: plan mode moved 2026-05-08 00:37:09 -04:00
HRiggs 97c751c585 versioning: 0.0.4, feat: create listing, source image
Build Release EXE / build-windows-exe (release) Successful in 52s
2026-05-08 00:02:59 -04:00
HRiggs e2f87481d6 feat: plans - longrunning tasks 2026-05-07 23:54:58 -04:00
HRiggs d6c2d57fd9 feat: add cornerstone 2026-05-07 21:47:30 -04:00
HRiggs 71638fcaed feat: add smdb intergration 2026-05-07 21:20:43 -04:00
HRiggs 767e929bf9 ux: ollama flow 2026-05-07 03:03:25 -04:00
HRiggs 3b6e3c34d5 feat: chat sidebar and inbox, feat: saved chats, fix: wake jobs, fix: sandbox sends, ux: negotiation replies and draft box 2026-05-06 22:53:19 -04:00
HRiggs 58a57ddc6a fix: config saving and config loading
Build Release EXE / build-windows-exe (release) Successful in 50s
2026-05-06 15:18:51 -04:00
25 changed files with 5078 additions and 164 deletions
+3 -1
View File
@@ -1,7 +1,9 @@
OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=qwen3.5:9b OLLAMA_MODEL=qwen3.5:9b
OLLAMA_NUM_CTX=64000 OLLAMA_NUM_CTX=64512
UEX_BASE_URL=https://api.uexcorp.space/2.0 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_SECRET_KEY=
UEX_BEARER_TOKEN= UEX_BEARER_TOKEN=
TRADERAI_USER_NAME= TRADERAI_USER_NAME=
+7 -1
View File
@@ -5,6 +5,8 @@ Local Ollama-powered chat for UEX marketplace workflows.
## What It Does ## What It Does
- Searches active/current UEX marketplace listings through `GET /marketplace_listings/`. - 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. - 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. - Drafts negotiation messages and marketplace listings as pending actions.
- Requires browser approval before sending authenticated write requests to UEX. - 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. 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: 4. Install and run:
```powershell ```powershell
@@ -34,7 +38,7 @@ Local Ollama-powered chat for UEX marketplace workflows.
## Notes ## Notes
Ollama runs locally at `http://localhost:11434` by default. This app talks to Ollama's native chat API with tool schemas, then executes approved UEX calls in the FastAPI backend. `OLLAMA_NUM_CTX` controls the per-request Ollama context window; `64000` is the default because Ollama recommends at least 64k tokens for agent-style workflows when hardware allows it. Ollama runs locally at `http://localhost:11434` by default. This app talks to Ollama's native chat API with tool schemas, then executes approved UEX calls in the FastAPI backend. `OLLAMA_NUM_CTX` controls the per-request Ollama context window; `64512` is the default because Ollama recommends at least 64k tokens for agent-style workflows when hardware allows it.
## Releases And Updates ## Releases And Updates
@@ -71,6 +75,8 @@ UEX notifications are checked every `UEX_NOTIFICATION_POLL_SECONDS` seconds by d
## Sources Used ## Sources Used
- UEX SwaggerHub OpenAPI v2.1: https://app.swaggerhub.com/apis-docs/dolejska-daniel/UEX-API/v2.1 - 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 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 - 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 - 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 -*- # -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_all from PyInstaller.utils.hooks import collect_all
import sysconfig
from pathlib import Path
webview_datas, webview_binaries, webview_hiddenimports = collect_all("webview") 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( a = Analysis(
["traderai\\desktop.py"], ["traderai\\desktop.py"],
pathex=[], pathex=[],
binaries=webview_binaries, binaries=[*webview_binaries, *python_binaries],
datas=[("web", "web"), *webview_datas], datas=[("web", "web"), *webview_datas],
hiddenimports=[ hiddenimports=[
*webview_hiddenimports, *webview_hiddenimports,
+9 -1
View File
@@ -1,14 +1,22 @@
# -*- mode: python ; coding: utf-8 -*- # -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_all from PyInstaller.utils.hooks import collect_all
import sysconfig
from pathlib import Path
webview_datas, webview_binaries, webview_hiddenimports = collect_all("webview") 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( a = Analysis(
["traderai\\desktop.py"], ["traderai\\desktop.py"],
pathex=[], pathex=[],
binaries=webview_binaries, binaries=[*webview_binaries, *python_binaries],
datas=[("web", "web"), *webview_datas], datas=[("web", "web"), *webview_datas],
hiddenimports=[ hiddenimports=[
*webview_hiddenimports, *webview_hiddenimports,
+5 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "traderai" name = "traderai"
version = "0.0.1" version = "0.0.5"
description = "Local Ollama-powered assistant for UEX marketplace workflows." description = "Local Ollama-powered assistant for UEX marketplace workflows."
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
@@ -35,3 +35,7 @@ include = ["traderai*"]
+165
View File
@@ -1,4 +1,5 @@
import pytest import pytest
import asyncio
from traderai.agent import OllamaAgent, SYSTEM_PROMPT from traderai.agent import OllamaAgent, SYSTEM_PROMPT
from traderai.memory import MemoryStore from traderai.memory import MemoryStore
@@ -12,6 +13,117 @@ class EmptyTools:
return {} 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 @pytest.mark.asyncio
async def test_chat_events_warns_when_ollama_offline(): async def test_chat_events_warns_when_ollama_offline():
agent = OllamaAgent("http://127.0.0.1:1", "missing-model", EmptyTools()) 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) agent = OllamaAgent("http://127.0.0.1:1", "missing-model", EmptyTools(), num_ctx=64000)
assert agent._ollama_options() == {"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["memories"] == []
assert snapshot["conversations"] == [] assert snapshot["conversations"] == []
assert snapshot["profile"][0]["key"] == "configured_name" 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() == []
+217
View File
@@ -0,0 +1,217 @@
import pytest
from datetime import timedelta
from traderai.memory import MemoryStore, utc_now
from traderai.plans import ContinualPlanRunner, ContinualPlanStore
from traderai.scheduler import WakeScheduler
from traderai.tools import ToolRegistry
class BuyingUEX:
def __init__(self):
self.posts = []
async def get(self, path, params=None, authenticated=False):
if path == "marketplace_listings":
return {
"data": [
{
"id": 501,
"slug": "wikelo-panel-good",
"title": "Wikelo Idris panel",
"operation": "sell",
"type": "item",
"price": 450_000,
"currency": "UEC",
"in_stock": 2,
"location": "Orison",
"user_username": "seller_a",
},
{
"id": 502,
"slug": "wikelo-panel-expensive",
"title": "Wikelo Idris panel premium",
"operation": "sell",
"type": "item",
"price": 900_000,
"currency": "UEC",
"in_stock": 1,
"location": "Area18",
"user_username": "seller_b",
},
],
}
return {"data": []}
async def post(self, path, payload, authenticated=True):
self.posts.append({"path": path, "payload": payload, "authenticated": authenticated})
return {"status": "ok", "posted": self.posts[-1]}
async def delete(self, path, params=None, authenticated=True):
return {"status": "ok"}
class FakePlanAgent:
def __init__(self):
self.prompts = []
async def generate_wake_response(self, wake_message):
self.prompts.append(wake_message)
return "Custom plan checked notifications and found no blockers."
def plan_stack(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
store = ContinualPlanStore(memory)
scheduler = WakeScheduler(memory)
tools = ToolRegistry(BuyingUEX(), memory=memory, scheduler=scheduler, plan_store=store)
runner = ContinualPlanRunner(store, tools, memory)
tools.plan_runner = runner
scheduler.bind_plan_runner(runner)
return memory, store, tools, runner, scheduler
def test_continual_plan_store_creates_needs_input_plan(tmp_path):
_, store, _, _, _ = plan_stack(tmp_path)
plan = store.create_plan("Wikelo Idris", objective="Get all parts", items=[])
assert plan["status"] == "needs_input"
assert plan["items"] == []
assert plan["events"][0]["kind"] == "needs_input"
def test_custom_plan_without_items_is_active(tmp_path):
_, store, _, _, _ = plan_stack(tmp_path)
plan = store.create_plan("Watch negotiations", kind="custom", objective="Check replies and summarize next steps", items=[])
assert plan["status"] == "active"
assert plan["items"] == []
def test_continual_plan_store_creates_buying_checklist(tmp_path):
_, store, _, _, _ = plan_stack(tmp_path)
plan = store.create_plan(
"Wikelo Idris",
objective="Get all listed parts",
items=[{"item_name": "Wikelo Idris panel", "desired_quantity": 2, "max_unit_price": 500_000}],
)
assert plan["status"] == "active"
assert plan["items"][0]["item_name"] == "Wikelo Idris panel"
assert plan["items"][0]["desired_quantity"] == 2
@pytest.mark.asyncio
async def test_buying_runner_tracks_candidates_and_drafts_only(tmp_path):
memory, store, tools, runner, _ = plan_stack(tmp_path)
plan = store.create_plan(
"Wikelo Idris",
objective="Get all listed parts",
items=[{"item_name": "Wikelo Idris panel", "desired_quantity": 1, "max_unit_price": 500_000}],
)
result = await runner.run_plan(plan["id"])
snapshot = store.get_plan(plan["id"])
assert result["drafted"] == 1
assert any(candidate["listing_id"] == "501" and candidate["status"] == "drafted" for candidate in snapshot["candidates"])
assert snapshot["negotiations"][0]["status"] == "drafted"
assert len(tools.pending_actions) == 1
assert not tools.uex.posts
assert "Drafted 1 negotiation" in memory.list_outbox()[0]["content"]
@pytest.mark.asyncio
async def test_plan_approval_logs_back_to_plan(tmp_path):
_, store, tools, runner, _ = plan_stack(tmp_path)
plan = store.create_plan(
"Wikelo Idris",
objective="Get all listed parts",
items=[{"item_name": "Wikelo Idris panel", "max_unit_price": 500_000}],
)
await runner.run_plan(plan["id"])
action_id = next(iter(tools.pending_actions))
approved = await tools.approve(action_id)
snapshot = store.get_plan(plan["id"])
assert approved["posted"]["path"] == "marketplace_negotiations_messages"
assert any(event["kind"] == "approved" for event in snapshot["events"])
assert any(negotiation["status"] == "approved" for negotiation in snapshot["negotiations"])
@pytest.mark.asyncio
async def test_custom_runner_continues_plan_through_agent(tmp_path):
memory, store, tools, runner, _ = plan_stack(tmp_path)
agent = FakePlanAgent()
runner.bind_agent(agent)
plan = store.create_plan(
"Watch open negotiations",
kind="custom",
objective="Check UEX replies and recommend next action",
constraints={"instructions": "Pay attention to stale buyer replies."},
items=[],
)
result = await runner.run_plan(plan["id"])
snapshot = store.get_plan(plan["id"])
assert result["status"] == "ok"
assert "Custom plan checked notifications" in result["summary"]
assert plan["id"] in agent.prompts[0]
assert any(event["kind"] == "run" for event in snapshot["events"])
assert "Custom plan checked notifications" in memory.list_outbox()[0]["content"]
@pytest.mark.asyncio
async def test_scheduler_plan_run_survives_runner_error(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
store = ContinualPlanStore(memory)
plan = store.create_plan(
"Broken plan",
objective="Test failure handling",
items=[{"item_name": "Wikelo Idris panel"}],
)
class FailingRunner:
def __init__(self, store):
self.store = store
async def run_plan(self, plan_id):
self.store.add_event(plan_id, "error", "boom")
memory.add_outbox("Broken plan: boom")
return {"error": "boom", "plan": self.store.get_plan(plan_id)}
scheduler = WakeScheduler(memory)
scheduler.bind_plan_runner(FailingRunner(store))
await scheduler._run_plan(plan["id"])
snapshot = store.get_plan(plan["id"])
assert snapshot["status"] == "active"
assert snapshot["events"][0]["kind"] == "error"
assert "boom" in memory.list_outbox()[0]["content"]
@pytest.mark.asyncio
async def test_scheduler_schedules_overdue_plan_catchup_on_start(tmp_path):
memory, store, _, runner, scheduler = plan_stack(tmp_path)
plan = store.create_plan(
"Overdue plan",
objective="Check after restart",
items=[{"item_name": "Wikelo Idris panel"}],
)
store.update_schedule(plan["id"], (utc_now() - timedelta(minutes=5)).isoformat())
scheduler.start()
try:
catchup = scheduler.scheduler.get_job(scheduler._plan_catchup_job_id(plan["id"]))
snapshot = store.get_plan(plan["id"])
finally:
scheduler.shutdown()
assert catchup is not None
assert any(event["kind"] == "catchup_scheduled" for event in snapshot["events"])
+146
View File
@@ -1,5 +1,6 @@
import pytest import pytest
from traderai.agent import OllamaAgent
from traderai.memory import MemoryStore from traderai.memory import MemoryStore
from traderai.scheduler import WakeScheduler 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 @pytest.mark.asyncio
async def test_poll_uex_notifications_adds_unread_once(tmp_path): async def test_poll_uex_notifications_adds_unread_once(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3")) 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 second == []
assert len(outbox) == 1 assert len(outbox) == 1
assert "A buyer replied to your listing." in outbox[0]["content"] 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
+313
View File
@@ -2,11 +2,15 @@ import pytest
import respx import respx
from httpx import Response from httpx import Response
from traderai.cornerstone_client import CornerstoneClient, parse_cornerstone_item_page
from traderai.tools import ToolRegistry from traderai.tools import ToolRegistry
from traderai.uex_client import UEXClient from traderai.uex_client import UEXClient
class FakeUEX: class FakeUEX:
def __init__(self):
self.posts = []
async def get(self, path, params=None, authenticated=False): async def get(self, path, params=None, authenticated=False):
if path == "commodities_prices_history": if path == "commodities_prices_history":
return { return {
@@ -113,6 +117,147 @@ class FakeUEX:
async def delete(self, path, params=None, authenticated=True): async def delete(self, path, params=None, authenticated=True):
return {"status": "ok", "deleted": {"path": path, "params": params, "authenticated": authenticated}} 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>
<meta property="og:image" content="/images/abrade.png">
</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>
""",
}
async def get_image_data(self, url, max_bytes=10_000_000):
assert url == f"{self.base_url}/images/abrade.png"
return {
"url": url,
"content_type": "image/png",
"size_bytes": 12,
"image_data": "ZmFrZS1pbWFnZQ==",
}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_search_marketplace_listings_filters_locally(): async def test_search_marketplace_listings_filters_locally():
@@ -145,6 +290,19 @@ async def test_decline_pending_action_removes_without_sending():
assert action_id not in registry.pending_actions 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(): def test_uex_client_uses_bearer_and_secret_headers():
client = UEXClient("https://api.uexcorp.space/2.0", secret_key="secret", bearer_token="bearer") client = UEXClient("https://api.uexcorp.space/2.0", secret_key="secret", bearer_token="bearer")
@@ -216,6 +374,161 @@ def test_schemas_expose_specific_uex_tools_instead_of_generic_api_tool():
assert "uex_draft_post" not in names 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
assert "get_cornerstone_item_media" in names
assert "draft_marketplace_listing_with_cornerstone_image" 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",
}
]
@pytest.mark.asyncio
async def test_get_cornerstone_item_media_returns_absolute_image_urls():
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
result = await registry.get_cornerstone_item_media(query="abrade")
assert result["media"] == [
{
"url": "https://finder.cstone.test/images/abrade.png",
"source": "og:image",
}
]
@pytest.mark.asyncio
async def test_draft_marketplace_listing_with_cornerstone_image_adds_image_data_and_redacts_display():
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
result = await registry.draft_marketplace_listing_with_cornerstone_image(
item_query="abrade",
id_category=3,
operation="sell",
type="item",
unit="unit",
title="Abrade Scraper Module",
description="Clean module, ready for pickup.",
price=21250,
currency="UEC",
language="en_US",
source="purchased_in_game",
in_stock=1,
)
pending = result["pending_action"]
stored = registry.pending_actions[pending["id"]]
assert pending["endpoint"] == "marketplace_advertise"
assert pending["payload"]["image_data"].startswith("<base64 image data redacted")
assert stored.payload["image_data"] == "ZmFrZS1pbWFnZQ=="
assert pending["metadata"]["cornerstone_image_status"] == "included"
def test_parse_cornerstone_item_page_extracts_locations():
parsed = parse_cornerstone_item_page(
"""
<html><head><title>Star Citizen - Food - Whamburger</title><meta property="og:image" content="/img/wham.png"></head>
<body><table><tr><td>NAME</td><td>Whamburger</td></tr></table>
<img src="https://example.test/extra.png" alt="Whamburger">
<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>
""",
"https://finder.cstone.test/Search/item-wham",
)
assert parsed["name"] == "Whamburger"
assert parsed["locations"][0]["base_price"] == 9
assert parsed["media"][0]["url"] == "https://finder.cstone.test/img/wham.png"
assert parsed["media"][1]["url"] == "https://example.test/extra.png"
@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 @pytest.mark.asyncio
async def test_search_uex_api_index_finds_history_tools(): async def test_search_uex_api_index_finds_history_tools():
registry = ToolRegistry(FakeUEX()) registry = ToolRegistry(FakeUEX())
+290 -48
View File
@@ -1,23 +1,31 @@
from __future__ import annotations from __future__ import annotations
import json import json
import re
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from typing import Any from typing import Any
import httpx import httpx
from tzlocal import get_localzone 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 from traderai.tools import ToolRegistry
SYSTEM_PROMPT = """You are TraderAI, a local assistant for UEX marketplace work. SYSTEM_PROMPT = """You are TraderAI, a local assistant for UEX marketplace work.
Use tools when the user asks about UEX data, open/current listings, active negotiations, unread notifications, messages, offers, or posting ads. Use tools when the user asks about UEX data, open/current listings, active negotiations, unread notifications, messages, offers, or posting ads.
Use continual plan tools when the user asks for multi-day or recurring marketplace work, such as finding several parts, watching for deals, tracking candidates, or coordinating negotiations over time.
UEX credentials are configured server-side when available. Never ask the user to provide UEX_SECRET_KEY or UEX_BEARER_TOKEN in chat; call the authenticated UEX tool and only mention credential configuration if the tool returns an authentication error.
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. Use the specific UEX tool for the needed endpoint, such as get_uex_commodities_prices or get_uex_vehicles. Use fields, limit, and summary mode so tool results stay compact.
When the user asks for history, trends, changes over time, or past prices, prefer the summarize_uex_*_history tools when available; use search_uex_api_index(history_only=true) if you need to discover history endpoints. When 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.
When drafting UEX marketplace item posts that need images, use Cornerstone media tools or draft_marketplace_listing_with_cornerstone_image so the pending listing can include UEX image_data sourced from Cornerstone.
Prefer open and current UEX marketplace information. Do not use historical sale data, completed sale records, or sale/average-history information unless the user explicitly asks for historical sales. 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. 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. For marketplace writes, draft the exact pending action and tell the user what will be sent; never claim it was sent until approval succeeds.
For continual plans, never invent an unknown parts checklist. If the required items cannot be derived from provided details or tools, create the plan in a needs-input state and say what item list is missing.
When a scheduled wake job fires, always write a concise Inbox-ready result that says what you checked, the key findings, and the suggested next action.
Keep prices, listing ids, slugs, users, and UEX status codes precise. If data is missing, say what you need next.""" Keep prices, listing ids, slugs, users, and UEX status codes precise. If data is missing, say what you need next."""
@@ -37,7 +45,7 @@ class OllamaAgent:
self.memory = memory self.memory = memory
self.user_name = user_name self.user_name = user_name
self.num_ctx = num_ctx 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]: async def health(self) -> dict[str, Any]:
try: try:
@@ -50,7 +58,7 @@ class OllamaAgent:
"online": False, "online": False,
"model": self.model, "model": self.model,
"base_url": self.base_url, "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), "detail": str(exc),
} }
@@ -69,52 +77,87 @@ class OllamaAgent:
if not health["online"]: if not health["online"]:
raise OllamaUnavailable(health["message"]) 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() 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: if self.memory:
self.memory.add_conversation("user", content) self.memory.add_conversation("user", content, resolved_thread_id)
self.messages.append({"role": "user", "content": content}) 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): 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 {} message = response.get("message") or {}
tool_calls = message.get("tool_calls") or [] tool_calls = message.get("tool_calls") or []
if not tool_calls: 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: if self.memory:
self.memory.add_conversation("assistant", message.get("content", "")) self.memory.add_conversation("assistant", answer, resolved_thread_id)
return {"message": message.get("content", ""), "pending_actions": self._pending_payloads()} 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: for call in tool_calls:
name, arguments = self._extract_call(call) name, arguments = self._extract_call(call)
result = await self.tools.execute(name, arguments) 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." 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: if self.memory:
self.memory.add_conversation("assistant", fallback) self.memory.add_conversation("assistant", fallback, resolved_thread_id)
return {"message": fallback, "pending_actions": self._pending_payloads()} 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() health = await self.health()
if not health["online"]: if not health["online"]:
yield {"type": "warning", "message": health["message"]} yield {"type": "warning", "message": health["message"]}
yield {"type": "done", "pending_actions": self._pending_payloads()} yield {"type": "done", "pending_actions": self._pending_payloads()}
return 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: if self.memory:
self.memory.add_conversation("user", content) self.memory.add_conversation("user", content, resolved_thread_id)
self.messages.append({"role": "user", "content": content}) await self._title_first_message(resolved_thread_id, content, previous_interaction)
messages.append({"role": "user", "content": content})
yield {"type": "status", "message": "Thinking"} yield {"type": "status", "message": "Thinking"}
last_tool_results: list[dict[str, Any]] = []
for _ in range(5): for _ in range(5):
assistant_message: dict[str, Any] = {"role": "assistant", "content": ""} assistant_message: dict[str, Any] = {"role": "assistant", "content": ""}
tool_calls: list[dict[str, Any]] = [] tool_calls: list[dict[str, Any]] = []
async for event in self._ollama_chat_stream(content, previous_interaction=previous_interaction): 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 {} message = event.get("message") or {}
chunk = message.get("content") or "" chunk = message.get("content") or ""
if chunk: if chunk:
@@ -126,50 +169,122 @@ class OllamaAgent:
metrics = self._stream_metrics(event) metrics = self._stream_metrics(event)
if metrics: if metrics:
yield {"type": "metrics", **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: 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: if self.memory:
self.memory.add_conversation("assistant", assistant_message.get("content", "")) self.memory.add_conversation("assistant", assistant_message.get("content", ""), resolved_thread_id)
yield {"type": "done", "pending_actions": self._pending_payloads()} yield {"type": "done", "pending_actions": self._pending_payloads(), "thread_id": resolved_thread_id}
return return
assistant_message["tool_calls"] = tool_calls assistant_message["tool_calls"] = tool_calls
self.messages.append(assistant_message) messages.append(assistant_message)
for call in tool_calls: for call in tool_calls:
name, arguments = self._extract_call(call) name, arguments = self._extract_call(call)
yield {"type": "status", "message": self._tool_status(name)} yield {"type": "status", "message": self._tool_status(name)}
result = await self.tools.execute(name, arguments) 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"} yield {"type": "status", "message": "Writing response"}
fallback = "I hit the tool-call limit while working on that. Try narrowing the request or approve any pending action first." fallback = "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: 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": "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: async def generate_wake_response(self, wake_message: str) -> str:
await self.ensure_available() await self.ensure_available()
self.messages.append({"role": "user", "content": wake_message}) messages = self._messages_for_thread("wake")
response = await self._ollama_chat(wake_message) previous_interaction = self.memory.last_interaction("wake") if self.memory else None
message = response.get("message") or {} messages.append({"role": "user", "content": wake_message})
content = message.get("content", "") last_tool_results: list[dict[str, Any]] = []
self.messages.append({"role": "assistant", "content": content}) 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: if self.memory:
self.memory.add_conversation("system", wake_message) self.memory.add_conversation("system", wake_message, "wake")
self.memory.add_conversation("assistant", content) self.memory.add_conversation("assistant", content, "wake")
return content or wake_message 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: async with httpx.AsyncClient(timeout=120) as client:
response = await client.post( response = await client.post(
f"{self.base_url}/api/chat", f"{self.base_url}/api/chat",
json={ json={
"model": self.model, "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, "tools": self.tools.schemas,
"options": self._ollama_options(), "options": self._ollama_options(),
"stream": False, "stream": False,
@@ -181,7 +296,9 @@ class OllamaAgent:
async def _ollama_chat_stream( async def _ollama_chat_stream(
self, self,
query: str = "", query: str = "",
messages: list[dict[str, Any]] | None = None,
previous_interaction: dict[str, Any] | None = None, previous_interaction: dict[str, Any] | None = None,
thread_id: str | None = DEFAULT_THREAD_ID,
) -> AsyncIterator[dict[str, Any]]: ) -> AsyncIterator[dict[str, Any]]:
async with httpx.AsyncClient(timeout=120) as client: async with httpx.AsyncClient(timeout=120) as client:
async with client.stream( async with client.stream(
@@ -189,7 +306,12 @@ class OllamaAgent:
f"{self.base_url}/api/chat", f"{self.base_url}/api/chat",
json={ json={
"model": self.model, "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, "tools": self.tools.schemas,
"options": self._ollama_options(), "options": self._ollama_options(),
"stream": True, "stream": True,
@@ -203,18 +325,40 @@ class OllamaAgent:
def _messages_with_context( def _messages_with_context(
self, self,
query: str, query: str,
messages: list[dict[str, Any]],
previous_interaction: dict[str, Any] | None = None, previous_interaction: dict[str, Any] | None = None,
thread_id: str | None = DEFAULT_THREAD_ID,
) -> list[dict[str, Any]]: ) -> 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: if not context:
return self.messages return messages
return [self.messages[0], {"role": "system", "content": context}, *self.messages[1:]] 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() local_zone = get_localzone()
parts = [ parts = [
f"Current local date/time: {iso_now()} UTC; {iso_now_in_zone(local_zone)} {local_zone}.", f"Current local date/time: {iso_now()} UTC; {iso_now_in_zone(local_zone)} {local_zone}.",
] ]
uex = getattr(self.tools, "uex", None)
if uex:
auth_methods = []
if uex.secret_key:
auth_methods.append("secret key")
if uex.bearer_token:
auth_methods.append("bearer token")
if auth_methods:
parts.append(
"UEX API authentication is configured server-side with "
+ " and ".join(auth_methods)
+ "; use authenticated UEX tools directly and do not ask for tokens."
)
else:
parts.append("UEX API authentication is not configured server-side.")
if self.user_name: if self.user_name:
parts.append(f"Known user name/handle: {self.user_name}.") parts.append(f"Known user name/handle: {self.user_name}.")
@@ -228,7 +372,7 @@ class OllamaAgent:
parts.append(identity) parts.append(identity)
parts.append(f"Known user profile JSON: {json.dumps(self._profile_for_prompt(profile), ensure_ascii=True)}.") 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: if last:
parts.append( parts.append(
f"Previous interaction before this message: {last['created_at']} " f"Previous interaction before this message: {last['created_at']} "
@@ -245,16 +389,85 @@ class OllamaAgent:
) )
parts.append(f"Relevant long-term memories:\n{memory_text}") 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: if recent:
recent_text = "\n".join( recent_text = "\n".join(
f"- {item['created_at']} {item['role']}: {item['content'][:500]}" f"- {item['created_at']} {item['role']}: {item['content'][:500]}"
for item in recent 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) 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]]: def _pending_payloads(self) -> list[dict[str, Any]]:
return [ return [
{ {
@@ -262,7 +475,8 @@ class OllamaAgent:
"label": action.label, "label": action.label,
"method": action.method, "method": action.method,
"endpoint": action.endpoint, "endpoint": action.endpoint,
"payload": action.payload, "payload": self.tools._display_payload(action.payload) if hasattr(self.tools, "_display_payload") else action.payload,
"metadata": action.metadata or {},
} }
for action in self.tools.pending_actions.values() for action in self.tools.pending_actions.values()
] ]
@@ -272,6 +486,27 @@ class OllamaAgent:
return {} return {}
return {"num_ctx": self.num_ctx} 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 @staticmethod
def _tool_status(name: str) -> str: def _tool_status(name: str) -> str:
if name.startswith("get_uex_"): if name.startswith("get_uex_"):
@@ -285,6 +520,12 @@ class OllamaAgent:
"summarize_uex_commodity_price_history": "Summarizing commodity price history", "summarize_uex_commodity_price_history": "Summarizing commodity price history",
"summarize_uex_marketplace_price_history": "Summarizing marketplace price history", "summarize_uex_marketplace_price_history": "Summarizing marketplace price history",
"summarize_uex_currency_index_history": "Summarizing currency index 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",
"get_cornerstone_item_media": "Fetching Cornerstone item media",
"uex_api_catalog": "Checking UEX API catalog", "uex_api_catalog": "Checking UEX API catalog",
"uex_get": "Fetching UEX data", "uex_get": "Fetching UEX data",
"uex_draft_post": "Drafting UEX write for approval", "uex_draft_post": "Drafting UEX write for approval",
@@ -295,6 +536,7 @@ class OllamaAgent:
"get_negotiation_messages": "Reading negotiation messages", "get_negotiation_messages": "Reading negotiation messages",
"draft_negotiation_message": "Drafting message for approval", "draft_negotiation_message": "Drafting message for approval",
"draft_marketplace_listing": "Drafting listing for approval", "draft_marketplace_listing": "Drafting listing for approval",
"draft_marketplace_listing_with_cornerstone_image": "Drafting listing with Cornerstone image",
"check_uex_notifications": "Checking UEX notifications", "check_uex_notifications": "Checking UEX notifications",
} }
return labels.get(name, f"Running {name}") return labels.get(name, f"Running {name}")
+13 -1
View File
@@ -15,6 +15,8 @@ CONFIG_FIELDS: dict[str, dict[str, Any]] = {
"ollama_model": {"env": "OLLAMA_MODEL", "type": "string", "secret": False}, "ollama_model": {"env": "OLLAMA_MODEL", "type": "string", "secret": False},
"ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False}, "ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False},
"uex_base_url": {"env": "UEX_BASE_URL", "type": "string", "secret": False}, "uex_base_url": {"env": "UEX_BASE_URL", "type": "string", "secret": False},
"scmdb_base_url": {"env": "SCMDB_BASE_URL", "type": "string", "secret": False},
"cornerstone_base_url": {"env": "CORNERSTONE_BASE_URL", "type": "string", "secret": False},
"uex_secret_key": {"env": "UEX_SECRET_KEY", "type": "string", "secret": True}, "uex_secret_key": {"env": "UEX_SECRET_KEY", "type": "string", "secret": True},
"uex_bearer_token": {"env": "UEX_BEARER_TOKEN", "type": "string", "secret": True}, "uex_bearer_token": {"env": "UEX_BEARER_TOKEN", "type": "string", "secret": True},
"traderai_user_name": {"env": "TRADERAI_USER_NAME", "type": "string", "secret": False}, "traderai_user_name": {"env": "TRADERAI_USER_NAME", "type": "string", "secret": False},
@@ -62,8 +64,10 @@ class Settings(BaseSettings):
ollama_base_url: str = "http://localhost:11434" ollama_base_url: str = "http://localhost:11434"
ollama_model: str = "qwen3.5:9b" ollama_model: str = "qwen3.5:9b"
ollama_num_ctx: int = 64000 ollama_num_ctx: int = 64512
uex_base_url: str = "https://api.uexcorp.space/2.0" 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_secret_key: str | None = Field(default=None)
uex_bearer_token: str | None = Field(default=None) uex_bearer_token: str | None = Field(default=None)
traderai_user_name: str | None = Field(default=None) traderai_user_name: str | None = Field(default=None)
@@ -90,6 +94,11 @@ def get_settings() -> Settings:
def settings_payload(settings: Settings | None = None) -> dict[str, Any]: def settings_payload(settings: Settings | None = None) -> dict[str, Any]:
current = settings or get_settings() current = settings or get_settings()
values = current.model_dump() values = current.model_dump()
secrets_configured = {}
for key, meta in CONFIG_FIELDS.items():
if meta.get("secret"):
secrets_configured[key] = bool(values.get(key))
values[key] = ""
return { return {
"app_data_dir": str(ensure_app_data_dir()), "app_data_dir": str(ensure_app_data_dir()),
"config_path": str(user_config_path()), "config_path": str(user_config_path()),
@@ -97,6 +106,7 @@ def settings_payload(settings: Settings | None = None) -> dict[str, Any]:
"edge_profile_dir": str(edge_profile_dir()), "edge_profile_dir": str(edge_profile_dir()),
"values": values, "values": values,
"fields": CONFIG_FIELDS, "fields": CONFIG_FIELDS,
"secrets_configured": secrets_configured,
} }
@@ -106,6 +116,8 @@ def save_settings(values: dict[str, Any]) -> dict[str, Any]:
for key, value in values.items(): for key, value in values.items():
if key not in CONFIG_FIELDS: if key not in CONFIG_FIELDS:
continue continue
if CONFIG_FIELDS[key].get("secret") and value == "":
continue
next_values[key] = _coerce_value(key, value) next_values[key] = _coerce_value(key, value)
path = user_config_path() path = user_config_path()
+226
View File
@@ -0,0 +1,226 @@
from __future__ import annotations
from html.parser import HTMLParser
import base64
import json
from typing import Any
from urllib.parse import urljoin
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_image_data(self, url: str, max_bytes: int = 10_000_000) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
response = await client.get(url, headers={"Accept": "image/png,image/jpeg,image/*"})
if response.status_code >= 400:
raise CornerstoneError(f"Cornerstone image HTTP {response.status_code}: {response.text[:240]}")
content_type = response.headers.get("content-type", "").split(";")[0].strip().casefold()
if content_type not in {"image/jpeg", "image/jpg", "image/png"}:
raise CornerstoneError(f"Cornerstone image was not JPG or PNG: {content_type or 'unknown content type'}")
if len(response.content) > max_bytes:
raise CornerstoneError(f"Cornerstone image is larger than {max_bytes} bytes.")
return {
"url": str(response.url),
"content_type": content_type,
"size_bytes": len(response.content),
"image_data": base64.b64encode(response.content).decode("ascii"),
}
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.images: list[dict[str, 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 == "meta":
attr_map = self._attrs(attrs)
name = (attr_map.get("property") or attr_map.get("name") or "").casefold()
content = attr_map.get("content") or ""
if content and name in {"og:image", "twitter:image", "twitter:image:src"}:
self.images.append({"url": content, "source": name})
elif tag == "link":
attr_map = self._attrs(attrs)
rel = (attr_map.get("rel") or "").casefold()
href = attr_map.get("href") or ""
if href and "image_src" in rel:
self.images.append({"url": href, "source": "link:image_src"})
elif tag == "img":
attr_map = self._attrs(attrs)
url = attr_map.get("src") or attr_map.get("data-src") or attr_map.get("data-original") or ""
if url:
self.images.append(
{
"url": url,
"alt": attr_map.get("alt") or "",
"source": "img",
}
)
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)
@staticmethod
def _attrs(attrs: list[tuple[str, str | None]]) -> dict[str, str]:
return {key.casefold(): value or "" for key, value in attrs}
def parse_cornerstone_item_page(html: str, page_url: str | None = None) -> 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"])
media = _dedupe_media(parser.images, page_url)
if media:
info["media"] = media
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
def _dedupe_media(images: list[dict[str, str]], page_url: str | None = None) -> list[dict[str, str]]:
media = []
seen = set()
for image in images:
raw_url = (image.get("url") or "").strip()
if not raw_url or raw_url.startswith("data:"):
continue
url = urljoin(page_url or "", raw_url)
if url in seen:
continue
seen.add(url)
item = dict(image)
item["url"] = url
media.append(item)
return media
+192 -13
View File
@@ -8,6 +8,9 @@ from typing import Any
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
DEFAULT_THREAD_ID = "default"
def utc_now() -> datetime: def utc_now() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
@@ -65,8 +68,16 @@ class MemoryStore:
with self._connect() as db: with self._connect() as db:
db.executescript( 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 ( CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id TEXT,
role TEXT NOT NULL, role TEXT NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
created_at TEXT NOT NULL created_at TEXT NOT NULL
@@ -129,25 +140,150 @@ class MemoryStore:
); );
""" """
) )
self._ensure_column(db, "conversations", "thread_id", "TEXT")
def add_conversation(self, role: str, content: str) -> None: now = iso_now()
with self._connect() as db:
db.execute( 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: 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( 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() ).fetchone()
return dict(row) if row else None 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: with self._connect() as db:
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( rows = db.execute(
"SELECT role, content, created_at FROM conversations ORDER BY id DESC LIMIT ?", """
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,), (limit,),
).fetchall() ).fetchall()
return [dict(row) for row in reversed(rows)] return [dict(row) for row in reversed(rows)]
@@ -222,13 +358,22 @@ class MemoryStore:
).fetchall() ).fetchall()
conversations = db.execute( conversations = db.execute(
""" """
SELECT id, role, content, created_at SELECT id, thread_id, role, content, created_at
FROM conversations FROM conversations
ORDER BY id DESC ORDER BY id DESC
LIMIT ? LIMIT ?
""", """,
(limit,), (limit,),
).fetchall() ).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( profile_rows = db.execute(
"SELECT key, value, updated_at FROM user_profile ORDER BY key" "SELECT key, value, updated_at FROM user_profile ORDER BY key"
).fetchall() ).fetchall()
@@ -252,6 +397,7 @@ class MemoryStore:
return { return {
"path": str(self.path), "path": str(self.path),
"memories": [self._memory_row(row) for row in memories], "memories": [self._memory_row(row) for row in memories],
"chat_threads": [dict(row) for row in threads],
"conversations": [dict(row) for row in conversations], "conversations": [dict(row) for row in conversations],
"profile": profile, "profile": profile,
"scheduled_jobs": [dict(row) for row in jobs], "scheduled_jobs": [dict(row) for row in jobs],
@@ -339,17 +485,38 @@ class MemoryStore:
).fetchall() ).fetchall()
return [dict(row) for row in rows] 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: with self._connect() as db:
db.execute( db.execute(
"UPDATE scheduled_jobs SET last_run_at = ?, next_run_at = ? WHERE id = ?", "UPDATE scheduled_jobs SET last_run_at = ?, next_run_at = ?, enabled = ? WHERE id = ?",
(iso_now(), next_run_at, job_id), (iso_now(), next_run_at, 1 if enabled else 0, job_id),
) )
def add_outbox(self, content: str) -> None: def add_outbox(self, content: str) -> None:
with self._connect() as db: with self._connect() as db:
db.execute("INSERT INTO outbox(content, created_at) VALUES (?, ?)", (content, iso_now())) 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]]: def undelivered_outbox(self) -> list[dict[str, Any]]:
now = iso_now() now = iso_now()
with self._connect() as db: with self._connect() as db:
@@ -362,6 +529,18 @@ class MemoryStore:
) )
return [dict(row) for row in rows] 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 @staticmethod
def _fts_query(query: str) -> str: def _fts_query(query: str) -> str:
tokens = [token.replace('"', "") for token in query.split() if token.strip()] tokens = [token.replace('"', "") for token in query.split() if token.strip()]
+590
View File
@@ -0,0 +1,590 @@
from __future__ import annotations
import json
import uuid
from typing import Any
from traderai.memory import MemoryStore, iso_now
DEFAULT_PLAN_CADENCE = "0 */6 * * *"
class ContinualPlanStore:
def __init__(self, memory: MemoryStore) -> None:
self.memory = memory
self._init_db()
def _init_db(self) -> None:
with self.memory._connect() as db:
db.executescript(
"""
CREATE TABLE IF NOT EXISTS continual_plans (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
kind TEXT NOT NULL,
status TEXT NOT NULL,
objective TEXT NOT NULL,
constraints TEXT NOT NULL DEFAULT '{}',
cadence TEXT NOT NULL,
next_run_at TEXT,
last_run_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS continual_plan_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id TEXT NOT NULL,
item_name TEXT NOT NULL,
desired_quantity INTEGER NOT NULL DEFAULT 1,
max_unit_price REAL,
status TEXT NOT NULL DEFAULT 'active',
acquired_quantity INTEGER NOT NULL DEFAULT 0,
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS continual_plan_candidates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id TEXT NOT NULL,
plan_item_id INTEGER NOT NULL,
listing_id TEXT,
listing_slug TEXT,
title TEXT,
seller TEXT,
price REAL,
currency TEXT,
stock INTEGER,
location TEXT,
score REAL,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'current',
metadata TEXT NOT NULL DEFAULT '{}',
UNIQUE(plan_item_id, listing_id)
);
CREATE TABLE IF NOT EXISTS continual_plan_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id TEXT NOT NULL,
kind TEXT NOT NULL,
message TEXT NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS continual_plan_negotiations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id TEXT NOT NULL,
plan_item_id INTEGER,
candidate_id INTEGER,
listing_id TEXT,
listing_slug TEXT,
negotiation_id TEXT,
negotiation_hash TEXT,
status TEXT NOT NULL DEFAULT 'drafted',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
"""
)
def create_plan(
self,
title: str,
kind: str = "buying",
objective: str = "",
items: list[dict[str, Any]] | None = None,
constraints: dict[str, Any] | None = None,
cadence: str | None = None,
status: str | None = None,
) -> dict[str, Any]:
clean_items = [item for item in (items or []) if str(item.get("item_name") or item.get("name") or "").strip()]
plan_id = f"plan-{uuid.uuid4()}"
now = iso_now()
clean_kind = (kind.strip() or "buying").casefold()
resolved_status = status or ("needs_input" if clean_kind == "buying" and not clean_items else "active")
with self.memory._connect() as db:
db.execute(
"""
INSERT INTO continual_plans(id, title, kind, status, objective, constraints, cadence, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
plan_id,
title.strip() or "Continual plan",
clean_kind,
resolved_status,
objective.strip() or title.strip(),
json.dumps(constraints or {}),
(cadence or DEFAULT_PLAN_CADENCE).strip() or DEFAULT_PLAN_CADENCE,
now,
now,
),
)
for item in clean_items:
db.execute(
"""
INSERT INTO continual_plan_items(
plan_id, item_name, desired_quantity, max_unit_price, status,
acquired_quantity, metadata, created_at, updated_at
)
VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?)
""",
(
plan_id,
str(item.get("item_name") or item.get("name")).strip(),
max(1, int(item.get("desired_quantity") or item.get("quantity") or 1)),
item.get("max_unit_price"),
max(0, int(item.get("acquired_quantity") or 0)),
json.dumps(item.get("metadata") or {}),
now,
now,
),
)
if clean_kind == "buying" and not clean_items:
self.add_event(plan_id, "needs_input", "Created plan, but no item checklist was provided. Add the required parts before it can run.")
elif clean_items:
self.add_event(plan_id, "created", f"Created continual {clean_kind} plan with {len(clean_items)} checklist item(s).")
else:
self.add_event(plan_id, "created", f"Created continual {clean_kind} plan.")
return self.get_plan(plan_id) or {}
def list_plans(self, include_inactive: bool = True) -> list[dict[str, Any]]:
where = "" if include_inactive else "WHERE status = 'active'"
with self.memory._connect() as db:
rows = db.execute(
f"""
SELECT *
FROM continual_plans
{where}
ORDER BY
CASE status WHEN 'active' THEN 0 WHEN 'needs_input' THEN 1 WHEN 'paused' THEN 2 ELSE 3 END,
updated_at DESC
"""
).fetchall()
return [self._plan_row(row) for row in rows]
def get_plan(self, plan_id: str) -> dict[str, Any] | None:
with self.memory._connect() as db:
plan = db.execute("SELECT * FROM continual_plans WHERE id = ?", (plan_id,)).fetchone()
if not plan:
return None
data = self._plan_row(plan)
data["items"] = self.list_items(plan_id)
data["candidates"] = self.list_candidates(plan_id)
data["negotiations"] = self.list_negotiations(plan_id)
data["events"] = self.list_events(plan_id)
return data
def list_items(self, plan_id: str) -> list[dict[str, Any]]:
with self.memory._connect() as db:
rows = db.execute(
"SELECT * FROM continual_plan_items WHERE plan_id = ? ORDER BY id",
(plan_id,),
).fetchall()
return [self._json_row(row, "metadata") for row in rows]
def list_candidates(self, plan_id: str, limit: int = 100) -> list[dict[str, Any]]:
with self.memory._connect() as db:
rows = db.execute(
"""
SELECT *
FROM continual_plan_candidates
WHERE plan_id = ?
ORDER BY status = 'current' DESC, score DESC, last_seen_at DESC
LIMIT ?
""",
(plan_id, limit),
).fetchall()
return [self._json_row(row, "metadata") for row in rows]
def list_events(self, plan_id: str, limit: int = 50) -> list[dict[str, Any]]:
with self.memory._connect() as db:
rows = db.execute(
"""
SELECT *
FROM continual_plan_events
WHERE plan_id = ?
ORDER BY id DESC
LIMIT ?
""",
(plan_id, limit),
).fetchall()
return [self._json_row(row, "metadata") for row in rows]
def list_negotiations(self, plan_id: str) -> list[dict[str, Any]]:
with self.memory._connect() as db:
rows = db.execute(
"SELECT * FROM continual_plan_negotiations WHERE plan_id = ? ORDER BY updated_at DESC",
(plan_id,),
).fetchall()
return [dict(row) for row in rows]
def set_status(self, plan_id: str, status: str) -> dict[str, Any] | None:
with self.memory._connect() as db:
db.execute(
"UPDATE continual_plans SET status = ?, updated_at = ? WHERE id = ?",
(status, iso_now(), plan_id),
)
self.add_event(plan_id, status, f"Plan status changed to {status}.")
return self.get_plan(plan_id)
def add_event(self, plan_id: str, kind: str, message: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
now = iso_now()
with self.memory._connect() as db:
cursor = db.execute(
"""
INSERT INTO continual_plan_events(plan_id, kind, message, metadata, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(plan_id, kind, message, json.dumps(metadata or {}), now),
)
return {"id": cursor.lastrowid, "plan_id": plan_id, "kind": kind, "message": message, "created_at": now}
def update_schedule(self, plan_id: str, next_run_at: str | None = None, last_run_at: str | None = None) -> None:
fields = ["next_run_at = ?", "updated_at = ?"]
values: list[Any] = [next_run_at, iso_now()]
if last_run_at is not None:
fields.insert(1, "last_run_at = ?")
values.insert(1, last_run_at)
values.append(plan_id)
with self.memory._connect() as db:
db.execute(f"UPDATE continual_plans SET {', '.join(fields)} WHERE id = ?", values)
def upsert_candidate(self, plan_id: str, plan_item_id: int, listing: dict[str, Any], score: float) -> dict[str, Any]:
now = iso_now()
listing_id = str(listing.get("id") or listing.get("listing_id") or listing.get("slug") or uuid.uuid4())
metadata = dict(listing)
with self.memory._connect() as db:
db.execute(
"""
INSERT INTO continual_plan_candidates(
plan_id, plan_item_id, listing_id, listing_slug, title, seller, price, currency,
stock, location, score, first_seen_at, last_seen_at, status, metadata
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'current', ?)
ON CONFLICT(plan_item_id, listing_id) DO UPDATE SET
listing_slug=excluded.listing_slug,
title=excluded.title,
seller=excluded.seller,
price=excluded.price,
currency=excluded.currency,
stock=excluded.stock,
location=excluded.location,
score=excluded.score,
last_seen_at=excluded.last_seen_at,
status='current',
metadata=excluded.metadata
""",
(
plan_id,
plan_item_id,
listing_id,
listing.get("slug"),
listing.get("title"),
listing.get("advertiser") or listing.get("user_username") or listing.get("seller"),
listing.get("price"),
listing.get("currency"),
listing.get("in_stock") or listing.get("stock"),
listing.get("location"),
score,
now,
now,
json.dumps(metadata),
),
)
row = db.execute(
"SELECT * FROM continual_plan_candidates WHERE plan_item_id = ? AND listing_id = ?",
(plan_item_id, listing_id),
).fetchone()
return self._json_row(row, "metadata")
def mark_stale_candidates(self, plan_item_id: int, seen_listing_ids: set[str]) -> int:
with self.memory._connect() as db:
rows = db.execute(
"SELECT id, listing_id FROM continual_plan_candidates WHERE plan_item_id = ? AND status = 'current'",
(plan_item_id,),
).fetchall()
stale_ids = [row["id"] for row in rows if str(row["listing_id"]) not in seen_listing_ids]
if stale_ids:
placeholders = ",".join("?" for _ in stale_ids)
db.execute(
f"UPDATE continual_plan_candidates SET status = 'stale', last_seen_at = ? WHERE id IN ({placeholders})",
(iso_now(), *stale_ids),
)
return len(stale_ids)
def mark_candidate_drafted(self, candidate_id: int) -> None:
with self.memory._connect() as db:
db.execute("UPDATE continual_plan_candidates SET status = 'drafted', last_seen_at = ? WHERE id = ?", (iso_now(), candidate_id))
def add_negotiation(self, plan_id: str, plan_item_id: int | None, candidate_id: int | None, metadata: dict[str, Any]) -> dict[str, Any]:
now = iso_now()
with self.memory._connect() as db:
cursor = db.execute(
"""
INSERT INTO continual_plan_negotiations(
plan_id, plan_item_id, candidate_id, listing_id, listing_slug,
negotiation_id, negotiation_hash, status, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
plan_id,
plan_item_id,
candidate_id,
metadata.get("listing_id"),
metadata.get("listing_slug"),
metadata.get("id_negotiation"),
metadata.get("hash"),
metadata.get("status") or "drafted",
now,
now,
),
)
row = db.execute("SELECT * FROM continual_plan_negotiations WHERE id = ?", (cursor.lastrowid,)).fetchone()
return dict(row)
def has_negotiation_for_candidate(self, plan_id: str, plan_item_id: int, candidate: dict[str, Any]) -> bool:
with self.memory._connect() as db:
row = db.execute(
"""
SELECT id
FROM continual_plan_negotiations
WHERE plan_id = ?
AND plan_item_id = ?
AND (
candidate_id = ?
OR (listing_id IS NOT NULL AND listing_id = ?)
OR (listing_slug IS NOT NULL AND listing_slug = ?)
)
LIMIT 1
""",
(
plan_id,
plan_item_id,
candidate.get("id"),
candidate.get("listing_id"),
candidate.get("listing_slug"),
),
).fetchone()
return row is not None
@staticmethod
def _json_row(row: Any, *json_fields: str) -> dict[str, Any]:
data = dict(row)
for field in json_fields:
try:
data[field] = json.loads(data.get(field) or "{}")
except (TypeError, json.JSONDecodeError):
data[field] = {}
return data
@classmethod
def _plan_row(cls, row: Any) -> dict[str, Any]:
return cls._json_row(row, "constraints")
class ContinualPlanRunner:
def __init__(self, store: ContinualPlanStore, tools: Any, memory: MemoryStore, agent: Any | None = None) -> None:
self.store = store
self.tools = tools
self.memory = memory
self.agent = agent
def bind_agent(self, agent: Any) -> None:
self.agent = agent
async def run_plan(self, plan_id: str) -> dict[str, Any]:
plan = self.store.get_plan(plan_id)
if not plan:
return {"error": f"Plan not found: {plan_id}"}
if plan["status"] != "active":
message = f"Skipped {plan['title']} because status is {plan['status']}."
self.store.add_event(plan_id, "skipped", message)
return {"status": "skipped", "summary": message, "plan": self.store.get_plan(plan_id)}
try:
if plan["kind"] == "buying":
result = await self._run_buying_plan(plan)
else:
result = await self._run_agent_plan(plan)
self.store.update_schedule(plan_id, plan.get("next_run_at"), last_run_at=iso_now())
self.memory.add_outbox(result["summary"])
return {**result, "plan": self.store.get_plan(plan_id)}
except Exception as exc:
message = f"Continual plan failed: {exc}"
self.store.add_event(plan_id, "error", message)
self.memory.add_outbox(f"{plan['title']}: {message}")
self.store.update_schedule(plan_id, plan.get("next_run_at"), last_run_at=iso_now())
return {"error": str(exc), "summary": message, "plan": self.store.get_plan(plan_id)}
async def _run_agent_plan(self, plan: dict[str, Any]) -> dict[str, Any]:
if self.agent is None:
raise RuntimeError("No agent is bound to run generic continual plans.")
prompt = self._agent_plan_prompt(plan)
response = await self.agent.generate_wake_response(prompt)
summary = f"{plan['title']}: {response}"
self.store.add_event(plan["id"], "run", "Ran generic continual plan through the agent.", {"response": response})
return {"status": "ok", "summary": summary, "checked": 0, "drafted": 0}
async def _run_buying_plan(self, plan: dict[str, Any]) -> dict[str, Any]:
items = [item for item in plan.get("items") or [] if item.get("status") != "acquired"]
if not items:
self.store.set_status(plan["id"], "completed")
summary = f"{plan['title']}: all checklist items are marked acquired."
return {"status": "completed", "summary": summary, "drafted": 0, "checked": 0}
checked = 0
drafted = 0
best_lines = []
constraints = plan.get("constraints") or {}
excluded_sellers = {str(value).casefold() for value in constraints.get("excluded_sellers") or []}
preferred_locations = [str(value).casefold() for value in constraints.get("preferred_locations") or []]
for item in items:
response = await self.tools.search_marketplace_listings(
query=item["item_name"],
operation="sell",
type="item",
limit=25,
)
listings = response.get("listings") or response.get("data") or []
seen: set[str] = set()
candidates = []
for listing in listings:
if not isinstance(listing, dict):
continue
listing_id = str(listing.get("id") or listing.get("slug") or "")
if listing_id:
seen.add(listing_id)
if str(listing.get("advertiser") or listing.get("seller") or "").casefold() in excluded_sellers:
continue
score = self._candidate_score(listing, item, preferred_locations)
candidate = self.store.upsert_candidate(plan["id"], int(item["id"]), listing, score)
candidates.append(candidate)
stale = self.store.mark_stale_candidates(int(item["id"]), seen)
checked += 1
current_candidates = [candidate for candidate in candidates if candidate.get("status") == "current"]
current_candidates.sort(key=lambda candidate: (-float(candidate.get("score") or 0), float(candidate.get("price") or 10**18)))
best = current_candidates[0] if current_candidates else None
if not best:
best_lines.append(f"{item['item_name']}: no active matching sell listings found.")
self.store.add_event(plan["id"], "search", f"{item['item_name']}: no active candidates found.", {"stale": stale})
continue
best_lines.append(
f"{item['item_name']}: best candidate is {best.get('title') or best.get('listing_slug')} "
f"at {self._format_price(best.get('price'), best.get('currency'))} from {best.get('seller') or 'unknown seller'}."
)
self.store.add_event(
plan["id"],
"search",
f"{item['item_name']}: found {len(current_candidates)} current candidate(s); {stale} stale candidate(s) marked.",
{"best_candidate_id": best.get("id")},
)
if self.store.has_negotiation_for_candidate(plan["id"], int(item["id"]), best) or not self._within_budget(best, item, constraints):
continue
draft = await self._draft_buying_message(plan, item, best)
if "pending_action" in draft:
drafted += 1
self.store.mark_candidate_drafted(int(best["id"]))
self.store.add_negotiation(
plan["id"],
int(item["id"]),
int(best["id"]),
{
"listing_id": best.get("listing_id"),
"listing_slug": best.get("listing_slug"),
"status": "drafted",
},
)
self.store.add_event(
plan["id"],
"draft",
f"Drafted negotiation opener for {item['item_name']} candidate {best.get('listing_id')}.",
{"pending_action_id": draft["pending_action"].get("id"), "candidate_id": best.get("id")},
)
summary = f"{plan['title']}: checked {checked} item(s). " + " ".join(best_lines[:4])
if drafted:
summary += f" Drafted {drafted} negotiation message(s) for approval."
self.store.add_event(plan["id"], "run", summary, {"checked": checked, "drafted": drafted})
return {"status": "ok", "summary": summary, "checked": checked, "drafted": drafted}
async def _draft_buying_message(self, plan: dict[str, Any], item: dict[str, Any], candidate: dict[str, Any]) -> dict[str, Any]:
tone = (plan.get("constraints") or {}).get("message_tone") or "polite and concise"
message = (
f"Hi, I am interested in your {candidate.get('title') or item['item_name']} listing "
f"for {self._format_price(candidate.get('price'), candidate.get('currency'))}. "
f"Is it still available? I am trying to complete: {plan['objective']}. "
f"Tone note: {tone}."
)
return await self.tools.draft_negotiation_message(
message=message,
id_listing=self._int_or_none(candidate.get("listing_id")),
plan_id=plan["id"],
plan_item_id=int(item["id"]),
candidate_id=int(candidate["id"]),
listing_slug=candidate.get("listing_slug"),
)
@staticmethod
def _candidate_score(listing: dict[str, Any], item: dict[str, Any], preferred_locations: list[str]) -> float:
price = float(listing.get("price") or 10**12)
max_price = item.get("max_unit_price")
budget_bonus = 40.0 if max_price and price <= float(max_price) else 0.0
stock = float(listing.get("in_stock") or listing.get("stock") or 1)
location = str(listing.get("location") or "").casefold()
location_bonus = 8.0 if preferred_locations and any(place in location for place in preferred_locations) else 0.0
return round(max(0.0, 50.0 - (price / 10_000_000.0)) + min(stock, 20.0) + budget_bonus + location_bonus, 4)
@staticmethod
def _within_budget(candidate: dict[str, Any], item: dict[str, Any], constraints: dict[str, Any]) -> bool:
price = candidate.get("price")
if price is None:
return False
max_price = item.get("max_unit_price") or constraints.get("max_unit_price")
return max_price is None or float(price) <= float(max_price)
@staticmethod
def _format_price(price: Any, currency: Any) -> str:
if isinstance(price, (int, float)):
return f"{price:,.0f} {currency or 'UEC'}"
return f"unknown price {currency or 'UEC'}"
@staticmethod
def _int_or_none(value: Any) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
@staticmethod
def _agent_plan_prompt(plan: dict[str, Any]) -> str:
recent_events = [
{
"kind": event.get("kind"),
"message": event.get("message"),
"created_at": event.get("created_at"),
}
for event in (plan.get("events") or [])[:8]
]
payload = {
"plan_id": plan.get("id"),
"title": plan.get("title"),
"kind": plan.get("kind"),
"objective": plan.get("objective"),
"constraints": plan.get("constraints") or {},
"items": plan.get("items") or [],
"recent_events": recent_events,
}
return (
"Continual plan wake run. Continue this durable plan and write an Inbox-ready summary. "
"Use tools as needed. For any account-affecting marketplace write, only draft a pending action for approval. "
"Do not claim a message, offer, listing, or negotiation was sent unless an approved action result says it was sent. "
f"Plan JSON: {json.dumps(payload, ensure_ascii=True)}"
)
+88 -3
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime, timedelta
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
@@ -10,7 +10,7 @@ from apscheduler.triggers.date import DateTrigger
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
from tzlocal import get_localzone from tzlocal import get_localzone
from traderai.memory import MemoryStore, iso_now, time_since from traderai.memory import MemoryStore, iso_now, parse_iso, time_since, utc_now
UEX_NOTIFICATION_JOB_ID = "uex-notification-poll" UEX_NOTIFICATION_JOB_ID = "uex-notification-poll"
@@ -22,11 +22,15 @@ class WakeScheduler:
self.scheduler = AsyncIOScheduler(timezone=get_localzone()) self.scheduler = AsyncIOScheduler(timezone=get_localzone())
self.agent = None self.agent = None
self.uex = None self.uex = None
self.plan_runner = None
self.notification_poll_seconds = 60 self.notification_poll_seconds = 60
def bind_agent(self, agent: Any) -> None: def bind_agent(self, agent: Any) -> None:
self.agent = agent self.agent = agent
def bind_plan_runner(self, plan_runner: Any) -> None:
self.plan_runner = plan_runner
def bind_uex_notifications(self, uex: Any, poll_seconds: int = 60) -> None: def bind_uex_notifications(self, uex: Any, poll_seconds: int = 60) -> None:
self.uex = uex self.uex = uex
self.notification_poll_seconds = max(15, poll_seconds) self.notification_poll_seconds = max(15, poll_seconds)
@@ -37,6 +41,9 @@ class WakeScheduler:
self._schedule_notification_poll() self._schedule_notification_poll()
for job in self.memory.list_jobs(): for job in self.memory.list_jobs():
self._schedule_existing(job) self._schedule_existing(job)
if self.plan_runner is not None:
for plan in self.plan_runner.store.list_plans(include_inactive=False):
self.schedule_plan(plan)
def shutdown(self) -> None: def shutdown(self) -> None:
if self.scheduler.running: if self.scheduler.running:
@@ -59,6 +66,70 @@ class WakeScheduler:
def list_jobs(self) -> list[dict[str, Any]]: def list_jobs(self) -> list[dict[str, Any]]:
return self.memory.list_jobs() return self.memory.list_jobs()
def schedule_plan(self, plan: dict[str, Any]) -> dict[str, Any]:
if self.plan_runner is None or plan.get("status") != "active":
return plan
job_id = self._plan_job_id(plan["id"])
previous_next_run = plan.get("next_run_at")
trigger = CronTrigger.from_crontab(plan.get("cadence") or "0 */6 * * *")
self.scheduler.add_job(self._run_plan, trigger=trigger, id=job_id, args=[plan["id"]], replace_existing=True)
job = self.scheduler.get_job(job_id)
next_run = job.next_run_time if job else None
self.plan_runner.store.update_schedule(plan["id"], next_run.isoformat() if next_run else None)
if self._plan_is_overdue(previous_next_run):
catchup_id = self._plan_catchup_job_id(plan["id"])
self.scheduler.add_job(
self._run_plan,
trigger=DateTrigger(run_date=datetime.now() + timedelta(seconds=5)),
id=catchup_id,
args=[plan["id"]],
replace_existing=True,
)
self.plan_runner.store.add_event(
plan["id"],
"catchup_scheduled",
"Plan was overdue while the app was closed, so a one-time catch-up run was scheduled after startup.",
{"previous_next_run_at": previous_next_run},
)
return self.plan_runner.store.get_plan(plan["id"]) or plan
def unschedule_plan(self, plan_id: str) -> None:
job_id = self._plan_job_id(plan_id)
if self.scheduler.get_job(job_id):
self.scheduler.remove_job(job_id)
catchup_id = self._plan_catchup_job_id(plan_id)
if self.scheduler.get_job(catchup_id):
self.scheduler.remove_job(catchup_id)
if self.plan_runner is not None:
self.plan_runner.store.update_schedule(plan_id, None)
async def _run_plan(self, plan_id: str) -> None:
if self.plan_runner is None:
return
result = await self.plan_runner.run_plan(plan_id)
plan = result.get("plan") or self.plan_runner.store.get_plan(plan_id)
if plan and plan.get("status") == "active":
job = self.scheduler.get_job(self._plan_job_id(plan_id))
next_run = job.next_run_time if job else None
self.plan_runner.store.update_schedule(plan_id, next_run.isoformat() if next_run else None)
@staticmethod
def _plan_job_id(plan_id: str) -> str:
return f"continual-{plan_id}"
@staticmethod
def _plan_catchup_job_id(plan_id: str) -> str:
return f"continual-catchup-{plan_id}"
@staticmethod
def _plan_is_overdue(next_run_at: str | None) -> bool:
if not next_run_at:
return False
try:
return parse_iso(next_run_at) <= utc_now()
except ValueError:
return False
def _schedule_existing(self, job: dict[str, Any]) -> None: def _schedule_existing(self, job: dict[str, Any]) -> None:
if job["trigger_type"] == "cron": if job["trigger_type"] == "cron":
trigger = CronTrigger.from_crontab(job["trigger_value"]) trigger = CronTrigger.from_crontab(job["trigger_value"])
@@ -83,11 +154,20 @@ class WakeScheduler:
) )
if self.agent is None: if self.agent is None:
self.memory.add_outbox(wake_message) self.memory.add_outbox(wake_message)
self._mark_job_finished(job_id)
return return
try:
text = await self.agent.generate_wake_response(wake_message) 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.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: def _schedule_notification_poll(self) -> None:
if self.uex is None: if self.uex is None:
@@ -104,7 +184,12 @@ class WakeScheduler:
if self.uex is None: if self.uex is None:
return [] return []
try:
response = await self.uex.get_user_notifications() 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 [] notifications = response.get("notifications") or []
pending = [item for item in notifications if not item.get("date_read")] pending = [item for item in notifications if not item.get("date_read")]
profile = self.memory.get_profile() 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
+179 -4
View File
@@ -21,8 +21,11 @@ from pydantic import BaseModel
from traderai.agent import OllamaAgent, OllamaUnavailable from traderai.agent import OllamaAgent, OllamaUnavailable
from traderai.config import save_settings, settings_payload from traderai.config import save_settings, settings_payload
from traderai.config import get_settings 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.plans import ContinualPlanRunner, ContinualPlanStore
from traderai.scheduler import WakeScheduler from traderai.scheduler import WakeScheduler
from traderai.scmdb_client import SCMDBClient
from traderai.tools import ToolRegistry from traderai.tools import ToolRegistry
from traderai.uex_client import UEXClient from traderai.uex_client import UEXClient
from traderai.version import RELEASES_API_URL, RELEASES_URL, __version__ from traderai.version import RELEASES_API_URL, RELEASES_URL, __version__
@@ -35,6 +38,19 @@ def resource_path(*parts: str) -> Path:
class ChatRequest(BaseModel): class ChatRequest(BaseModel):
message: str 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): class ClearMemoryRequest(BaseModel):
@@ -45,6 +61,27 @@ class ClearMemoryRequest(BaseModel):
include_outbox: bool = True include_outbox: bool = True
class ContinualPlanItemRequest(BaseModel):
item_name: str
desired_quantity: int = 1
max_unit_price: float | None = None
class ContinualPlanCreateRequest(BaseModel):
title: str
objective: str
kind: str = "buying"
cadence: str | None = None
constraints: dict[str, Any] = {}
items: list[ContinualPlanItemRequest] = []
class ContinualPlanEventRequest(BaseModel):
kind: str = "note"
message: str
metadata: dict[str, Any] = {}
class ConfigUpdateRequest(BaseModel): class ConfigUpdateRequest(BaseModel):
values: dict values: dict
@@ -60,9 +97,22 @@ UPDATE_ASSET_NAME = "TraderAI.exe"
def create_app() -> FastAPI: def create_app() -> FastAPI:
settings = get_settings() settings = get_settings()
memory = MemoryStore(settings.traderai_memory_path) memory = MemoryStore(settings.traderai_memory_path)
plan_store = ContinualPlanStore(memory)
scheduler = WakeScheduler(memory) scheduler = WakeScheduler(memory)
uex = UEXClient(settings.uex_base_url, settings.uex_secret_key, settings.uex_bearer_token) 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,
plan_store=plan_store,
)
plan_runner = ContinualPlanRunner(plan_store, tools, memory)
tools.plan_runner = plan_runner
agent = OllamaAgent( agent = OllamaAgent(
settings.ollama_base_url, settings.ollama_base_url,
settings.ollama_model, settings.ollama_model,
@@ -71,7 +121,9 @@ def create_app() -> FastAPI:
user_name=settings.traderai_user_name, user_name=settings.traderai_user_name,
num_ctx=settings.ollama_num_ctx, num_ctx=settings.ollama_num_ctx,
) )
plan_runner.bind_agent(agent)
scheduler.bind_agent(agent) scheduler.bind_agent(agent)
scheduler.bind_plan_runner(plan_runner)
scheduler.bind_uex_notifications(uex, settings.uex_notification_poll_seconds) scheduler.bind_uex_notifications(uex, settings.uex_notification_poll_seconds)
app = FastAPI(title="TraderAI") app = FastAPI(title="TraderAI")
@@ -246,18 +298,43 @@ def create_app() -> FastAPI:
@app.post("/api/chat") @app.post("/api/chat")
async def chat(request: ChatRequest) -> dict: async def chat(request: ChatRequest) -> dict:
try: try:
return await agent.chat(request.message) return await agent.chat(request.message, thread_id=request.thread_id)
except OllamaUnavailable as exc: except OllamaUnavailable as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc raise HTTPException(status_code=503, detail=str(exc)) from exc
@app.post("/api/chat/stream") @app.post("/api/chat/stream")
async def chat_stream(request: ChatRequest) -> StreamingResponse: async def chat_stream(request: ChatRequest) -> StreamingResponse:
async def events(): 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" yield f"data: {json.dumps(event)}\n\n"
return StreamingResponse(events(), media_type="text/event-stream") 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") @app.get("/api/pending-actions")
async def pending_actions() -> dict: async def pending_actions() -> dict:
return {"pending_actions": agent._pending_payloads()} return {"pending_actions": agent._pending_payloads()}
@@ -266,10 +343,99 @@ def create_app() -> FastAPI:
async def notifications() -> dict: async def notifications() -> dict:
return {"notifications": memory.undelivered_outbox()} 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") @app.get("/api/wake-jobs")
async def wake_jobs() -> dict: async def wake_jobs() -> dict:
return {"scheduled_jobs": scheduler.list_jobs()} return {"scheduled_jobs": scheduler.list_jobs()}
@app.get("/api/plans")
async def continual_plans(include_inactive: bool = True) -> dict:
return {"plans": plan_store.list_plans(include_inactive=include_inactive)}
@app.post("/api/plans")
async def create_continual_plan(request: ContinualPlanCreateRequest) -> dict:
result = await tools.create_continual_plan(
title=request.title,
objective=request.objective,
kind=request.kind,
items=[item.model_dump() for item in request.items],
constraints=request.constraints,
cadence=request.cadence,
)
if result.get("error"):
raise HTTPException(status_code=400, detail=result["error"])
return result
@app.get("/api/plans/{plan_id}")
async def continual_plan(plan_id: str) -> dict:
plan = plan_store.get_plan(plan_id)
if not plan:
raise HTTPException(status_code=404, detail="Plan not found.")
return {"plan": plan}
@app.post("/api/plans/{plan_id}/pause")
async def pause_continual_plan(plan_id: str) -> dict:
result = await tools.pause_continual_plan(plan_id)
if result.get("error"):
raise HTTPException(status_code=404, detail=result["error"])
return result
@app.post("/api/plans/{plan_id}/resume")
async def resume_continual_plan(plan_id: str) -> dict:
result = await tools.resume_continual_plan(plan_id)
if result.get("error"):
raise HTTPException(status_code=404, detail=result["error"])
return result
@app.post("/api/plans/{plan_id}/cancel")
async def cancel_continual_plan(plan_id: str) -> dict:
result = await tools.cancel_continual_plan(plan_id)
if result.get("error"):
raise HTTPException(status_code=404, detail=result["error"])
return result
@app.post("/api/plans/{plan_id}/run")
async def run_continual_plan(plan_id: str) -> dict:
result = await tools.run_continual_plan_now(plan_id)
if result.get("error"):
raise HTTPException(status_code=400, detail=result["error"])
return result
@app.post("/api/plans/{plan_id}/events")
async def add_continual_plan_event(plan_id: str, request: ContinualPlanEventRequest) -> dict:
if not plan_store.get_plan(plan_id):
raise HTTPException(status_code=404, detail="Plan not found.")
event = plan_store.add_event(plan_id, request.kind, request.message, request.metadata)
return {"event": event, "plan": plan_store.get_plan(plan_id)}
@app.get("/api/memory") @app.get("/api/memory")
async def inspect_memory(limit: int = 50) -> dict: async def inspect_memory(limit: int = 50) -> dict:
return memory.inspect(max(1, min(limit, 200))) return memory.inspect(max(1, min(limit, 200)))
@@ -300,6 +466,15 @@ def create_app() -> FastAPI:
return app 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]: async def inspect_ollama() -> dict[str, Any]:
settings = get_settings() settings = get_settings()
executable = find_ollama_executable() executable = find_ollama_executable()
+1024 -13
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -1,9 +1,13 @@
from __future__ import annotations from __future__ import annotations
__version__ = "0.0.1" __version__ = "0.0.5"
RELEASES_URL = "https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases" RELEASES_URL = "https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases"
RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingConglomerate/TraderAI/releases" RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingConglomerate/TraderAI/releases"
Generated
+5 -1
View File
@@ -755,7 +755,7 @@ wheels = [
[[package]] [[package]]
name = "traderai" name = "traderai"
version = "0.0.1" version = "0.0.5"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "apscheduler" }, { name = "apscheduler" },
@@ -1047,3 +1047,7 @@ wheels = [
+627 -23
View File
@@ -13,9 +13,11 @@ const configStatusEl = document.getElementById("config-status");
const configPathsEl = document.getElementById("config-paths"); const configPathsEl = document.getElementById("config-paths");
const settingsToggle = document.getElementById("settings-toggle"); const settingsToggle = document.getElementById("settings-toggle");
const memoryToggle = document.getElementById("memory-toggle"); const memoryToggle = document.getElementById("memory-toggle");
const plansToggle = document.getElementById("plans-toggle");
const ollamaToggle = document.getElementById("ollama-toggle"); const ollamaToggle = document.getElementById("ollama-toggle");
const settingsPanel = document.getElementById("settings-panel"); const settingsPanel = document.getElementById("settings-panel");
const memoryPanel = document.getElementById("memory-panel"); const memoryPanel = document.getElementById("memory-panel");
const plansPanel = document.getElementById("plans-panel");
const ollamaPanel = document.getElementById("ollama-panel"); const ollamaPanel = document.getElementById("ollama-panel");
const ollamaForm = document.getElementById("ollama-config-form"); const ollamaForm = document.getElementById("ollama-config-form");
const ollamaRefreshButton = document.getElementById("ollama-refresh"); const ollamaRefreshButton = document.getElementById("ollama-refresh");
@@ -29,9 +31,37 @@ const updateCheckButton = document.getElementById("update-check");
const updateInstallButton = document.getElementById("update-install"); const updateInstallButton = document.getElementById("update-install");
const updateOpenReleasesButton = document.getElementById("update-open-releases"); const updateOpenReleasesButton = document.getElementById("update-open-releases");
const updateStatusEl = document.getElementById("update-status"); 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");
const plansRefreshButton = document.getElementById("plans-refresh");
const plansCloseButton = document.getElementById("plans-close");
const planForm = document.getElementById("plan-form");
const plansStatusEl = document.getElementById("plans-status");
const plansDashboardEl = document.getElementById("plans-dashboard");
const plansRailListEl = document.getElementById("plans-rail-list");
let ollamaOnline = true; let ollamaOnline = true;
let latestUpdate = null; let latestUpdate = null;
let currentThreadId = "default";
let currentNegotiationId = null;
let latestOllamaStatus = null;
const clickedOllamaActions = new Set();
if (window.lucide) { if (window.lucide) {
window.lucide.createIcons(); window.lucide.createIcons();
@@ -49,6 +79,7 @@ function addMessage(role, text) {
function setMessageMarkdown(node, text) { function setMessageMarkdown(node, text) {
const body = node.querySelector(".message-body") || node; const body = node.querySelector(".message-body") || node;
body.innerHTML = renderMarkdown(text); body.innerHTML = renderMarkdown(text);
enhanceNegotiationLinks(body);
} }
function setMessageActivity(node, text, active = false) { function setMessageActivity(node, text, active = false) {
@@ -342,6 +373,43 @@ function inlineMarkdown(text) {
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>'); .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) { function isTableRow(line) {
const trimmed = line.trim(); const trimmed = line.trim();
return trimmed.includes("|") && /^\|?.+\|.+\|?$/.test(trimmed); return trimmed.includes("|") && /^\|?.+\|.+\|?$/.test(trimmed);
@@ -416,9 +484,6 @@ function fetchErrorMessage(error) {
} }
const configFieldIds = { const configFieldIds = {
ollama_base_url: "config-ollama-base-url",
ollama_model: "config-ollama-model",
ollama_num_ctx: "config-ollama-num-ctx",
uex_base_url: "config-uex-base-url", uex_base_url: "config-uex-base-url",
uex_secret_key: "config-uex-secret-key", uex_secret_key: "config-uex-secret-key",
uex_bearer_token: "config-uex-bearer-token", uex_bearer_token: "config-uex-bearer-token",
@@ -446,11 +511,15 @@ async function refreshConfig() {
function renderConfig(config) { function renderConfig(config) {
const values = config.values || {}; const values = config.values || {};
const secretsConfigured = config.secrets_configured || {};
for (const [key, id] of Object.entries(configFieldIds)) { for (const [key, id] of Object.entries(configFieldIds)) {
const field = document.getElementById(id); const field = document.getElementById(id);
if (!field) continue; if (!field) continue;
if (field.type === "checkbox") { if (field.type === "checkbox") {
field.checked = Boolean(values[key]); field.checked = Boolean(values[key]);
} else if (field.type === "password") {
field.value = "";
field.placeholder = secretsConfigured[key] ? "Configured" : "";
} else { } else {
field.value = values[key] ?? ""; field.value = values[key] ?? "";
} }
@@ -526,6 +595,7 @@ async function refreshOllamaStatus() {
function renderOllamaStatus(status) { function renderOllamaStatus(status) {
if (!ollamaStatusEl) return; if (!ollamaStatusEl) return;
latestOllamaStatus = status;
const models = status.models?.length ? status.models.join(", ") : "None detected"; const models = status.models?.length ? status.models.join(", ") : "None detected";
const pillClass = status.installed && status.running && status.model_available ? "status-pill" : "status-pill warning"; const pillClass = status.installed && status.running && status.model_available ? "status-pill" : "status-pill warning";
ollamaStatusEl.innerHTML = ` ollamaStatusEl.innerHTML = `
@@ -536,14 +606,18 @@ function renderOllamaStatus(status) {
${ollamaStatusItem("Model", status.configured_model || "")} ${ollamaStatusItem("Model", status.configured_model || "")}
${ollamaStatusItem("Pulled", status.model_available ? "Yes" : "No")} ${ollamaStatusItem("Pulled", status.model_available ? "Yes" : "No")}
${ollamaStatusItem("URL", status.base_url || "")} ${ollamaStatusItem("URL", status.base_url || "")}
${ollamaStatusItem("Auto Install", status.can_auto_install ? "Available" : "Unavailable")} ${status.can_auto_install ? ollamaStatusItem("Auto Install", "Available") : ""}
</div> </div>
${ollamaStatusItem("Installed Models", models)} ${ollamaStatusItem("Installed Models", models)}
${status.detail ? ollamaStatusItem("Detail", status.detail) : ""} ${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 (ollamaLaunchButton) ollamaLaunchButton.disabled = !status.installed || Boolean(status.running);
if (ollamaPullButton) ollamaPullButton.disabled = !status.running || Boolean(status.model_available); if (ollamaPullButton) ollamaPullButton.disabled = !status.running || Boolean(status.model_available);
updateOllamaAttention(status);
} }
function ollamaStatusItem(label, value) { function ollamaStatusItem(label, value) {
@@ -571,11 +645,34 @@ async function postOllamaAction(endpoint, options = {}) {
} }
} }
function configuredOllamaModel() { function markOllamaActionClicked(action) {
return document.getElementById("ollama-model")?.value || document.getElementById("config-ollama-model")?.value || ""; if (action) clickedOllamaActions.add(action);
updateOllamaAttention();
} }
async function checkForUpdate() { 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(promptUser = false) {
if (!updateStatusEl) return; if (!updateStatusEl) return;
updateStatusEl.textContent = "Checking releases"; updateStatusEl.textContent = "Checking releases";
try { try {
@@ -583,6 +680,7 @@ async function checkForUpdate() {
const result = await response.json(); const result = await response.json();
latestUpdate = result; latestUpdate = result;
renderUpdateStatus(result); renderUpdateStatus(result);
if (promptUser) maybeShowUpdatePrompt(result);
} catch (error) { } catch (error) {
updateStatusEl.textContent = `Update check failed: ${error.message}`; updateStatusEl.textContent = `Update check failed: ${error.message}`;
if (updateInstallButton) updateInstallButton.disabled = true; if (updateInstallButton) updateInstallButton.disabled = true;
@@ -626,6 +724,22 @@ function openReleasesPage() {
window.open(url, "_blank", "noreferrer"); 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) { function toggleSidebarPanel(panelName) {
const panels = { const panels = {
settings: { panel: settingsPanel, button: settingsToggle }, settings: { panel: settingsPanel, button: settingsToggle },
@@ -657,6 +771,464 @@ 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 openPlansPanel(openPlanId = null) {
if (!plansPanel) return;
plansPanel.hidden = false;
plansToggle?.setAttribute("aria-expanded", "true");
refreshPlans(openPlanId);
}
function closePlansPanel() {
if (!plansPanel) return;
plansPanel.hidden = true;
plansToggle?.setAttribute("aria-expanded", "false");
}
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)}`;
}
}
function parsePlanItems(text) {
return text
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [name, quantity, maxPrice] = line.split("|").map((part) => part.trim());
const item = { item_name: name };
if (quantity) item.desired_quantity = Math.max(1, Number.parseInt(quantity, 10) || 1);
if (maxPrice) item.max_unit_price = Number(maxPrice.replace(/,/g, ""));
return item;
});
}
async function createPlan(event) {
event.preventDefault();
const title = document.getElementById("plan-title").value.trim();
const objective = document.getElementById("plan-objective").value.trim();
if (!title || !objective) return;
const tone = document.getElementById("plan-tone").value.trim();
const instructions = document.getElementById("plan-instructions").value.trim();
const constraints = {};
if (tone) constraints.message_tone = tone;
if (instructions) constraints.instructions = instructions;
const payload = {
title,
objective,
kind: document.getElementById("plan-kind").value || "buying",
cadence: document.getElementById("plan-cadence").value.trim() || null,
constraints,
items: parsePlanItems(document.getElementById("plan-items").value || ""),
};
plansStatusEl.textContent = "Creating plan";
try {
const response = await fetch("/api/plans", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
planForm.reset();
plansStatusEl.textContent = result.plan?.status === "needs_input"
? "Plan created, but it needs an item checklist."
: "Plan created";
await refreshPlans(result.plan?.id);
} catch (error) {
plansStatusEl.textContent = `Plan create failed: ${fetchErrorMessage(error)}`;
}
}
async function refreshPlans(openPlanId = null) {
if (!plansDashboardEl && !plansRailListEl) return;
try {
const response = await fetch("/api/plans");
const result = await response.json();
const plans = result.plans || [];
renderPlansRail(plans);
if (plansDashboardEl) await renderPlans(plans, openPlanId);
} catch (error) {
if (plansDashboardEl) plansDashboardEl.textContent = `Plans failed: ${fetchErrorMessage(error)}`;
if (plansRailListEl) plansRailListEl.textContent = `Plans failed: ${fetchErrorMessage(error)}`;
}
}
function renderPlansRail(plans) {
if (!plansRailListEl) return;
plansRailListEl.innerHTML = "";
if (!plans.length) {
plansRailListEl.innerHTML = '<div class="pending-empty">No plans</div>';
return;
}
for (const plan of plans.slice(0, 5)) {
const row = document.createElement("button");
row.type = "button";
row.className = "plan-rail-item";
const title = document.createElement("span");
title.className = "plan-rail-title";
title.textContent = plan.title || "Untitled plan";
const status = document.createElement("span");
status.className = "plan-rail-status";
status.textContent = plan.status || "plan";
row.append(title, status);
row.addEventListener("click", () => openPlansPanel(plan.id));
plansRailListEl.appendChild(row);
}
if (plans.length > 5) {
const more = document.createElement("button");
more.type = "button";
more.className = "plan-rail-item";
more.textContent = `${plans.length - 5} more`;
more.addEventListener("click", () => openPlansPanel());
plansRailListEl.appendChild(more);
}
}
async function renderPlans(plans, openPlanId = null) {
plansDashboardEl.innerHTML = "";
if (!plans.length) {
plansDashboardEl.innerHTML = '<div class="pending-empty">No continual plans</div>';
return;
}
for (const plan of plans) {
const card = document.createElement("article");
card.className = `plan-card${plan.status === "active" ? " active" : ""}`;
const title = document.createElement("h3");
title.textContent = plan.title || "Untitled plan";
const meta = document.createElement("div");
meta.className = "plan-meta";
meta.textContent = plan.objective || "";
const pills = document.createElement("div");
pills.className = "plan-pill-row";
for (const value of [plan.status, plan.kind, plan.next_run_at ? `next ${formatShortDate(plan.next_run_at)}` : "not scheduled"]) {
const pill = document.createElement("span");
pill.className = "plan-pill";
pill.textContent = value;
pills.appendChild(pill);
}
const controls = document.createElement("div");
controls.className = "plan-controls";
controls.append(
planButton("Details", () => loadPlanDetail(plan.id, card)),
planButton("Run", () => postPlanAction(plan.id, "run")),
planButton(plan.status === "active" ? "Pause" : "Resume", () => postPlanAction(plan.id, plan.status === "active" ? "pause" : "resume")),
planButton("Cancel", () => postPlanAction(plan.id, "cancel"), "secondary small-button")
);
card.append(title, meta, pills, controls);
plansDashboardEl.appendChild(card);
if (openPlanId && plan.id === openPlanId) await loadPlanDetail(plan.id, card);
}
}
function planButton(label, onClick, className = "small-button") {
const button = document.createElement("button");
button.type = "button";
button.className = className;
button.textContent = label;
button.addEventListener("click", onClick);
return button;
}
async function loadPlanDetail(planId, card) {
const existing = card.querySelector(".plan-detail");
if (existing) {
existing.remove();
return;
}
const response = await fetch(`/api/plans/${encodeURIComponent(planId)}`);
const result = await response.json();
const plan = result.plan;
const detail = document.createElement("div");
detail.className = "plan-detail";
detail.append(
planSection("Checklist", (plan.items || []).map((item) => `${item.item_name}: ${item.acquired_quantity || 0}/${item.desired_quantity || 1}${item.max_unit_price ? `, max ${Number(item.max_unit_price).toLocaleString()} UEC` : ""} (${item.status})`)),
planSection("Best Candidates", bestCandidateLines(plan)),
planSection("Recent Events", (plan.events || []).slice(0, 5).map((event) => `${formatShortDate(event.created_at)} ${event.kind}: ${event.message}`))
);
card.appendChild(detail);
}
function planSection(title, lines) {
const wrapper = document.createElement("section");
const heading = document.createElement("h4");
heading.textContent = title;
const list = document.createElement("ul");
list.className = "plan-list";
const items = lines.length ? lines : ["Empty"];
for (const line of items) {
const item = document.createElement("li");
item.textContent = line;
list.appendChild(item);
}
wrapper.append(heading, list);
return wrapper;
}
function bestCandidateLines(plan) {
const byItem = new Map((plan.items || []).map((item) => [item.id, item.item_name]));
return (plan.candidates || [])
.filter((candidate) => candidate.status === "current" || candidate.status === "drafted")
.slice(0, 6)
.map((candidate) => `${byItem.get(candidate.plan_item_id) || "Item"}: ${candidate.title || candidate.listing_slug || candidate.listing_id} at ${Number(candidate.price || 0).toLocaleString()} ${candidate.currency || "UEC"} from ${candidate.seller || "unknown"} (${candidate.status})`);
}
async function postPlanAction(planId, action) {
plansStatusEl.textContent = `${action} requested`;
try {
const response = await fetch(`/api/plans/${encodeURIComponent(planId)}/${action}`, { method: "POST" });
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
plansStatusEl.textContent = result.summary || `Plan ${action} complete`;
await refreshPlans(planId);
await refreshPending();
await refreshInbox();
} catch (error) {
plansStatusEl.textContent = `Plan ${action} failed: ${fetchErrorMessage(error)}`;
}
}
function formatShortDate(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
}
async function checkHealth() { async function checkHealth() {
try { try {
const response = await fetch("/api/health"); const response = await fetch("/api/health");
@@ -665,20 +1237,24 @@ async function checkHealth() {
ollamaOnline = Boolean(health.online); ollamaOnline = Boolean(health.online);
if (!ollamaOnline) { if (!ollamaOnline) {
statusEl.textContent = "Offline"; 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; return false;
} }
if (health.model_available === 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 { } else {
setWarning(""); setWarning("");
ollamaToggle?.classList.remove("attention-pulse");
} }
statusEl.textContent = "Ready"; statusEl.textContent = "Ready";
return true; return true;
} catch (error) { } catch (error) {
ollamaOnline = false; ollamaOnline = false;
statusEl.textContent = "Offline"; 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; return false;
} }
} }
@@ -832,9 +1408,7 @@ async function pollNotifications() {
try { try {
const response = await fetch("/api/notifications"); const response = await fetch("/api/notifications");
const result = await response.json(); const result = await response.json();
for (const notification of result.notifications || []) { if ((result.notifications || []).length) await refreshInbox();
addMessage("assistant", notification.content);
}
} catch { } catch {
// Notification polling should never interrupt chat. // Notification polling should never interrupt chat.
} }
@@ -858,23 +1432,49 @@ configRefreshButton?.addEventListener("click", refreshConfig);
configForm?.addEventListener("submit", saveConfig); configForm?.addEventListener("submit", saveConfig);
settingsToggle?.addEventListener("click", () => toggleSidebarPanel("settings")); settingsToggle?.addEventListener("click", () => toggleSidebarPanel("settings"));
memoryToggle?.addEventListener("click", () => toggleSidebarPanel("memory")); memoryToggle?.addEventListener("click", () => toggleSidebarPanel("memory"));
plansToggle?.addEventListener("click", () => {
if (plansPanel?.hidden) openPlansPanel();
else closePlansPanel();
});
ollamaToggle?.addEventListener("click", () => toggleSidebarPanel("ollama")); ollamaToggle?.addEventListener("click", () => toggleSidebarPanel("ollama"));
plansRefreshButton?.addEventListener("click", () => refreshPlans());
plansCloseButton?.addEventListener("click", closePlansPanel);
planForm?.addEventListener("submit", createPlan);
ollamaForm?.addEventListener("submit", saveOllamaConfig); ollamaForm?.addEventListener("submit", saveOllamaConfig);
ollamaRefreshButton?.addEventListener("click", refreshOllamaStatus); ollamaRefreshButton?.addEventListener("click", refreshOllamaStatus);
ollamaDownloadButton?.addEventListener("click", () => postOllamaAction("/api/ollama/download")); ollamaDownloadButton?.addEventListener("click", () => {
ollamaInstallButton?.addEventListener("click", () => postOllamaAction("/api/ollama/install")); markOllamaActionClicked("download");
ollamaLaunchButton?.addEventListener("click", () => postOllamaAction("/api/ollama/launch")); postOllamaAction("/api/ollama/download");
ollamaPullButton?.addEventListener("click", () => postOllamaAction("/api/ollama/pull", { body: { model: configuredOllamaModel() } })); });
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); updateCheckButton?.addEventListener("click", checkForUpdate);
updateInstallButton?.addEventListener("click", installUpdate); updateInstallButton?.addEventListener("click", installUpdate);
updateOpenReleasesButton?.addEventListener("click", openReleasesPage); 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() { async function sendMessage() {
const message = input.value.trim(); const message = input.value.trim();
if (!message || input.disabled) return; if (!message || input.disabled) return;
const healthy = await checkHealth(); const healthy = await checkHealth();
if (!healthy) { 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; return;
} }
input.value = ""; input.value = "";
@@ -891,7 +1491,7 @@ async function sendMessage() {
const response = await fetch("/api/chat/stream", { const response = await fetch("/api/chat/stream", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }), body: JSON.stringify({ message, thread_id: currentThreadId }),
}); });
if (!response.ok || !response.body) { if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status}`); throw new Error(`HTTP ${response.status}`);
@@ -925,6 +1525,7 @@ async function sendMessage() {
} }
messages.scrollTop = messages.scrollHeight; messages.scrollTop = messages.scrollHeight;
} else if (event.type === "done") { } else if (event.type === "done") {
if (event.thread_id) currentThreadId = event.thread_id;
const visibleContent = thinkParser.flush(); const visibleContent = thinkParser.flush();
if (visibleContent) { if (visibleContent) {
assistantText += visibleContent; assistantText += visibleContent;
@@ -936,7 +1537,7 @@ async function sendMessage() {
} }
} catch (error) { } catch (error) {
const message = error.message.includes("503") 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}`; : `Chat failed: ${error.message}`;
setWarning(message); setWarning(message);
setMessageMarkdown(assistantNode, message); setMessageMarkdown(assistantNode, message);
@@ -947,15 +1548,18 @@ async function sendMessage() {
statusEl.textContent = "Ready"; statusEl.textContent = "Ready";
finishThinking(assistantNode); finishThinking(assistantNode);
setMessageActivity(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(); refreshPending();
refreshMemory(); refreshMemory();
refreshPlans();
refreshConfig(); refreshConfig();
refreshOllamaStatus(); refreshOllamaStatus();
checkForUpdate(); refreshChats().then(() => loadChatMessages(currentThreadId));
refreshInbox();
checkForUpdate(true);
pollNotifications(); pollNotifications();
checkHealth(); checkHealth();
setInterval(checkHealth, 30000); setInterval(checkHealth, 30000);
+99 -3
View File
@@ -9,6 +9,37 @@
</head> </head>
<body> <body>
<main class="shell"> <main class="shell">
<nav class="chat-rail collapsed" id="chat-rail" aria-label="Chats, plans, 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-row">
<div class="rail-heading">Plans</div>
<button class="rail-icon-button" id="plans-toggle" type="button" title="Plans" aria-expanded="false" aria-controls="plans-panel">
<i data-lucide="list-checks" aria-hidden="true"></i>
</button>
</div>
<div class="plans-rail-list" id="plans-rail-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"> <section class="workspace">
<header class="topbar"> <header class="topbar">
<div class="brand-block"> <div class="brand-block">
@@ -20,6 +51,7 @@
<h1>TraderAI</h1> <h1>TraderAI</h1>
<p>Institutional marketplace intelligence for UEX operations</p> <p>Institutional marketplace intelligence for UEX operations</p>
</div> </div>
<span class="brand-short" aria-hidden="true">LBC</span>
</div> </div>
<div class="status" id="status">Ready</div> <div class="status" id="status">Ready</div>
</header> </header>
@@ -59,9 +91,6 @@
<button class="secondary small-button" id="config-refresh" type="button">Refresh</button> <button class="secondary small-button" id="config-refresh" type="button">Refresh</button>
</div> </div>
<form class="config-form" id="config-form"> <form class="config-form" id="config-form">
<label>Ollama URL<input id="config-ollama-base-url" name="ollama_base_url" type="text"></label>
<label>Ollama Model<input id="config-ollama-model" name="ollama_model" type="text"></label>
<label>Context Tokens<input id="config-ollama-num-ctx" name="ollama_num_ctx" type="number" min="1024" step="1024"></label>
<label>UEX API URL<input id="config-uex-base-url" name="uex_base_url" type="text"></label> <label>UEX API URL<input id="config-uex-base-url" name="uex_base_url" type="text"></label>
<label>UEX Secret Key<input id="config-uex-secret-key" name="uex_secret_key" type="password" autocomplete="off"></label> <label>UEX Secret Key<input id="config-uex-secret-key" name="uex_secret_key" type="password" autocomplete="off"></label>
<label>UEX Bearer Token<input id="config-uex-bearer-token" name="uex_bearer_token" type="password" autocomplete="off"></label> <label>UEX Bearer Token<input id="config-uex-bearer-token" name="uex_bearer_token" type="password" autocomplete="off"></label>
@@ -123,6 +152,73 @@
</section> </section>
</aside> </aside>
</main> </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="floating-panel plans-floating-panel" id="plans-panel" hidden>
<div class="floating-panel-header">
<div>
<p class="eyebrow">Continual work</p>
<h2>Plans</h2>
</div>
<div class="floating-panel-actions">
<button class="icon-button light" id="plans-refresh" type="button" title="Refresh plans">
<i data-lucide="refresh-cw" aria-hidden="true"></i>
</button>
<button class="icon-button light" id="plans-close" type="button" title="Close">
<i data-lucide="x" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="plans-panel-body">
<form class="config-form" id="plan-form">
<label>Title<input id="plan-title" type="text" placeholder="Wikelo Idris parts"></label>
<label>Objective<input id="plan-objective" type="text" placeholder="Find and draft deals for the parts I list"></label>
<label>Kind
<select id="plan-kind">
<option value="buying">Buying</option>
<option value="custom">Custom</option>
</select>
</label>
<label>Items<textarea id="plan-items" rows="4" placeholder="One item per line, optionally: name | quantity | max unit price"></textarea></label>
<label>Instructions<textarea id="plan-instructions" rows="3" placeholder="Extra guidance for custom or buying plans"></textarea></label>
<label>Cron Cadence<input id="plan-cadence" type="text" placeholder="0 */6 * * *"></label>
<label>Message Tone<input id="plan-tone" type="text" placeholder="polite and concise"></label>
<button type="submit">Create Plan</button>
<div class="config-status" id="plans-status"></div>
</form>
<div class="plans-dashboard" id="plans-dashboard"></div>
</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="https://unpkg.com/lucide@0.562.0/dist/umd/lucide.min.js"></script>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
</body> </body>
+670 -32
View File
@@ -22,6 +22,10 @@
box-sizing: border-box; box-sizing: border-box;
} }
[hidden] {
display: none !important;
}
html, html,
body { body {
height: 100%; height: 100%;
@@ -50,7 +54,7 @@ body::before {
.shell { .shell {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 380px; grid-template-columns: 64px minmax(0, 1fr) 380px;
gap: 24px; gap: 24px;
height: 100vh; height: 100vh;
min-height: 0; min-height: 0;
@@ -58,8 +62,13 @@ body::before {
padding: 24px; padding: 24px;
} }
.shell.chat-open {
grid-template-columns: 280px minmax(0, 1fr) 380px;
}
.workspace, .workspace,
.actions { .actions,
.chat-rail {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(240, 214, 129, 0.34); border: 1px solid rgba(240, 214, 129, 0.34);
@@ -69,12 +78,196 @@ body::before {
} }
.workspace { .workspace {
display: grid; display: flex;
grid-template-rows: auto auto 1fr auto; flex-direction: column;
height: 100%;
min-width: 0; min-width: 0;
min-height: 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(92px, 20%) minmax(130px, 30%);
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;
}
.rail-heading-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.rail-heading-row .rail-heading {
margin-bottom: 0;
}
.rail-icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
min-width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--line-strong);
border-radius: 8px;
background: #fff9e9;
color: var(--forest);
box-shadow: 0 8px 18px rgba(38, 58, 27, 0.08);
}
.rail-icon-button svg {
width: 15px;
height: 15px;
}
.chat-list,
.inbox-list,
.plans-rail-list {
display: grid;
gap: 8px;
max-height: calc(100% - 26px);
overflow: auto;
}
.chat-item,
.inbox-item,
.plan-rail-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;
}
.plan-rail-item {
grid-template-columns: minmax(0, 1fr) auto;
width: 100%;
min-width: 0;
border: 1px solid var(--line);
background: rgba(255, 250, 240, 0.78);
color: var(--brown);
font-family: Inter, "Segoe UI", Arial, sans-serif;
text-align: left;
box-shadow: none;
cursor: pointer;
}
.plan-rail-item:hover {
background: #edf3df;
color: var(--brown);
box-shadow: none;
}
.chat-item.active {
border-color: rgba(52, 83, 38, 0.42);
background: #edf3df;
}
.chat-title,
.inbox-title,
.plan-rail-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;
}
.plan-rail-title {
white-space: nowrap;
}
.plan-rail-status {
min-width: 0;
padding: 3px 6px;
border: 1px solid rgba(52, 83, 38, 0.2);
border-radius: 999px;
background: #edf3df;
color: var(--forest);
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
}
.actions { .actions {
padding: 28px; padding: 28px;
overflow: auto; overflow: auto;
@@ -107,6 +300,10 @@ body::before {
min-width: 0; min-width: 0;
} }
.brand-short {
display: none;
}
.logo-wrap { .logo-wrap {
position: relative; position: relative;
display: grid; display: grid;
@@ -203,6 +400,7 @@ h2 {
} }
.messages { .messages {
flex: 1 1 auto;
overflow: auto; overflow: auto;
min-height: 0; min-height: 0;
padding: 28px 28px 18px; padding: 28px 28px 18px;
@@ -212,13 +410,15 @@ h2 {
} }
.message { .message {
max-width: 920px; width: fit-content;
max-width: min(920px, 88%);
margin-bottom: 16px; margin-bottom: 16px;
padding: 17px 18px; padding: 17px 18px;
border: 1px solid rgba(221, 206, 176, 0.9); border: 1px solid rgba(221, 206, 176, 0.9);
border-radius: 18px; border-radius: 18px;
color: var(--brown); color: var(--brown);
line-height: 1.55; line-height: 1.55;
overflow-wrap: anywhere;
box-shadow: 0 16px 38px rgba(38, 58, 27, 0.11); box-shadow: 0 16px 38px rgba(38, 58, 27, 0.11);
} }
@@ -346,6 +546,7 @@ h2 {
.message.user { .message.user {
margin-left: auto; margin-left: auto;
max-width: min(720px, 76%);
border-color: rgba(52, 83, 38, 0.28); border-color: rgba(52, 83, 38, 0.28);
background: linear-gradient(180deg, #edf3df, #e5efd4); background: linear-gradient(180deg, #edf3df, #e5efd4);
} }
@@ -360,7 +561,9 @@ h2 {
} }
.composer-wrap { .composer-wrap {
position: sticky; position: relative;
flex: 0 0 auto;
margin-top: auto;
bottom: 0; bottom: 0;
z-index: 5; z-index: 5;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
@@ -549,7 +752,8 @@ textarea:disabled {
input[type="text"], input[type="text"],
input[type="password"], input[type="password"],
input[type="number"] { input[type="number"],
select {
width: 100%; width: 100%;
min-height: 38px; min-height: 38px;
padding: 9px 11px; padding: 9px 11px;
@@ -565,7 +769,8 @@ input[type="number"] {
input[type="text"]:focus, input[type="text"]:focus,
input[type="password"]:focus, input[type="password"]:focus,
input[type="number"]:focus { input[type="number"]:focus,
select:focus {
border-color: var(--gold); border-color: var(--gold);
box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.18); box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.18);
} }
@@ -582,6 +787,182 @@ button {
transition: transform 180ms ease, box-shadow 180ms ease, background 180ms ease; 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;
}
.floating-panel-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.plans-floating-panel {
grid-template-rows: auto minmax(0, 1fr);
width: min(680px, calc(100vw - 28px));
}
.plans-panel-body {
display: grid;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
gap: 16px;
min-height: 0;
padding: 16px;
overflow: auto;
}
button:hover { button:hover {
background: linear-gradient(180deg, #3d612c, #263e1b); 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); box-shadow: 0 18px 34px rgba(31, 52, 22, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.16);
@@ -638,38 +1019,83 @@ button.secondary {
} }
.sidebar-tool-buttons { .sidebar-tool-buttons {
display: grid; display: flex;
grid-template-columns: repeat(3, minmax(0, 1fr)); flex-wrap: nowrap;
gap: 10px; justify-content: flex-end;
width: 100%;
min-width: 0;
gap: 8px;
} }
.sidebar-tool-button { .sidebar-tool-button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; flex: 0 1 42px;
min-width: 0; gap: 0;
min-height: 46px; width: 42px;
padding: 10px 12px; min-width: 36px;
min-height: 42px;
padding: 9px;
overflow: hidden;
border: 1px solid var(--line-strong); border: 1px solid var(--line-strong);
border-radius: 14px; border-radius: 12px;
background: #fff9e9; background: #fff9e9;
color: var(--forest); color: var(--forest);
font-family: Inter, "Segoe UI", Arial, sans-serif; font-family: Inter, "Segoe UI", Arial, sans-serif;
font-size: 13px; font-size: 12px;
font-weight: 800; font-weight: 800;
white-space: nowrap;
box-shadow: 0 10px 22px rgba(38, 58, 27, 0.08); box-shadow: 0 10px 22px rgba(38, 58, 27, 0.08);
transition:
flex-basis 180ms ease,
width 180ms ease,
gap 180ms ease,
padding 180ms ease,
border-color 180ms ease,
background 180ms ease,
color 180ms ease,
box-shadow 180ms ease,
transform 180ms ease;
}
.sidebar-tool-button:hover,
.sidebar-tool-button:focus-visible {
flex-basis: 108px;
width: 108px;
gap: 7px;
padding-inline: 12px;
border-color: rgba(212, 175, 55, 0.72);
background: linear-gradient(180deg, #3d612c, #263e1b);
color: var(--ivory);
}
.sidebar-tool-button span {
max-width: 0;
overflow: hidden;
opacity: 0;
transition:
max-width 180ms ease,
opacity 140ms ease;
}
.sidebar-tool-button:hover span,
.sidebar-tool-button:focus-visible span {
max-width: 70px;
opacity: 1;
} }
.sidebar-tool-button svg { .sidebar-tool-button svg {
width: 18px; width: 18px;
height: 18px; height: 18px;
flex: 0 0 18px;
stroke-width: 2.3; stroke-width: 2.3;
} }
.sidebar-tool-image { .sidebar-tool-image {
width: 18px; width: 18px;
height: 18px; height: 18px;
flex: 0 0 18px;
object-fit: contain; object-fit: contain;
} }
@@ -793,6 +1219,31 @@ button.secondary {
width: 100%; 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 { .update-box {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -935,6 +1386,98 @@ pre {
padding: 11px; padding: 11px;
} }
.plans-dashboard {
display: grid;
gap: 12px;
margin-top: 14px;
}
.plan-card {
display: grid;
gap: 10px;
padding: 13px;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255, 250, 240, 0.82);
}
.plan-card.active {
border-color: rgba(52, 83, 38, 0.42);
background: #edf3df;
}
.plan-card h3 {
margin: 0;
color: var(--forest);
font-size: 16px;
line-height: 1.25;
}
.plan-meta,
.plan-line {
color: var(--muted);
font-size: 12px;
line-height: 1.4;
overflow-wrap: anywhere;
}
.plan-pill-row,
.plan-controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.plan-pill {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 4px 8px;
border: 1px solid rgba(52, 83, 38, 0.24);
border-radius: 999px;
background: rgba(255, 250, 240, 0.8);
color: var(--forest);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.plan-controls button {
flex: 1 1 80px;
min-width: 0;
}
.plan-detail {
display: grid;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--line);
}
.plan-detail h4 {
margin: 0;
color: var(--forest);
font-size: 13px;
}
.plan-list {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.plan-list li {
padding: 8px;
border: 1px solid rgba(221, 206, 176, 0.72);
border-radius: 8px;
background: rgba(255, 253, 247, 0.72);
color: var(--brown);
font-size: 12px;
line-height: 1.4;
}
.decline-button { .decline-button {
border: 1px solid var(--line-strong); border: 1px solid var(--line-strong);
background: #fff9e9; background: #fff9e9;
@@ -950,13 +1493,34 @@ pre {
@media (max-width: 960px) { @media (max-width: 960px) {
.shell { .shell {
grid-template-columns: 1fr; 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;
} }
} }
@media (max-width: 620px) { @media (max-width: 620px) {
body {
background: var(--cream);
}
body::before {
display: none;
}
.shell { .shell {
gap: 14px; gap: 14px;
grid-template-rows: minmax(0, 1fr) minmax(220px, 34vh);
padding: 10px; padding: 10px;
} }
@@ -965,35 +1529,100 @@ pre {
border-radius: 22px; border-radius: 22px;
} }
.chat-rail {
position: fixed;
inset: 10px auto auto 10px;
z-index: 10;
width: min(320px, calc(100vw - 20px));
height: calc(100vh - 20px);
max-height: calc(100vh - 20px);
}
.chat-rail.collapsed {
width: 48px;
height: 48px;
min-height: 48px;
max-height: 48px;
padding: 4px;
border: 0;
background: transparent;
box-shadow: none;
}
.chat-rail.collapsed .chat-rail-top {
display: block;
}
.chat-rail.collapsed #new-chat {
display: none;
}
.topbar { .topbar {
align-items: flex-start; display: flex;
grid-template-columns: 1fr; align-items: center;
padding: 22px; justify-content: center;
min-height: 68px;
padding: 10px 58px 10px 66px;
border-bottom-color: var(--line);
background: linear-gradient(180deg, var(--ivory) 0%, var(--cream) 100%);
} }
.brand-block { .brand-block {
align-items: flex-start; align-items: center;
justify-content: center;
gap: 9px;
min-width: 0;
} }
.logo-wrap { .logo-wrap {
width: 58px; width: 28px;
height: 58px; height: 28px;
flex-basis: 58px; flex: 0 0 28px;
border-radius: 18px; border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
color: var(--brown);
}
.logo-wrap::before {
content: "";
display: block;
width: 28px;
height: 28px;
background: currentColor;
-webkit-mask: url("/static/art/LBC_Logo.png") center / contain no-repeat;
mask: url("/static/art/LBC_Logo.png") center / contain no-repeat;
} }
.logo-wrap img { .logo-wrap img {
width: 45px; display: none;
height: 45px; }
.brand-copy {
display: contents;
}
.brand-copy p,
.status {
display: none;
} }
h1 { h1 {
font-size: 31px; color: var(--brown);
font-size: 22px;
line-height: 1;
text-shadow: none;
} }
.eyebrow { .brand-short {
font-size: 10px; display: inline-flex;
letter-spacing: 0.08em; align-items: center;
color: var(--brown);
font-family: "Playfair Display", Georgia, serif;
font-size: 18px;
font-weight: 800;
line-height: 1;
} }
.messages, .messages,
@@ -1015,6 +1644,11 @@ pre {
display: none; display: none;
} }
.message,
.message.user {
max-width: 100%;
}
.message-activity { .message-activity {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1022,4 +1656,8 @@ pre {
.message-phase { .message-phase {
grid-column: 1; grid-column: 1;
} }
.plans-panel-body {
grid-template-columns: 1fr;
}
} }