4 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
14 changed files with 2099 additions and 52 deletions
+3 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "traderai" name = "traderai"
version = "0.0.3" 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 = [
@@ -37,3 +37,5 @@ include = ["traderai*"]
+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"])
+63 -3
View File
@@ -230,7 +230,10 @@ class FakeCornerstone:
"url": f"{self.base_url}/ShipSalvageMods1/{item_id}", "url": f"{self.base_url}/ShipSalvageMods1/{item_id}",
"html": """ "html": """
<html> <html>
<head><title>Star Citizen - Salvage modifier - Abrade Scraper Module</title></head> <head>
<title>Star Citizen - Salvage modifier - Abrade Scraper Module</title>
<meta property="og:image" content="/images/abrade.png">
</head>
<body> <body>
<table> <table>
<tr><td>NAME</td><td>Abrade Scraper Module</td></tr> <tr><td>NAME</td><td>Abrade Scraper Module</td></tr>
@@ -246,6 +249,15 @@ class FakeCornerstone:
""", """,
} }
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():
@@ -379,6 +391,8 @@ def test_schemas_expose_cornerstone_item_tools():
assert "search_cornerstone_items" in names assert "search_cornerstone_items" in names
assert "get_cornerstone_item_locations" 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 @pytest.mark.asyncio
@@ -441,18 +455,64 @@ async def test_get_cornerstone_item_locations_parses_store_prices():
] ]
@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(): def test_parse_cornerstone_item_page_extracts_locations():
parsed = parse_cornerstone_item_page( parsed = parse_cornerstone_item_page(
""" """
<html><head><title>Star Citizen - Food - Whamburger</title></head> <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> <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> <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> <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["name"] == "Whamburger"
assert parsed["locations"][0]["base_price"] == 9 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 @pytest.mark.asyncio
+7 -1
View File
@@ -14,14 +14,17 @@ 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. 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 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. 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. 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."""
@@ -472,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()
] ]
@@ -521,6 +525,7 @@ class OllamaAgent:
"get_scmdb_mission_rewards": "Fetching SCMDB mission rewards", "get_scmdb_mission_rewards": "Fetching SCMDB mission rewards",
"search_cornerstone_items": "Searching Cornerstone items", "search_cornerstone_items": "Searching Cornerstone items",
"get_cornerstone_item_locations": "Fetching Cornerstone item locations", "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",
@@ -531,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}")
+68 -1
View File
@@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
from html.parser import HTMLParser from html.parser import HTMLParser
import base64
import json import json
from typing import Any from typing import Any
from urllib.parse import urljoin
import httpx import httpx
@@ -41,6 +43,23 @@ class CornerstoneClient:
raise CornerstoneError(f"Cornerstone HTTP {response.status_code}: {response.text[:240]}") raise CornerstoneError(f"Cornerstone HTTP {response.status_code}: {response.text[:240]}")
return {"url": str(response.url), "html": response.text} 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 def _get_json(self, path: str) -> Any:
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client: 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"}) response = await client.get(f"{self.base_url}/{path.lstrip('/')}", headers={"Accept": "application/json"})
@@ -58,6 +77,7 @@ class CornerstonePageParser(HTMLParser):
super().__init__(convert_charrefs=True) super().__init__(convert_charrefs=True)
self.title = "" self.title = ""
self.tables: list[list[list[str]]] = [] self.tables: list[list[list[str]]] = []
self.images: list[dict[str, str]] = []
self._skip_depth = 0 self._skip_depth = 0
self._in_title = False self._in_title = False
self._current_table: list[list[str]] | None = None self._current_table: list[list[str]] | None = None
@@ -73,6 +93,29 @@ class CornerstonePageParser(HTMLParser):
return return
if tag == "title": if tag == "title":
self._in_title = True 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": elif tag == "table":
self._current_table = [] self._current_table = []
elif tag == "tr" and self._current_table is not None: elif tag == "tr" and self._current_table is not None:
@@ -110,8 +153,12 @@ class CornerstonePageParser(HTMLParser):
if self._current_cell is not None: if self._current_cell is not None:
self._current_cell.append(data) 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) -> dict[str, Any]:
def parse_cornerstone_item_page(html: str, page_url: str | None = None) -> dict[str, Any]:
parser = CornerstonePageParser() parser = CornerstonePageParser()
parser.feed(html) parser.feed(html)
info: dict[str, Any] = {"page_title": " ".join(parser.title.split())} info: dict[str, Any] = {"page_title": " ".join(parser.title.split())}
@@ -142,6 +189,9 @@ def parse_cornerstone_item_page(html: str) -> dict[str, Any]:
general[key] = value general[key] = value
info["name"] = general.get("name") or _name_from_title(info["page_title"]) 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: if general:
info["general"] = general info["general"] = general
info["locations"] = locations info["locations"] = locations
@@ -157,3 +207,20 @@ def _name_from_title(title: str) -> str | None:
if " - " not in title: if " - " not in title:
return title or None return title or None
return title.rsplit(" - ", 1)[-1].strip() 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
+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)}"
)
+73 -2
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"])
+88
View File
@@ -23,6 +23,7 @@ from traderai.config import save_settings, settings_payload
from traderai.config import get_settings from traderai.config import get_settings
from traderai.cornerstone_client import CornerstoneClient from traderai.cornerstone_client import CornerstoneClient
from traderai.memory import DEFAULT_THREAD_ID, MemoryStore 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.scmdb_client import SCMDBClient
from traderai.tools import ToolRegistry from traderai.tools import ToolRegistry
@@ -60,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
@@ -75,6 +97,7 @@ 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)
scmdb = SCMDBClient(settings.scmdb_base_url) scmdb = SCMDBClient(settings.scmdb_base_url)
@@ -86,7 +109,10 @@ def create_app() -> FastAPI:
scheduler=scheduler, scheduler=scheduler,
scmdb=scmdb, scmdb=scmdb,
cornerstone=cornerstone, 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,
@@ -95,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")
@@ -348,6 +376,66 @@ def create_app() -> FastAPI:
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)))
+362 -10
View File
@@ -148,6 +148,7 @@ class PendingAction:
endpoint: str endpoint: str
payload: dict[str, Any] payload: dict[str, Any]
method: str = "POST" method: str = "POST"
metadata: dict[str, Any] | None = None
class ToolRegistry: class ToolRegistry:
@@ -159,6 +160,8 @@ class ToolRegistry:
scheduler: WakeScheduler | None = None, scheduler: WakeScheduler | None = None,
scmdb: SCMDBClient | None = None, scmdb: SCMDBClient | None = None,
cornerstone: CornerstoneClient | None = None, cornerstone: CornerstoneClient | None = None,
plan_store: Any | None = None,
plan_runner: Any | None = None,
) -> None: ) -> None:
self.uex = uex self.uex = uex
self.scmdb = scmdb or SCMDBClient() self.scmdb = scmdb or SCMDBClient()
@@ -166,6 +169,8 @@ class ToolRegistry:
self.require_write_approval = require_write_approval self.require_write_approval = require_write_approval
self.memory = memory self.memory = memory
self.scheduler = scheduler self.scheduler = scheduler
self.plan_store = plan_store
self.plan_runner = plan_runner
self.pending_actions: dict[str, PendingAction] = {} self.pending_actions: dict[str, PendingAction] = {}
self.handlers: dict[str, ToolHandler] = { self.handlers: dict[str, ToolHandler] = {
"search_marketplace_listings": self.search_marketplace_listings, "search_marketplace_listings": self.search_marketplace_listings,
@@ -178,12 +183,21 @@ class ToolRegistry:
"recall_memory": self.recall_memory, "recall_memory": self.recall_memory,
"schedule_wake_job": self.schedule_wake_job, "schedule_wake_job": self.schedule_wake_job,
"list_wake_jobs": self.list_wake_jobs, "list_wake_jobs": self.list_wake_jobs,
"create_continual_plan": self.create_continual_plan,
"list_continual_plans": self.list_continual_plans,
"get_continual_plan": self.get_continual_plan,
"pause_continual_plan": self.pause_continual_plan,
"resume_continual_plan": self.resume_continual_plan,
"cancel_continual_plan": self.cancel_continual_plan,
"run_continual_plan_now": self.run_continual_plan_now,
"check_uex_notifications": self.check_uex_notifications, "check_uex_notifications": self.check_uex_notifications,
"list_scmdb_versions": self.list_scmdb_versions, "list_scmdb_versions": self.list_scmdb_versions,
"search_scmdb_missions": self.search_scmdb_missions, "search_scmdb_missions": self.search_scmdb_missions,
"get_scmdb_mission_rewards": self.get_scmdb_mission_rewards, "get_scmdb_mission_rewards": self.get_scmdb_mission_rewards,
"search_cornerstone_items": self.search_cornerstone_items, "search_cornerstone_items": self.search_cornerstone_items,
"get_cornerstone_item_locations": self.get_cornerstone_item_locations, "get_cornerstone_item_locations": self.get_cornerstone_item_locations,
"get_cornerstone_item_media": self.get_cornerstone_item_media,
"draft_marketplace_listing_with_cornerstone_image": self.draft_marketplace_listing_with_cornerstone_image,
} }
self.handlers["uex_api_catalog"] = self.uex_api_catalog self.handlers["uex_api_catalog"] = self.uex_api_catalog
self.handlers["uex_get"] = self.uex_get self.handlers["uex_get"] = self.uex_get
@@ -285,6 +299,11 @@ class ToolRegistry:
"message": {"type": "string"}, "message": {"type": "string"},
"hash": {"type": "string"}, "hash": {"type": "string"},
"id_negotiation": {"type": "integer"}, "id_negotiation": {"type": "integer"},
"id_listing": {"type": "integer"},
"plan_id": {"type": "string"},
"plan_item_id": {"type": "integer"},
"candidate_id": {"type": "integer"},
"listing_slug": {"type": "string"},
"is_production": {"type": "integer", "enum": [0, 1], "default": 1}, "is_production": {"type": "integer", "enum": [0, 1], "default": 1},
}, },
}, },
@@ -294,7 +313,7 @@ class ToolRegistry:
"type": "function", "type": "function",
"function": { "function": {
"name": "draft_marketplace_listing", "name": "draft_marketplace_listing",
"description": "Draft a new UEX marketplace listing. Listing prices are in-game aUEC/UEC credits, not real-world dollars. This creates a pending action that must be approved before posting.", "description": "Draft a new UEX marketplace listing. Listing prices are in-game aUEC/UEC credits, not real-world dollars. This creates a pending action that must be approved before posting. Prefer draft_marketplace_listing_with_cornerstone_image for item posts when a Cornerstone image is useful.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"required": ["id_category", "operation", "type", "unit", "title", "description", "price", "currency", "language"], "required": ["id_category", "operation", "type", "unit", "title", "description", "price", "currency", "language"],
@@ -315,8 +334,12 @@ class ToolRegistry:
"source": {"type": "string"}, "source": {"type": "string"},
"availability": {"type": "string"}, "availability": {"type": "string"},
"in_stock": {"type": "integer"}, "in_stock": {"type": "integer"},
"durability": {"type": "integer", "minimum": 0, "maximum": 100},
"video_url": {"type": "string"},
"image_data": {"type": "string", "description": "Base64 JPG or PNG image data for UEX upload."},
"hours_expiration": {"type": "integer"}, "hours_expiration": {"type": "integer"},
"is_hidden": {"type": "integer", "enum": [0, 1]}, "is_hidden": {"type": "integer", "enum": [0, 1]},
"is_tv_allowed": {"type": "integer", "enum": [0, 1]},
"is_production": {"type": "integer", "enum": [0, 1], "default": 1}, "is_production": {"type": "integer", "enum": [0, 1], "default": 1},
}, },
}, },
@@ -376,6 +399,83 @@ class ToolRegistry:
"parameters": {"type": "object", "properties": {}}, "parameters": {"type": "object", "properties": {}},
}, },
}, },
{
"type": "function",
"function": {
"name": "create_continual_plan",
"description": "Create a durable multi-run plan. Use this for long-running marketplace work over days. kind=buying uses structured listing/candidate tracking; kind=custom continues through an agent wake prompt. All UEX writes are draft-only for approval.",
"parameters": {
"type": "object",
"required": ["title", "objective"],
"properties": {
"title": {"type": "string"},
"objective": {"type": "string"},
"kind": {"type": "string", "enum": ["buying", "custom"], "default": "buying"},
"cadence": {"type": "string", "description": "Five-field cron expression, default every six hours."},
"constraints": {"type": "object", "description": "Plan-specific options such as message_tone, excluded_sellers, preferred_locations, max_unit_price, or custom instructions."},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"item_name": {"type": "string"},
"desired_quantity": {"type": "integer", "minimum": 1},
"max_unit_price": {"type": "number"},
},
},
},
},
},
},
},
{
"type": "function",
"function": {
"name": "list_continual_plans",
"description": "List durable continual plans and their statuses.",
"parameters": {"type": "object", "properties": {"include_inactive": {"type": "boolean", "default": True}}},
},
},
{
"type": "function",
"function": {
"name": "get_continual_plan",
"description": "Get one continual plan with checklist items, candidates, negotiations, and event history.",
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
},
},
{
"type": "function",
"function": {
"name": "pause_continual_plan",
"description": "Pause a continual plan so scheduled runs stop.",
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
},
},
{
"type": "function",
"function": {
"name": "resume_continual_plan",
"description": "Resume a paused or needs-input continual plan. It only becomes active when it has checklist items.",
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
},
},
{
"type": "function",
"function": {
"name": "cancel_continual_plan",
"description": "Cancel a continual plan.",
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
},
},
{
"type": "function",
"function": {
"name": "run_continual_plan_now",
"description": "Run one continual plan immediately and put the result in the Inbox.",
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
},
},
{ {
"type": "function", "type": "function",
"function": { "function": {
@@ -400,13 +500,17 @@ class ToolRegistry:
if not action: if not action:
return {"error": f"Pending action not found: {action_id}"} return {"error": f"Pending action not found: {action_id}"}
if action.method == "DELETE": if action.method == "DELETE":
return await self.uex.delete(action.endpoint, action.payload, authenticated=True) result = await self.uex.delete(action.endpoint, action.payload, authenticated=True)
return await self.uex.post(action.endpoint, self._production_payload(action.endpoint, action.payload), authenticated=True) else:
result = await self.uex.post(action.endpoint, self._production_payload(action.endpoint, action.payload), authenticated=True)
self._record_pending_action_result(action, "approved", result)
return result
async def decline(self, action_id: str) -> dict[str, Any]: async def decline(self, action_id: str) -> dict[str, Any]:
action = self.pending_actions.pop(action_id, None) action = self.pending_actions.pop(action_id, None)
if not action: if not action:
return {"error": f"Pending action not found: {action_id}"} return {"error": f"Pending action not found: {action_id}"}
self._record_pending_action_result(action, "declined", {})
return { return {
"declined": True, "declined": True,
"pending_action": { "pending_action": {
@@ -414,7 +518,8 @@ class ToolRegistry:
"label": action.label, "label": action.label,
"method": action.method, "method": action.method,
"endpoint": action.endpoint, "endpoint": action.endpoint,
"payload": action.payload, "payload": self._display_payload(action.payload),
"metadata": action.metadata or {},
}, },
} }
@@ -874,6 +979,60 @@ class ToolRegistry:
}, },
}, },
}, },
{
"type": "function",
"function": {
"name": "get_cornerstone_item_media",
"description": "Fetch Cornerstone item page media, especially image URLs that can be used when drafting UEX marketplace listings.",
"parameters": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "Cornerstone item id from search_cornerstone_items."},
"query": {"type": "string", "description": "Item name if id is not known."},
"limit": {"type": "integer", "minimum": 1, "maximum": 10, "default": 5},
},
},
},
},
{
"type": "function",
"function": {
"name": "draft_marketplace_listing_with_cornerstone_image",
"description": "Draft a UEX marketplace listing and source the listing image from Cornerstone. The image is downloaded as base64 image_data and included in the pending action. Nothing is posted until user approval.",
"parameters": {
"type": "object",
"required": ["item_query", "id_category", "operation", "type", "unit", "title", "description", "price", "currency", "language"],
"properties": {
"item_query": {"type": "string", "description": "Cornerstone item name to source an image from."},
"cornerstone_id": {"type": "string", "description": "Cornerstone item id, if already known."},
"id_item": {"type": "integer"},
"id_star_system": {"type": "integer"},
"id_terminal": {"type": "integer"},
"id_organization": {"type": "integer"},
"id_category": {"type": "integer"},
"operation": {"type": "string", "enum": ["buy", "sell", "rent", "trade"]},
"type": {"type": "string", "enum": ["item", "service", "contract"]},
"unit": {"type": "string"},
"title": {"type": "string"},
"description": {"type": "string"},
"price": {"type": "number"},
"currency": {"type": "string", "enum": ["UEC"]},
"language": {"type": "string", "default": "en_US"},
"location": {"type": "string"},
"source": {"type": "string", "enum": ["looted", "pledged", "purchased_in_game", "pirated", "gifted"]},
"availability": {"type": "string"},
"in_stock": {"type": "integer"},
"durability": {"type": "integer", "minimum": 0, "maximum": 100},
"video_url": {"type": "string"},
"hours_expiration": {"type": "integer"},
"is_hidden": {"type": "integer", "enum": [0, 1]},
"is_tv_allowed": {"type": "integer", "enum": [0, 1]},
"is_production": {"type": "integer", "enum": [0, 1], "default": 1},
"require_image": {"type": "boolean", "default": False, "description": "Return an error instead of drafting if no Cornerstone JPG/PNG image can be sourced."},
},
},
},
},
] ]
@classmethod @classmethod
@@ -1046,14 +1205,77 @@ class ToolRegistry:
message: str, message: str,
hash: str | None = None, hash: str | None = None,
id_negotiation: int | None = None, id_negotiation: int | None = None,
id_listing: int | None = None,
plan_id: str | None = None,
plan_item_id: int | None = None,
candidate_id: int | None = None,
listing_slug: str | None = None,
is_production: int = 1, is_production: int = 1,
) -> dict[str, Any]: ) -> dict[str, Any]:
payload = {"message": message, "hash": hash, "id_negotiation": id_negotiation, "is_production": is_production} payload = {"message": message, "hash": hash, "id_negotiation": id_negotiation, "id_listing": id_listing, "is_production": is_production}
return self._pending("Send negotiation message", "marketplace_negotiations_messages", payload) metadata = {
"plan_id": plan_id,
"plan_item_id": plan_item_id,
"candidate_id": candidate_id,
"listing_id": id_listing,
"listing_slug": listing_slug,
"hash": hash,
"id_negotiation": id_negotiation,
}
return self._pending("Send negotiation message", "marketplace_negotiations_messages", payload, metadata=metadata)
async def draft_marketplace_listing(self, **payload: Any) -> dict[str, Any]: async def draft_marketplace_listing(self, **payload: Any) -> dict[str, Any]:
return self._pending("Post marketplace listing", "marketplace_advertise", payload) return self._pending("Post marketplace listing", "marketplace_advertise", payload)
async def draft_marketplace_listing_with_cornerstone_image(
self,
item_query: str,
cornerstone_id: str | None = None,
**payload: Any,
) -> dict[str, Any]:
require_image = bool(payload.pop("require_image", False))
item = await self._resolve_cornerstone_item(id=cornerstone_id, query=item_query)
if not item:
return {"error": "No Cornerstone item matched. Provide cornerstone_id or a more specific item_query."}
page = await self.cornerstone.get_item_page(str(item["id"]))
parsed = parse_cornerstone_item_page(page["html"], page["url"])
media = parsed.get("media") or []
image_result: dict[str, Any] | None = None
image_error = ""
for media_item in media:
try:
image_result = await self.cornerstone.get_image_data(media_item["url"])
break
except Exception as exc:
image_error = str(exc)
if image_result:
payload["image_data"] = image_result["image_data"]
elif require_image:
return {
"error": "Cornerstone item matched, but no usable JPG/PNG image could be sourced.",
"cornerstone": {
"item": {"id": item.get("id"), "name": parsed.get("name") or item.get("name")},
"url": page["url"],
"media": media,
"image_error": image_error,
},
}
payload.setdefault("id_item", self._int_or_none(item.get("id")))
metadata = {
"cornerstone_item_id": item.get("id"),
"cornerstone_item_name": parsed.get("name") or item.get("name"),
"cornerstone_url": page["url"],
"cornerstone_image_url": image_result.get("url") if image_result else None,
"cornerstone_image_content_type": image_result.get("content_type") if image_result else None,
"cornerstone_image_size_bytes": image_result.get("size_bytes") if image_result else None,
"cornerstone_image_status": "included" if image_result else "not_found",
"cornerstone_image_error": image_error or None,
}
return self._pending("Post marketplace listing with Cornerstone image", "marketplace_advertise", payload, metadata=metadata)
async def remember_user_fact(self, content: str, kind: str = "note", importance: int = 3) -> dict[str, Any]: async def remember_user_fact(self, content: str, kind: str = "note", importance: int = 3) -> dict[str, Any]:
if self.memory is None: if self.memory is None:
return {"error": "Memory store is not configured."} return {"error": "Memory store is not configured."}
@@ -1083,6 +1305,68 @@ class ToolRegistry:
return {"error": "Scheduler is not configured."} return {"error": "Scheduler is not configured."}
return {"scheduled_jobs": self.scheduler.list_jobs()} return {"scheduled_jobs": self.scheduler.list_jobs()}
async def create_continual_plan(
self,
title: str,
objective: str,
kind: str = "buying",
items: list[dict[str, Any]] | None = None,
constraints: dict[str, Any] | None = None,
cadence: str | None = None,
) -> dict[str, Any]:
if self.plan_store is None:
return {"error": "Continual plan store is not configured."}
plan = self.plan_store.create_plan(title, kind=kind, objective=objective, items=items or [], constraints=constraints or {}, cadence=cadence)
if self.scheduler is not None and plan.get("status") == "active":
self.scheduler.schedule_plan(plan)
plan = self.plan_store.get_plan(plan["id"]) or plan
return {"plan": plan}
async def list_continual_plans(self, include_inactive: bool = True) -> dict[str, Any]:
if self.plan_store is None:
return {"error": "Continual plan store is not configured."}
return {"plans": self.plan_store.list_plans(include_inactive=include_inactive)}
async def get_continual_plan(self, plan_id: str) -> dict[str, Any]:
if self.plan_store is None:
return {"error": "Continual plan store is not configured."}
plan = self.plan_store.get_plan(plan_id)
if not plan:
return {"error": f"Plan not found: {plan_id}"}
return {"plan": plan}
async def pause_continual_plan(self, plan_id: str) -> dict[str, Any]:
if self.plan_store is None:
return {"error": "Continual plan store is not configured."}
if self.scheduler is not None:
self.scheduler.unschedule_plan(plan_id)
return {"plan": self.plan_store.set_status(plan_id, "paused")}
async def resume_continual_plan(self, plan_id: str) -> dict[str, Any]:
if self.plan_store is None:
return {"error": "Continual plan store is not configured."}
plan = self.plan_store.get_plan(plan_id)
if not plan:
return {"error": f"Plan not found: {plan_id}"}
next_status = "active" if plan.get("items") else "needs_input"
plan = self.plan_store.set_status(plan_id, next_status)
if self.scheduler is not None and plan and plan.get("status") == "active":
self.scheduler.schedule_plan(plan)
plan = self.plan_store.get_plan(plan_id)
return {"plan": plan}
async def cancel_continual_plan(self, plan_id: str) -> dict[str, Any]:
if self.plan_store is None:
return {"error": "Continual plan store is not configured."}
if self.scheduler is not None:
self.scheduler.unschedule_plan(plan_id)
return {"plan": self.plan_store.set_status(plan_id, "canceled")}
async def run_continual_plan_now(self, plan_id: str) -> dict[str, Any]:
if self.plan_runner is None:
return {"error": "Continual plan runner is not configured."}
return await self.plan_runner.run_plan(plan_id)
async def check_uex_notifications(self) -> dict[str, Any]: async def check_uex_notifications(self) -> dict[str, Any]:
response = await self.uex.get_user_notifications() response = await self.uex.get_user_notifications()
notifications = response.get("notifications") or [] notifications = response.get("notifications") or []
@@ -1255,7 +1539,7 @@ class ToolRegistry:
return {"error": "No Cornerstone item matched. Provide an id or a more specific query."} return {"error": "No Cornerstone item matched. Provide an id or a more specific query."}
page = await self.cornerstone.get_item_page(str(item["id"])) page = await self.cornerstone.get_item_page(str(item["id"]))
parsed = parse_cornerstone_item_page(page["html"]) parsed = parse_cornerstone_item_page(page["html"], page["url"])
locations = parsed.get("locations") or [] locations = parsed.get("locations") or []
location_filter = (location or "").casefold().strip() location_filter = (location or "").casefold().strip()
if location_filter: if location_filter:
@@ -1280,22 +1564,90 @@ class ToolRegistry:
"locations": locations[:limit], "locations": locations[:limit],
} }
def _pending(self, label: str, endpoint: str, payload: dict[str, Any], method: str = "POST") -> dict[str, Any]: async def get_cornerstone_item_media(
self,
id: str | None = None,
query: str | None = None,
limit: int = 5,
) -> dict[str, Any]:
item = await self._resolve_cornerstone_item(id=id, query=query)
if not item:
return {"error": "No Cornerstone item matched. Provide an id or a more specific query."}
page = await self.cornerstone.get_item_page(str(item["id"]))
parsed = parse_cornerstone_item_page(page["html"], page["url"])
media = parsed.get("media") or []
limit = max(1, min(limit, 10))
return {
"source": self.cornerstone.base_url,
"url": page["url"],
"item": {
"id": item.get("id"),
"name": parsed.get("name") or item.get("name"),
"sold": bool(item.get("sold")),
"general": parsed.get("general") or {},
},
"returned": min(len(media), limit),
"truncated": len(media) > limit,
"media": media[:limit],
}
def _pending(
self,
label: str,
endpoint: str,
payload: dict[str, Any],
method: str = "POST",
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
action_id = str(uuid.uuid4()) action_id = str(uuid.uuid4())
payload = {key: value for key, value in payload.items() if value is not None} payload = {key: value for key, value in payload.items() if value is not None}
metadata = {key: value for key, value in (metadata or {}).items() if value is not None}
payload = self._production_payload(endpoint, payload) payload = self._production_payload(endpoint, payload)
self.pending_actions[action_id] = PendingAction(action_id, label, endpoint, payload, method) self.pending_actions[action_id] = PendingAction(action_id, label, endpoint, payload, method, metadata)
return { return {
"pending_action": { "pending_action": {
"id": action_id, "id": action_id,
"label": label, "label": label,
"method": method, "method": method,
"endpoint": endpoint, "endpoint": endpoint,
"payload": payload, "payload": self._display_payload(payload),
"metadata": metadata,
"approval_required": self.require_write_approval, "approval_required": self.require_write_approval,
} }
} }
@staticmethod
def _display_payload(payload: dict[str, Any]) -> dict[str, Any]:
display = dict(payload)
image_data = display.get("image_data")
if isinstance(image_data, str) and image_data:
display["image_data"] = f"<base64 image data redacted; {len(image_data)} characters>"
return display
@staticmethod
def _int_or_none(value: Any) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
def _record_pending_action_result(self, action: PendingAction, result_kind: str, result: dict[str, Any]) -> None:
metadata = action.metadata or {}
plan_id = metadata.get("plan_id")
if not plan_id or self.plan_store is None:
return
message = f"{action.label} {result_kind} for continual plan."
event_metadata = {"action_id": action.id, "endpoint": action.endpoint, "payload": action.payload, "result": result, **metadata}
self.plan_store.add_event(plan_id, result_kind, message, event_metadata)
if result_kind == "approved" and action.endpoint == "marketplace_negotiations_messages":
self.plan_store.add_negotiation(
plan_id,
metadata.get("plan_item_id"),
metadata.get("candidate_id"),
{**metadata, "status": "approved"},
)
@staticmethod @staticmethod
def _production_payload(endpoint: str, payload: dict[str, Any]) -> dict[str, Any]: def _production_payload(endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
if endpoint not in UEX_PRODUCTION_WRITE_RESOURCES: if endpoint not in UEX_PRODUCTION_WRITE_RESOURCES:
+3 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
__version__ = "0.0.3" __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"
@@ -9,3 +9,5 @@ RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingCo
Generated
+3 -1
View File
@@ -755,7 +755,7 @@ wheels = [
[[package]] [[package]]
name = "traderai" name = "traderai"
version = "0.0.3" version = "0.0.5"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "apscheduler" }, { name = "apscheduler" },
@@ -1049,3 +1049,5 @@ wheels = [
+235
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");
@@ -47,6 +49,12 @@ const updateModalCopy = document.getElementById("update-modal-copy");
const updateModalClose = document.getElementById("update-modal-close"); const updateModalClose = document.getElementById("update-modal-close");
const updateModalInstall = document.getElementById("update-modal-install"); const updateModalInstall = document.getElementById("update-modal-install");
const updateModalReleases = document.getElementById("update-modal-releases"); 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;
@@ -963,6 +971,19 @@ function closeNegotiationPanel() {
negotiationStatusEl.textContent = ""; 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) { function renderNegotiationMessages(data) {
negotiationMessagesEl.innerHTML = ""; negotiationMessagesEl.innerHTML = "";
const items = Array.isArray(data) ? data : [data].filter(Boolean); const items = Array.isArray(data) ? data : [data].filter(Boolean);
@@ -1002,6 +1023,212 @@ async function submitNegotiationMessage(event) {
} }
} }
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");
@@ -1205,7 +1432,14 @@ 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", () => { ollamaDownloadButton?.addEventListener("click", () => {
@@ -1320,6 +1554,7 @@ async function sendMessage() {
refreshPending(); refreshPending();
refreshMemory(); refreshMemory();
refreshPlans();
refreshConfig(); refreshConfig();
refreshOllamaStatus(); refreshOllamaStatus();
refreshChats().then(() => loadChatMessages(currentThreadId)); refreshChats().then(() => loadChatMessages(currentThreadId));
+46 -1
View File
@@ -9,7 +9,7 @@
</head> </head>
<body> <body>
<main class="shell"> <main class="shell">
<nav class="chat-rail collapsed" id="chat-rail" aria-label="Chats and inbox"> <nav class="chat-rail collapsed" id="chat-rail" aria-label="Chats, plans, and inbox">
<div class="chat-rail-top"> <div class="chat-rail-top">
<button class="icon-button" id="chat-sidebar-toggle" type="button" title="Chats" aria-expanded="false"> <button class="icon-button" id="chat-sidebar-toggle" type="button" title="Chats" aria-expanded="false">
<i data-lucide="panel-left" aria-hidden="true"></i> <i data-lucide="panel-left" aria-hidden="true"></i>
@@ -25,6 +25,15 @@
<div class="rail-heading">Chats</div> <div class="rail-heading">Chats</div>
<div class="chat-list" id="chat-list"></div> <div class="chat-list" id="chat-list"></div>
</section> </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"> <section class="chat-nav-section">
<div class="rail-heading">Inbox</div> <div class="rail-heading">Inbox</div>
<div class="inbox-list" id="inbox-list"></div> <div class="inbox-list" id="inbox-list"></div>
@@ -42,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>
@@ -159,6 +169,41 @@
</form> </form>
<div class="config-status" id="negotiation-status"></div> <div class="config-status" id="negotiation-status"></div>
</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> <div class="modal-backdrop" id="update-modal" hidden>
<section class="update-modal-card"> <section class="update-modal-card">
<div class="section-title-row"> <div class="section-title-row">
+341 -31
View File
@@ -105,7 +105,7 @@ body::before {
.chat-rail-content { .chat-rail-content {
display: grid; display: grid;
grid-template-rows: minmax(0, 1fr) minmax(140px, 34%); grid-template-rows: minmax(0, 1fr) minmax(92px, 20%) minmax(130px, 30%);
gap: 16px; gap: 16px;
min-height: 0; min-height: 0;
padding-top: 16px; padding-top: 16px;
@@ -131,8 +131,41 @@ body::before {
text-transform: uppercase; 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, .chat-list,
.inbox-list { .inbox-list,
.plans-rail-list {
display: grid; display: grid;
gap: 8px; gap: 8px;
max-height: calc(100% - 26px); max-height: calc(100% - 26px);
@@ -140,7 +173,8 @@ body::before {
} }
.chat-item, .chat-item,
.inbox-item { .inbox-item,
.plan-rail-item {
display: grid; display: grid;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
@@ -159,13 +193,33 @@ body::before {
grid-template-columns: minmax(0, 1fr) auto auto; 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 { .chat-item.active {
border-color: rgba(52, 83, 38, 0.42); border-color: rgba(52, 83, 38, 0.42);
background: #edf3df; background: #edf3df;
} }
.chat-title, .chat-title,
.inbox-title { .inbox-title,
.plan-rail-title {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
color: var(--brown); color: var(--brown);
@@ -198,6 +252,22 @@ body::before {
-webkit-box-orient: vertical; -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;
@@ -230,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;
@@ -678,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;
@@ -694,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);
} }
@@ -867,6 +943,26 @@ button {
line-height: 1.45; 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);
@@ -923,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;
} }
@@ -1245,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;
@@ -1277,8 +1510,17 @@ pre {
} }
@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;
} }
@@ -1287,40 +1529,104 @@ 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,
.actions, .actions {
.chat-rail {
padding: 22px; padding: 22px;
} }
@@ -1350,4 +1656,8 @@ pre {
.message-phase { .message-phase {
grid-column: 1; grid-column: 1;
} }
.plans-panel-body {
grid-template-columns: 1fr;
}
} }