Inital Commit

This commit is contained in:
2026-05-05 19:45:12 -04:00
parent 729f421ec8
commit dbc97bddee
21 changed files with 3238 additions and 2 deletions
+1
View File
@@ -0,0 +1 @@
"""TraderAI application package."""
+344
View File
@@ -0,0 +1,344 @@
from __future__ import annotations
import json
from collections.abc import AsyncIterator
from typing import Any
import httpx
from tzlocal import get_localzone
from traderai.memory import MemoryStore, iso_now, iso_now_in_zone, time_since
from traderai.tools import ToolRegistry
SYSTEM_PROMPT = """You are TraderAI, a local assistant for UEX marketplace work.
Use tools when the user asks about listings, negotiations, messages, offers, or posting ads.
For marketplace writes, draft the exact pending action and tell the user what will be sent; never claim it was sent until approval succeeds.
Keep prices, listing ids, slugs, users, and UEX status codes precise. If data is missing, say what you need next."""
class OllamaAgent:
def __init__(
self,
base_url: str,
model: str,
tools: ToolRegistry,
memory: MemoryStore | None = None,
user_name: str | None = None,
) -> None:
self.base_url = base_url.rstrip("/")
self.model = model
self.tools = tools
self.memory = memory
self.user_name = user_name
self.messages: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}]
async def health(self) -> dict[str, Any]:
try:
async with httpx.AsyncClient(timeout=3) as client:
response = await client.get(f"{self.base_url}/api/tags")
response.raise_for_status()
body = response.json()
except (httpx.HTTPError, ValueError) as exc:
return {
"online": False,
"model": self.model,
"base_url": self.base_url,
"message": f"Ollama is offline or unreachable at {self.base_url}. Start Ollama and make sure the model is pulled.",
"detail": str(exc),
}
models = [model.get("name") or model.get("model") for model in body.get("models", [])]
return {
"online": True,
"model": self.model,
"base_url": self.base_url,
"model_available": self.model in models,
"models": models,
"message": "Ollama is online.",
}
async def ensure_available(self) -> None:
health = await self.health()
if not health["online"]:
raise OllamaUnavailable(health["message"])
async def chat(self, content: str) -> dict[str, Any]:
await self.ensure_available()
previous_interaction = self.memory.last_interaction() if self.memory else None
if self.memory:
self.memory.add_conversation("user", content)
self.messages.append({"role": "user", "content": content})
for _ in range(5):
response = await self._ollama_chat(content, previous_interaction=previous_interaction)
message = response.get("message") or {}
tool_calls = message.get("tool_calls") or []
if not tool_calls:
self.messages.append({"role": "assistant", "content": message.get("content", "")})
if self.memory:
self.memory.add_conversation("assistant", message.get("content", ""))
return {"message": message.get("content", ""), "pending_actions": self._pending_payloads()}
self.messages.append(message)
for call in tool_calls:
name, arguments = self._extract_call(call)
result = await self.tools.execute(name, arguments)
self.messages.append({"role": "tool", "tool_name": name, "content": json.dumps(result)})
fallback = "I hit the tool-call limit while working on that. Try narrowing the request or approve any pending action first."
self.messages.append({"role": "assistant", "content": fallback})
if self.memory:
self.memory.add_conversation("assistant", fallback)
return {"message": fallback, "pending_actions": self._pending_payloads()}
async def chat_events(self, content: str) -> AsyncIterator[dict[str, Any]]:
health = await self.health()
if not health["online"]:
yield {"type": "warning", "message": health["message"]}
yield {"type": "done", "pending_actions": self._pending_payloads()}
return
previous_interaction = self.memory.last_interaction() if self.memory else None
if self.memory:
self.memory.add_conversation("user", content)
self.messages.append({"role": "user", "content": content})
yield {"type": "status", "message": "Thinking"}
for _ in range(5):
assistant_message: dict[str, Any] = {"role": "assistant", "content": ""}
tool_calls: list[dict[str, Any]] = []
async for event in self._ollama_chat_stream(content, previous_interaction=previous_interaction):
message = event.get("message") or {}
chunk = message.get("content") or ""
if chunk:
assistant_message["content"] += chunk
yield {"type": "token", "content": chunk}
if message.get("tool_calls"):
tool_calls.extend(message["tool_calls"])
if not tool_calls:
self.messages.append(assistant_message)
if self.memory:
self.memory.add_conversation("assistant", assistant_message.get("content", ""))
yield {"type": "done", "pending_actions": self._pending_payloads()}
return
assistant_message["tool_calls"] = tool_calls
self.messages.append(assistant_message)
for call in tool_calls:
name, arguments = self._extract_call(call)
yield {"type": "status", "message": self._tool_status(name)}
result = await self.tools.execute(name, arguments)
self.messages.append({"role": "tool", "tool_name": name, "content": json.dumps(result)})
yield {"type": "status", "message": "Writing response"}
fallback = "I hit the tool-call limit while working on that. Try narrowing the request or approve any pending action first."
self.messages.append({"role": "assistant", "content": fallback})
if self.memory:
self.memory.add_conversation("assistant", fallback)
yield {"type": "token", "content": fallback}
yield {"type": "done", "pending_actions": self._pending_payloads()}
async def generate_wake_response(self, wake_message: str) -> str:
await self.ensure_available()
self.messages.append({"role": "user", "content": wake_message})
response = await self._ollama_chat(wake_message)
message = response.get("message") or {}
content = message.get("content", "")
self.messages.append({"role": "assistant", "content": content})
if self.memory:
self.memory.add_conversation("system", wake_message)
self.memory.add_conversation("assistant", content)
return content or wake_message
async def _ollama_chat(self, query: str = "", previous_interaction: dict[str, Any] | None = None) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=120) as client:
response = await client.post(
f"{self.base_url}/api/chat",
json={
"model": self.model,
"messages": self._messages_with_context(query, previous_interaction=previous_interaction),
"tools": self.tools.schemas,
"stream": False,
},
)
response.raise_for_status()
return response.json()
async def _ollama_chat_stream(
self,
query: str = "",
previous_interaction: dict[str, Any] | None = None,
) -> AsyncIterator[dict[str, Any]]:
async with httpx.AsyncClient(timeout=120) as client:
async with client.stream(
"POST",
f"{self.base_url}/api/chat",
json={
"model": self.model,
"messages": self._messages_with_context(query, previous_interaction=previous_interaction),
"tools": self.tools.schemas,
"stream": True,
},
) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if line:
yield json.loads(line)
def _messages_with_context(
self,
query: str,
previous_interaction: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
context = self._runtime_context(query, previous_interaction=previous_interaction)
if not context:
return self.messages
return [self.messages[0], {"role": "system", "content": context}, *self.messages[1:]]
def _runtime_context(self, query: str, previous_interaction: dict[str, Any] | None = None) -> str:
local_zone = get_localzone()
parts = [
f"Current local date/time: {iso_now()} UTC; {iso_now_in_zone(local_zone)} {local_zone}.",
]
if self.user_name:
parts.append(f"Known user name/handle: {self.user_name}.")
if self.memory is None:
return "\n".join(parts)
profile = self.memory.get_profile()
if profile:
identity = self._profile_identity(profile)
if identity:
parts.append(identity)
parts.append(f"Known user profile JSON: {json.dumps(self._profile_for_prompt(profile), ensure_ascii=True)}.")
last = previous_interaction if previous_interaction is not None else self.memory.last_interaction()
if last:
parts.append(
f"Previous interaction before this message: {last['created_at']} "
f"({time_since(last['created_at'])}, role {last['role']})."
)
else:
parts.append("Previous interaction before this message: none recorded.")
memories = self.memory.recall(query, limit=6)
if memories:
memory_text = "\n".join(
f"- [{item['kind']}, importance {item['importance']}] {item['content']}"
for item in memories
)
parts.append(f"Relevant long-term memories:\n{memory_text}")
recent = self.memory.recent_conversation(limit=6)
if recent:
recent_text = "\n".join(
f"- {item['created_at']} {item['role']}: {item['content'][:500]}"
for item in recent
)
parts.append(f"Recent conversation excerpts:\n{recent_text}")
return "\n".join(parts)
def _pending_payloads(self) -> list[dict[str, Any]]:
return [
{
"id": action.id,
"label": action.label,
"endpoint": action.endpoint,
"payload": action.payload,
}
for action in self.tools.pending_actions.values()
]
@staticmethod
def _tool_status(name: str) -> str:
labels = {
"search_marketplace_listings": "Searching UEX listings",
"get_marketplace_listing": "Fetching listing details",
"list_marketplace_negotiations": "Checking negotiations",
"get_negotiation_messages": "Reading negotiation messages",
"draft_negotiation_message": "Drafting message for approval",
"draft_marketplace_listing": "Drafting listing for approval",
}
return labels.get(name, f"Running {name}")
@staticmethod
def _profile_identity(profile: dict[str, Any]) -> str:
user = profile.get("uex_user")
if not isinstance(user, dict):
configured = profile.get("configured_name")
return f"You are speaking with {configured}." if configured else ""
username = user.get("username") or user.get("user_username")
name = user.get("name")
fields = []
if username and name and username != name:
fields.append(f"You are speaking with UEX user {username} ({name}).")
elif username or name:
fields.append(f"You are speaking with UEX user {username or name}.")
details = []
for key, label in [
("timezone", "timezone"),
("language", "preferred language"),
("specializations", "specializations"),
("languages", "languages"),
("archetypes", "archetypes"),
]:
value = user.get(key)
if value:
details.append(f"{label}: {value}")
if details:
fields.append("UEX profile details: " + "; ".join(details) + ".")
return " ".join(fields)
@staticmethod
def _profile_for_prompt(profile: dict[str, Any]) -> dict[str, Any]:
user = profile.get("uex_user")
if not isinstance(user, dict):
return profile
useful_user_fields = [
"id",
"name",
"username",
"avatar",
"bio",
"website_url",
"timezone",
"language",
"day_availability",
"time_availability",
"specializations",
"languages",
"archetypes",
"is_datarunner",
"is_staff",
"is_away_game",
"date_rsi_verified",
"date_twitch_verified",
]
prompt_profile = dict(profile)
prompt_profile["uex_user"] = {
key: user[key]
for key in useful_user_fields
if key in user and user[key] not in (None, "")
}
return prompt_profile
@staticmethod
def _extract_call(call: dict[str, Any]) -> tuple[str, dict[str, Any]]:
function = call.get("function") or {}
name = function.get("name") or call.get("name")
arguments = function.get("arguments") or call.get("arguments") or {}
if isinstance(arguments, str):
arguments = json.loads(arguments or "{}")
return name, arguments
class OllamaUnavailable(RuntimeError):
pass
+22
View File
@@ -0,0 +1,22 @@
from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
ollama_base_url: str = "http://localhost:11434"
ollama_model: str = "qwen3.5:9b"
uex_base_url: str = "https://api.uexcorp.space/2.0"
uex_secret_key: str | None = Field(default=None)
uex_bearer_token: str | None = Field(default=None)
traderai_user_name: str | None = Field(default=None)
traderai_memory_path: str = "data/traderai.sqlite3"
require_write_approval: bool = True
@lru_cache
def get_settings() -> Settings:
return Settings()
+378
View File
@@ -0,0 +1,378 @@
from __future__ import annotations
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def iso_now() -> str:
return utc_now().isoformat()
def iso_now_in_zone(zone: ZoneInfo) -> str:
return utc_now().astimezone(zone).isoformat()
def parse_iso(value: str) -> datetime:
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed
def time_since(value: str, now: datetime | None = None) -> str:
then = parse_iso(value)
current = now or utc_now()
if current.tzinfo is None:
current = current.replace(tzinfo=timezone.utc)
seconds = max(0, int((current - then).total_seconds()))
if seconds < 60:
return f"{seconds} seconds ago"
minutes = seconds // 60
if minutes < 60:
return _plural(minutes, "minute") + " ago"
hours = minutes // 60
if hours < 24:
return _plural(hours, "hour") + " ago"
days = hours // 24
return _plural(days, "day") + " ago"
def _plural(value: int, unit: str) -> str:
suffix = "" if value == 1 else "s"
return f"{value} {unit}{suffix}"
class MemoryStore:
def __init__(self, path: str) -> None:
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.path)
connection.row_factory = sqlite3.Row
return connection
def _init_db(self) -> None:
with self._connect() as db:
db.executescript(
"""
CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
content TEXT NOT NULL,
importance INTEGER NOT NULL DEFAULT 3,
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
content,
kind UNINDEXED,
content='memories',
content_rowid='id'
);
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
INSERT INTO memories_fts(rowid, content, kind) VALUES (new.id, new.content, new.kind);
END;
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, content, kind)
VALUES('delete', old.id, old.content, old.kind);
END;
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, content, kind)
VALUES('delete', old.id, old.content, old.kind);
INSERT INTO memories_fts(rowid, content, kind) VALUES (new.id, new.content, new.kind);
END;
CREATE TABLE IF NOT EXISTS user_profile (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS scheduled_jobs (
id TEXT PRIMARY KEY,
prompt TEXT NOT NULL,
trigger_type TEXT NOT NULL,
trigger_value TEXT NOT NULL,
next_run_at TEXT,
created_at TEXT NOT NULL,
last_run_at TEXT,
enabled INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
created_at TEXT NOT NULL,
delivered_at TEXT
);
"""
)
def add_conversation(self, role: str, content: str) -> None:
with self._connect() as db:
db.execute(
"INSERT INTO conversations(role, content, created_at) VALUES (?, ?, ?)",
(role, content, iso_now()),
)
def last_interaction(self) -> dict[str, Any] | None:
with self._connect() as db:
row = db.execute(
"SELECT role, content, created_at FROM conversations ORDER BY id DESC LIMIT 1"
).fetchone()
return dict(row) if row else None
def recent_conversation(self, limit: int = 8) -> list[dict[str, Any]]:
with self._connect() as db:
rows = db.execute(
"SELECT role, content, created_at FROM conversations ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(row) for row in reversed(rows)]
def remember(self, kind: str, content: str, importance: int = 3, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
now = iso_now()
with self._connect() as db:
cursor = db.execute(
"""
INSERT INTO memories(kind, content, importance, metadata, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(kind, content, importance, json.dumps(metadata or {}), now, now),
)
memory_id = cursor.lastrowid
return {"id": memory_id, "kind": kind, "content": content, "importance": importance, "created_at": now}
def recall(self, query: str, limit: int = 6) -> list[dict[str, Any]]:
if not query.strip():
return self.top_memories(limit)
with self._connect() as db:
try:
rows = db.execute(
"""
SELECT m.id, m.kind, m.content, m.importance, m.metadata, m.created_at,
bm25(memories_fts) AS rank
FROM memories_fts
JOIN memories m ON m.id = memories_fts.rowid
WHERE memories_fts MATCH ?
ORDER BY rank, m.importance DESC
LIMIT ?
""",
(self._fts_query(query), limit),
).fetchall()
except sqlite3.OperationalError:
rows = db.execute(
"""
SELECT id, kind, content, importance, metadata, created_at
FROM memories
WHERE content LIKE ?
ORDER BY importance DESC, id DESC
LIMIT ?
""",
(f"%{query}%", limit),
).fetchall()
return [self._memory_row(row) for row in rows]
def top_memories(self, limit: int = 6) -> list[dict[str, Any]]:
with self._connect() as db:
rows = db.execute(
"""
SELECT id, kind, content, importance, metadata, created_at
FROM memories
ORDER BY importance DESC, updated_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
return [self._memory_row(row) for row in rows]
def inspect(self, limit: int = 50) -> dict[str, Any]:
with self._connect() as db:
memories = db.execute(
"""
SELECT id, kind, content, importance, metadata, created_at, updated_at
FROM memories
ORDER BY importance DESC, updated_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
conversations = db.execute(
"""
SELECT id, role, content, created_at
FROM conversations
ORDER BY id DESC
LIMIT ?
""",
(limit,),
).fetchall()
profile_rows = db.execute(
"SELECT key, value, updated_at FROM user_profile ORDER BY key"
).fetchall()
jobs = db.execute(
"SELECT * FROM scheduled_jobs ORDER BY enabled DESC, next_run_at"
).fetchall()
outbox = db.execute(
"SELECT id, content, created_at, delivered_at FROM outbox ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
profile = []
for row in profile_rows:
item = dict(row)
try:
item["value"] = json.loads(item["value"])
except json.JSONDecodeError:
pass
profile.append(item)
return {
"path": str(self.path),
"memories": [self._memory_row(row) for row in memories],
"conversations": [dict(row) for row in conversations],
"profile": profile,
"scheduled_jobs": [dict(row) for row in jobs],
"outbox": [dict(row) for row in outbox],
}
def clear(
self,
include_memories: bool = True,
include_conversations: bool = True,
include_profile: bool = False,
include_jobs: bool = False,
include_outbox: bool = True,
) -> dict[str, int]:
deleted: dict[str, int] = {}
with self._connect() as db:
if include_memories:
deleted["memories"] = db.execute("DELETE FROM memories").rowcount
db.execute("INSERT INTO memories_fts(memories_fts) VALUES('rebuild')")
if include_conversations:
deleted["conversations"] = db.execute("DELETE FROM conversations").rowcount
if include_profile:
deleted["profile"] = db.execute("DELETE FROM user_profile").rowcount
if include_jobs:
deleted["scheduled_jobs"] = db.execute("DELETE FROM scheduled_jobs").rowcount
if include_outbox:
deleted["outbox"] = db.execute("DELETE FROM outbox").rowcount
return deleted
def set_profile(self, key: str, value: Any) -> None:
with self._connect() as db:
db.execute(
"""
INSERT INTO user_profile(key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
""",
(key, json.dumps(value), iso_now()),
)
def get_profile(self) -> dict[str, Any]:
with self._connect() as db:
rows = db.execute("SELECT key, value FROM user_profile").fetchall()
profile = {}
for row in rows:
try:
profile[row["key"]] = json.loads(row["value"])
except json.JSONDecodeError:
profile[row["key"]] = row["value"]
return profile
def add_job(
self,
job_id: str,
prompt: str,
trigger_type: str,
trigger_value: str,
next_run_at: str | None = None,
) -> dict[str, Any]:
with self._connect() as db:
db.execute(
"""
INSERT INTO scheduled_jobs(id, prompt, trigger_type, trigger_value, next_run_at, created_at, enabled)
VALUES (?, ?, ?, ?, ?, ?, 1)
ON CONFLICT(id) DO UPDATE SET
prompt=excluded.prompt,
trigger_type=excluded.trigger_type,
trigger_value=excluded.trigger_value,
next_run_at=excluded.next_run_at,
enabled=1
""",
(job_id, prompt, trigger_type, trigger_value, next_run_at, iso_now()),
)
return {
"id": job_id,
"prompt": prompt,
"trigger_type": trigger_type,
"trigger_value": trigger_value,
"next_run_at": next_run_at,
}
def list_jobs(self) -> list[dict[str, Any]]:
with self._connect() as db:
rows = db.execute(
"SELECT * FROM scheduled_jobs WHERE enabled = 1 ORDER BY next_run_at IS NULL, next_run_at"
).fetchall()
return [dict(row) for row in rows]
def mark_job_run(self, job_id: str, next_run_at: str | None = None) -> None:
with self._connect() as db:
db.execute(
"UPDATE scheduled_jobs SET last_run_at = ?, next_run_at = ? WHERE id = ?",
(iso_now(), next_run_at, job_id),
)
def add_outbox(self, content: str) -> None:
with self._connect() as db:
db.execute("INSERT INTO outbox(content, created_at) VALUES (?, ?)", (content, iso_now()))
def undelivered_outbox(self) -> list[dict[str, Any]]:
now = iso_now()
with self._connect() as db:
rows = db.execute(
"SELECT id, content, created_at FROM outbox WHERE delivered_at IS NULL ORDER BY id"
).fetchall()
db.execute(
"UPDATE outbox SET delivered_at = ? WHERE delivered_at IS NULL",
(now,),
)
return [dict(row) for row in rows]
@staticmethod
def _fts_query(query: str) -> str:
tokens = [token.replace('"', "") for token in query.split() if token.strip()]
return " OR ".join(f'"{token}"' for token in tokens) or '""'
@staticmethod
def _memory_row(row: sqlite3.Row) -> dict[str, Any]:
data = dict(row)
if "metadata" in data:
try:
data["metadata"] = json.loads(data["metadata"])
except json.JSONDecodeError:
data["metadata"] = {}
return data
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from uuid import uuid4
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
from tzlocal import get_localzone
from traderai.memory import MemoryStore, iso_now, time_since
class WakeScheduler:
def __init__(self, memory: MemoryStore) -> None:
self.memory = memory
self.scheduler = AsyncIOScheduler(timezone=get_localzone())
self.agent = None
def bind_agent(self, agent: Any) -> None:
self.agent = agent
def start(self) -> None:
if not self.scheduler.running:
self.scheduler.start()
for job in self.memory.list_jobs():
self._schedule_existing(job)
def shutdown(self) -> None:
if self.scheduler.running:
self.scheduler.shutdown(wait=False)
def schedule_date(self, run_at: str, prompt: str, job_id: str | None = None) -> dict[str, Any]:
parsed = datetime.fromisoformat(run_at)
job_id = job_id or f"wake-{uuid4()}"
trigger = DateTrigger(run_date=parsed)
self.scheduler.add_job(self._run_job, trigger=trigger, id=job_id, args=[job_id, prompt], replace_existing=True)
return self.memory.add_job(job_id, prompt, "date", run_at, parsed.isoformat())
def schedule_cron(self, cron: str, prompt: str, job_id: str | None = None) -> dict[str, Any]:
job_id = job_id or f"wake-{uuid4()}"
trigger = CronTrigger.from_crontab(cron)
self.scheduler.add_job(self._run_job, trigger=trigger, id=job_id, args=[job_id, prompt], replace_existing=True)
next_run = self.scheduler.get_job(job_id).next_run_time
return self.memory.add_job(job_id, prompt, "cron", cron, next_run.isoformat() if next_run else None)
def list_jobs(self) -> list[dict[str, Any]]:
return self.memory.list_jobs()
def _schedule_existing(self, job: dict[str, Any]) -> None:
if job["trigger_type"] == "cron":
trigger = CronTrigger.from_crontab(job["trigger_value"])
elif job["trigger_type"] == "date":
trigger = DateTrigger(run_date=datetime.fromisoformat(job["trigger_value"]))
else:
return
self.scheduler.add_job(
self._run_job,
trigger=trigger,
id=job["id"],
args=[job["id"], job["prompt"]],
replace_existing=True,
)
async def _run_job(self, job_id: str, prompt: str) -> None:
last = self.memory.last_interaction()
last_text = f"{last['created_at']} ({time_since(last['created_at'])})" if last else "never"
wake_message = (
f"Scheduled wake job fired. Current time is {iso_now()}. "
f"The last chat interaction was {last_text}. Job instruction: {prompt}"
)
if self.agent is None:
self.memory.add_outbox(wake_message)
return
text = await self.agent.generate_wake_response(wake_message)
self.memory.add_outbox(text)
self.memory.mark_job_run(job_id)
+143
View File
@@ -0,0 +1,143 @@
from __future__ import annotations
from pathlib import Path
import json
from fastapi import FastAPI
from fastapi import HTTPException
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from traderai.agent import OllamaAgent, OllamaUnavailable
from traderai.config import get_settings
from traderai.memory import MemoryStore
from traderai.scheduler import WakeScheduler
from traderai.tools import ToolRegistry
from traderai.uex_client import UEXClient
class ChatRequest(BaseModel):
message: str
class ClearMemoryRequest(BaseModel):
include_memories: bool = True
include_conversations: bool = True
include_profile: bool = False
include_jobs: bool = False
include_outbox: bool = True
def create_app() -> FastAPI:
settings = get_settings()
memory = MemoryStore(settings.traderai_memory_path)
scheduler = WakeScheduler(memory)
uex = UEXClient(settings.uex_base_url, settings.uex_secret_key, settings.uex_bearer_token)
tools = ToolRegistry(uex, settings.require_write_approval, memory=memory, scheduler=scheduler)
agent = OllamaAgent(settings.ollama_base_url, settings.ollama_model, tools, memory=memory, user_name=settings.traderai_user_name)
scheduler.bind_agent(agent)
app = FastAPI(title="TraderAI")
static_dir = Path(__file__).resolve().parent.parent / "web"
app.mount("/static", StaticFiles(directory=static_dir), name="static")
@app.on_event("startup")
async def startup() -> None:
await refresh_user_profile()
scheduler.start()
@app.on_event("shutdown")
async def shutdown() -> None:
scheduler.shutdown()
async def refresh_user_profile() -> None:
if settings.traderai_user_name:
memory.set_profile("configured_name", settings.traderai_user_name)
agent.user_name = agent.user_name or settings.traderai_user_name
try:
response = await uex.get_user(authenticated=True)
except Exception as exc:
memory.set_profile("uex_user_error", str(exc))
if settings.traderai_user_name:
try:
response = await uex.get_user(username=settings.traderai_user_name)
except Exception:
return
else:
return
data = response.get("user")
if data:
memory.set_profile("uex_user", data)
username = data.get("username") or data.get("user_username") or data.get("name")
if username:
agent.user_name = username
@app.get("/")
async def index() -> FileResponse:
return FileResponse(static_dir / "index.html")
@app.get("/api/health")
async def health() -> dict:
return {
"ollama": await agent.health(),
"user": memory.get_profile(),
"jobs": scheduler.list_jobs(),
}
@app.post("/api/chat")
async def chat(request: ChatRequest) -> dict:
try:
return await agent.chat(request.message)
except OllamaUnavailable as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
@app.post("/api/chat/stream")
async def chat_stream(request: ChatRequest) -> StreamingResponse:
async def events():
async for event in agent.chat_events(request.message):
yield f"data: {json.dumps(event)}\n\n"
return StreamingResponse(events(), media_type="text/event-stream")
@app.get("/api/pending-actions")
async def pending_actions() -> dict:
return {"pending_actions": agent._pending_payloads()}
@app.get("/api/notifications")
async def notifications() -> dict:
return {"notifications": memory.undelivered_outbox()}
@app.get("/api/wake-jobs")
async def wake_jobs() -> dict:
return {"scheduled_jobs": scheduler.list_jobs()}
@app.get("/api/memory")
async def inspect_memory(limit: int = 50) -> dict:
return memory.inspect(max(1, min(limit, 200)))
@app.post("/api/memory/clear")
async def clear_memory(request: ClearMemoryRequest) -> dict:
if request.include_jobs:
scheduler.shutdown()
deleted = memory.clear(
include_memories=request.include_memories,
include_conversations=request.include_conversations,
include_profile=request.include_profile,
include_jobs=request.include_jobs,
include_outbox=request.include_outbox,
)
if request.include_jobs:
scheduler.start()
return {"deleted": deleted, "memory": memory.inspect(50)}
@app.post("/api/approve/{action_id}")
async def approve(action_id: str) -> dict:
return await tools.approve(action_id)
return app
app = create_app()
+357
View File
@@ -0,0 +1,357 @@
from __future__ import annotations
import uuid
from dataclasses import dataclass
from typing import Any, Awaitable, Callable
from traderai.memory import MemoryStore
from traderai.scheduler import WakeScheduler
from traderai.uex_client import UEXClient
ToolHandler = Callable[..., Awaitable[dict[str, Any]]]
@dataclass
class PendingAction:
id: str
label: str
endpoint: str
payload: dict[str, Any]
class ToolRegistry:
def __init__(
self,
uex: UEXClient,
require_write_approval: bool = True,
memory: MemoryStore | None = None,
scheduler: WakeScheduler | None = None,
) -> None:
self.uex = uex
self.require_write_approval = require_write_approval
self.memory = memory
self.scheduler = scheduler
self.pending_actions: dict[str, PendingAction] = {}
self.handlers: dict[str, ToolHandler] = {
"search_marketplace_listings": self.search_marketplace_listings,
"get_marketplace_listing": self.get_marketplace_listing,
"list_marketplace_negotiations": self.list_marketplace_negotiations,
"get_negotiation_messages": self.get_negotiation_messages,
"draft_negotiation_message": self.draft_negotiation_message,
"draft_marketplace_listing": self.draft_marketplace_listing,
"remember_user_fact": self.remember_user_fact,
"recall_memory": self.recall_memory,
"schedule_wake_job": self.schedule_wake_job,
"list_wake_jobs": self.list_wake_jobs,
}
@property
def schemas(self) -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": "search_marketplace_listings",
"description": "Search active UEX marketplace listings. UEX returns up to 100 active listings; filters are applied locally.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Text to search in title, description, location, advertiser, or slug."},
"operation": {"type": "string", "enum": ["buy", "sell"]},
"type": {"type": "string", "enum": ["item", "service", "contract"]},
"username": {"type": "string", "description": "Advertiser IGN."},
"location": {"type": "string"},
"min_price": {"type": "number"},
"max_price": {"type": "number"},
"limit": {"type": "integer", "minimum": 1, "maximum": 25},
},
},
},
},
{
"type": "function",
"function": {
"name": "get_marketplace_listing",
"description": "Fetch a specific UEX marketplace listing by id or slug.",
"parameters": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"slug": {"type": "string"},
},
},
},
},
{
"type": "function",
"function": {
"name": "list_marketplace_negotiations",
"description": "List authenticated marketplace negotiations for the configured UEX user.",
"parameters": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"id_listing": {"type": "integer"},
"hash": {"type": "string"},
},
},
},
},
{
"type": "function",
"function": {
"name": "get_negotiation_messages",
"description": "Fetch authenticated messages from a marketplace negotiation by hash or id_negotiation.",
"parameters": {
"type": "object",
"properties": {
"hash": {"type": "string"},
"id_negotiation": {"type": "integer"},
},
},
},
},
{
"type": "function",
"function": {
"name": "draft_negotiation_message",
"description": "Draft a message or offer to a UEX negotiation. This creates a pending action that must be approved before sending.",
"parameters": {
"type": "object",
"required": ["message"],
"properties": {
"message": {"type": "string"},
"hash": {"type": "string"},
"id_negotiation": {"type": "integer"},
"is_production": {"type": "integer", "enum": [0, 1], "default": 0},
},
},
},
},
{
"type": "function",
"function": {
"name": "draft_marketplace_listing",
"description": "Draft a new UEX marketplace listing. This creates a pending action that must be approved before posting.",
"parameters": {
"type": "object",
"required": ["id_category", "operation", "type", "unit", "title", "description", "price", "currency", "language"],
"properties": {
"id_item": {"type": "integer"},
"id_star_system": {"type": "integer"},
"id_organization": {"type": "integer"},
"id_category": {"type": "integer"},
"operation": {"type": "string", "enum": ["buy", "sell"]},
"type": {"type": "string", "enum": ["item", "service", "contract"]},
"unit": {"type": "string"},
"title": {"type": "string"},
"description": {"type": "string"},
"price": {"type": "number"},
"currency": {"type": "string", "enum": ["UEC", "WIF"]},
"language": {"type": "string", "default": "en_US"},
"location": {"type": "string"},
"source": {"type": "string"},
"availability": {"type": "string"},
"in_stock": {"type": "integer"},
"hours_expiration": {"type": "integer"},
"is_hidden": {"type": "integer", "enum": [0, 1]},
"is_production": {"type": "integer", "enum": [0, 1], "default": 0},
},
},
},
},
{
"type": "function",
"function": {
"name": "remember_user_fact",
"description": "Persist a durable user preference, identity detail, trading rule, or long-term note for future chats.",
"parameters": {
"type": "object",
"required": ["content"],
"properties": {
"content": {"type": "string"},
"kind": {"type": "string", "enum": ["user", "preference", "trading", "project", "note"], "default": "note"},
"importance": {"type": "integer", "minimum": 1, "maximum": 5, "default": 3},
},
},
},
},
{
"type": "function",
"function": {
"name": "recall_memory",
"description": "Search long-term memory for relevant prior facts, preferences, and chat context.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer", "minimum": 1, "maximum": 10, "default": 6},
},
},
},
},
{
"type": "function",
"function": {
"name": "schedule_wake_job",
"description": "Create a scheduled wake-up job for the assistant. Use either run_at for one-time jobs or cron for recurring jobs.",
"parameters": {
"type": "object",
"required": ["prompt"],
"properties": {
"prompt": {"type": "string", "description": "What the AI should consider or do when it wakes."},
"run_at": {"type": "string", "description": "ISO datetime for a one-time wake job, such as 2026-05-05T20:30:00-04:00."},
"cron": {"type": "string", "description": "Five-field cron expression for recurring jobs, such as 0 9 * * *."},
},
},
},
},
{
"type": "function",
"function": {
"name": "list_wake_jobs",
"description": "List currently enabled scheduled assistant wake jobs.",
"parameters": {"type": "object", "properties": {}},
},
},
]
async def execute(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
handler = self.handlers.get(name)
if not handler:
return {"error": f"Unknown tool: {name}"}
try:
return await handler(**arguments)
except Exception as exc:
return {"error": str(exc)}
async def approve(self, action_id: str) -> dict[str, Any]:
action = self.pending_actions.pop(action_id, None)
if not action:
return {"error": f"Pending action not found: {action_id}"}
return await self.uex.post(action.endpoint, action.payload, authenticated=True)
async def search_marketplace_listings(
self,
query: str | None = None,
operation: str | None = None,
type: str | None = None,
username: str | None = None,
location: str | None = None,
min_price: float | None = None,
max_price: float | None = None,
limit: int = 10,
) -> dict[str, Any]:
response = await self.uex.get("marketplace_listings", {"username": username})
listings = response.get("data") or []
filtered = []
q = (query or "").casefold()
loc = (location or "").casefold()
for listing in listings:
if operation and listing.get("operation") != operation:
continue
if type and listing.get("type") != type:
continue
if min_price is not None and float(listing.get("price") or 0) < min_price:
continue
if max_price is not None and float(listing.get("price") or 0) > max_price:
continue
if loc and loc not in str(listing.get("location") or "").casefold():
continue
haystack = " ".join(str(listing.get(k) or "") for k in ["title", "description", "location", "user_username", "slug"]).casefold()
if q and q not in haystack:
continue
filtered.append(self._summarize_listing(listing))
if len(filtered) >= max(1, min(limit, 25)):
break
return {"count": len(filtered), "listings": filtered}
async def get_marketplace_listing(self, id: int | None = None, slug: str | None = None) -> dict[str, Any]:
response = await self.uex.get("marketplace_listings", {"id": id, "slug": slug})
return {"listing": response.get("data")}
async def list_marketplace_negotiations(
self,
id: int | None = None,
id_listing: int | None = None,
hash: str | None = None,
) -> dict[str, Any]:
return await self.uex.get("marketplace_negotiations", {"id": id, "id_listing": id_listing, "hash": hash}, authenticated=True)
async def get_negotiation_messages(self, hash: str | None = None, id_negotiation: int | None = None) -> dict[str, Any]:
return await self.uex.get("marketplace_negotiations_messages", {"hash": hash, "id_negotiation": id_negotiation}, authenticated=True)
async def draft_negotiation_message(
self,
message: str,
hash: str | None = None,
id_negotiation: int | None = None,
is_production: int = 0,
) -> dict[str, Any]:
payload = {"message": message, "hash": hash, "id_negotiation": id_negotiation, "is_production": is_production}
return self._pending("Send negotiation message", "marketplace_negotiations_messages", payload)
async def draft_marketplace_listing(self, **payload: Any) -> dict[str, Any]:
return self._pending("Post marketplace listing", "marketplace_advertise", payload)
async def remember_user_fact(self, content: str, kind: str = "note", importance: int = 3) -> dict[str, Any]:
if self.memory is None:
return {"error": "Memory store is not configured."}
return {"memory": self.memory.remember(kind, content, importance)}
async def recall_memory(self, query: str = "", limit: int = 6) -> dict[str, Any]:
if self.memory is None:
return {"error": "Memory store is not configured."}
return {"memories": self.memory.recall(query, max(1, min(limit, 10)))}
async def schedule_wake_job(
self,
prompt: str,
run_at: str | None = None,
cron: str | None = None,
) -> dict[str, Any]:
if self.scheduler is None:
return {"error": "Scheduler is not configured."}
if bool(run_at) == bool(cron):
return {"error": "Provide exactly one of run_at or cron."}
if run_at:
return {"scheduled_job": self.scheduler.schedule_date(run_at, prompt)}
return {"scheduled_job": self.scheduler.schedule_cron(cron or "", prompt)}
async def list_wake_jobs(self) -> dict[str, Any]:
if self.scheduler is None:
return {"error": "Scheduler is not configured."}
return {"scheduled_jobs": self.scheduler.list_jobs()}
def _pending(self, label: str, endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
action_id = str(uuid.uuid4())
payload = {key: value for key, value in payload.items() if value is not None}
self.pending_actions[action_id] = PendingAction(action_id, label, endpoint, payload)
return {
"pending_action": {
"id": action_id,
"label": label,
"endpoint": endpoint,
"payload": payload,
"approval_required": self.require_write_approval,
}
}
@staticmethod
def _summarize_listing(listing: dict[str, Any]) -> dict[str, Any]:
return {
"id": listing.get("id"),
"slug": listing.get("slug"),
"title": listing.get("title"),
"operation": listing.get("operation"),
"type": listing.get("type"),
"price": listing.get("price"),
"currency": listing.get("currency"),
"unit": listing.get("unit"),
"location": listing.get("location"),
"availability": listing.get("availability"),
"in_stock": listing.get("in_stock"),
"advertiser": listing.get("user_username"),
"expires_at": listing.get("date_expiration"),
}
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
from typing import Any
import httpx
class UEXError(RuntimeError):
pass
class UEXClient:
def __init__(self, base_url: str, secret_key: str | None = None, bearer_token: str | None = None) -> None:
self.base_url = base_url.rstrip("/")
self.secret_key = secret_key
self.bearer_token = bearer_token
def _headers(self, authenticated: bool = False) -> dict[str, str]:
headers = {"Accept": "application/json"}
if authenticated:
if not self.secret_key and not self.bearer_token:
raise UEXError("UEX_SECRET_KEY or UEX_BEARER_TOKEN is required for this action.")
if self.secret_key:
headers["secret-key"] = self.secret_key
if self.bearer_token:
headers["Authorization"] = f"Bearer {self.bearer_token}"
return headers
async def get(self, path: str, params: dict[str, Any] | None = None, authenticated: bool = False) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(
f"{self.base_url}/{path.strip('/')}/",
params={k: v for k, v in (params or {}).items() if v is not None},
headers=self._headers(authenticated),
)
return self._handle_response(response)
async def get_user(self, username: str | None = None, authenticated: bool = False) -> dict[str, Any]:
body = await self.get("user", {"username": username}, authenticated=authenticated)
data = body.get("data")
if isinstance(data, list):
data = data[0] if data else None
return {"status": body.get("status"), "user": data}
async def post(self, path: str, payload: dict[str, Any], authenticated: bool = True) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
f"{self.base_url}/{path.strip('/')}/",
json=payload,
headers=self._headers(authenticated),
)
return self._handle_response(response)
@staticmethod
def _handle_response(response: httpx.Response) -> dict[str, Any]:
try:
body = response.json()
except ValueError as exc:
raise UEXError(f"UEX returned non-JSON response: HTTP {response.status_code}") from exc
if response.status_code >= 400:
raise UEXError(f"UEX HTTP {response.status_code}: {body}")
return body