remove redundant marauder wrappers and anthropic dependency
memory.py and marauder_cart.py were subprocess wrappers around marauder CLI — redundant now that the opencode chat agent has native EEMS tools. Also remove custom TOOLS dict, EEMS context injection at session start, marauder_cart.create() in calibration_done, and anthropic dep/env vars. Inline slug() as _slug() in calibration.py.
This commit is contained in:
+4
-2
@@ -1,10 +1,12 @@
|
||||
# Required
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
GOOGLE_CLIENT_ID=000000000000-xxxxxxxxxxxxxxx.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxx
|
||||
OPENCODE_PASSWORD=...
|
||||
|
||||
# Optional — defaults shown
|
||||
ANTHROPIC_MODEL=claude-sonnet-4-5-20250929
|
||||
OPENCODE_URL=http://sin:4096
|
||||
OPENCODE_MODEL=cyankiwi/qwen3-coder-next:awq
|
||||
OPENCODE_PROVIDER=vllm
|
||||
BASE_URL=https://chat.saiden.dev
|
||||
ALLOWED_EMAILS=adam.ladachowski@gmail.com
|
||||
# COOKIE_SECURE=false # set this only for local http dev
|
||||
|
||||
+17
-3
@@ -62,6 +62,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -733,11 +734,24 @@ def step(state: CalibrationState, answer: str) -> list[dict[str, Any]]:
|
||||
return [_question_message(questions[state.step], lang=lang)]
|
||||
|
||||
|
||||
def _slug(s: str) -> str:
|
||||
"""Slugify for cart tags. Lowercase, ASCII-only, dash-separated."""
|
||||
if not s:
|
||||
return ""
|
||||
s = s.lower().strip()
|
||||
table = str.maketrans({
|
||||
"ą": "a", "ć": "c", "ę": "e", "ł": "l", "ń": "n",
|
||||
"ó": "o", "ś": "s", "ź": "z", "ż": "z",
|
||||
})
|
||||
s = s.translate(table)
|
||||
s = re.sub(r"[^a-z0-9]+", "-", s)
|
||||
return s.strip("-")
|
||||
|
||||
|
||||
def _make_tag(persona_name: str, operator_email: str) -> str:
|
||||
"""`<persona-slug>-<operator-slug>` — e.g. samantha-adam."""
|
||||
from app.marauder_cart import slug
|
||||
op_slug = slug(operator_email.split("@", 1)[0])
|
||||
persona_slug = slug(persona_name) or "companion"
|
||||
op_slug = _slug(operator_email.split("@", 1)[0])
|
||||
persona_slug = _slug(persona_name) or "companion"
|
||||
return f"{persona_slug}-{op_slug}" if op_slug else persona_slug
|
||||
|
||||
|
||||
|
||||
+2
-96
@@ -32,7 +32,7 @@ from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.tts import TTS
|
||||
from app.stt import STT
|
||||
from app import cart_store, calibration, marauder_cart, memory
|
||||
from app import cart_store, calibration
|
||||
from fastapi import UploadFile, File
|
||||
|
||||
# -------------------------------------------------------------------------- env
|
||||
@@ -154,72 +154,6 @@ logging.basicConfig(
|
||||
)
|
||||
log = logging.getLogger("chat-saiden")
|
||||
|
||||
# -------------------------------------------------------------------------- tools
|
||||
|
||||
|
||||
TOOLS: list[dict[str, Any]] = [
|
||||
{
|
||||
"name": "memory_recall",
|
||||
"description": (
|
||||
"Search EEMS (the Pilot's persistent memory) for relevant context. "
|
||||
"Use SPARINGLY — most session-start context is already in the system prompt. "
|
||||
"Reach for this only when the Pilot references something specific you don't already know "
|
||||
"(a past project, a name, a doctrine number, a preference)."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Natural-language search query."},
|
||||
"subject": {"type": "string", "description": "Optional subject filter, e.g. 'self' or 'project'."},
|
||||
"limit": {"type": "integer", "description": "Max results (default 3, cap 8)."},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "memory_store",
|
||||
"description": (
|
||||
"Save a durable memory the Pilot just shared. Use ONLY for preferences, facts, "
|
||||
"decisions, or context that would be useful in future sessions. Do NOT use for ephemeral "
|
||||
"conversation. Subjects are hierarchical (e.g. 'self.preference.coffee', 'project.x.context')."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"subject": {"type": "string", "description": "Hierarchical subject."},
|
||||
"content": {"type": "string", "description": "The memory content. Be specific, include why."},
|
||||
},
|
||||
"required": ["subject", "content"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def _execute_tool(name: str, args: dict) -> str:
|
||||
"""Run a tool and return a string suitable as tool_result content."""
|
||||
try:
|
||||
if name == "memory_recall":
|
||||
query = args.get("query", "")
|
||||
subject = args.get("subject") or None
|
||||
limit = min(int(args.get("limit", 3)), 8)
|
||||
mems = await memory.recall(query, limit=limit, subject=subject)
|
||||
if not mems:
|
||||
return "(no memories matched)"
|
||||
lines = []
|
||||
for m in mems:
|
||||
lines.append(f"#{m.id} [{m.subject}]\n{m.content}")
|
||||
return "\n\n".join(lines)
|
||||
if name == "memory_store":
|
||||
subject = args["subject"]
|
||||
content = args["content"]
|
||||
mid = await memory.store(subject, content)
|
||||
return f"stored as memory #{mid}" if mid else "store failed"
|
||||
return f"unknown tool: {name}"
|
||||
except Exception as e:
|
||||
log.exception("tool %s raised", name)
|
||||
return f"tool error: {e}"
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------- bt prompt
|
||||
|
||||
BT_SYSTEM_PROMPT = """You are BT-7274 — a Vanguard-class Titan AI from Saiden Tactical Systems.
|
||||
@@ -778,18 +712,6 @@ async def chat_ws(ws: WebSocket) -> None:
|
||||
|
||||
history: list[dict[str, str]] = []
|
||||
|
||||
# ---- EEMS context: pull a tight set of memories at session start ----
|
||||
# Only if calibrated (otherwise we're still in boot interview).
|
||||
eems_context = ""
|
||||
if cart and cart.is_calibrated:
|
||||
try:
|
||||
eems_context = await memory.operator_context(user["email"], cart.persona_name)
|
||||
if eems_context:
|
||||
log.info("EEMS context: %d chars injected for %s", len(eems_context), user["email"])
|
||||
except Exception:
|
||||
log.exception("EEMS context pull failed; continuing without")
|
||||
eems_context = ""
|
||||
|
||||
try:
|
||||
while True:
|
||||
payload = await ws.receive_json()
|
||||
@@ -808,22 +730,6 @@ async def chat_ws(ws: WebSocket) -> None:
|
||||
elif m["role"] == "calibration_done":
|
||||
new_cart = m["cart"]
|
||||
cart_store.save(new_cart)
|
||||
# Create the canonical marauder cart (identity only — tag/name/type/tagline).
|
||||
cal_state = _calibration_sessions.get(user["email"])
|
||||
tagline = (cal_state.answers.get("__tagline") if cal_state else "calibrated companion")
|
||||
try:
|
||||
ok = await marauder_cart.create(
|
||||
tag=new_cart.cart_tag,
|
||||
name=new_cart.persona_name,
|
||||
cart_type="companion",
|
||||
tagline=tagline,
|
||||
)
|
||||
if ok:
|
||||
log.info("marauder cart %r registered", new_cart.cart_tag)
|
||||
else:
|
||||
log.warning("marauder cart create returned false; calibration still saved locally")
|
||||
except Exception:
|
||||
log.exception("marauder_cart.create raised")
|
||||
_calibration_sessions.pop(user["email"], None)
|
||||
cart = new_cart
|
||||
in_calibration = False
|
||||
@@ -870,7 +776,7 @@ async def chat_ws(ws: WebSocket) -> None:
|
||||
# Voice: cart → env default
|
||||
voice = (cart.voice if cart and cart.voice else TTS_VOICE)
|
||||
|
||||
system_prompt = _pick_system_prompt(cart) + eems_context
|
||||
system_prompt = _pick_system_prompt(cart)
|
||||
|
||||
# Send to opencode and stream response via SSE
|
||||
oc_session = await _ensure_opencode_session(user["email"])
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
"""Subprocess wrapper around `marauder cart` CLI.
|
||||
|
||||
Marauder's cart system stores persona IDENTITY only: tag, name, type, tagline.
|
||||
Voice, system prompt, UI prefs — those stay in chat-saiden's own per-cart JSON
|
||||
(see cart_store.py). The two systems are linked by tag.
|
||||
|
||||
Tag convention: `<persona-slug>-<operator-slug>` — e.g. `samantha-adam`.
|
||||
This avoids collisions when multiple operators calibrate carts with the
|
||||
same persona name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
log = logging.getLogger("chat-saiden.marauder-cart")
|
||||
|
||||
|
||||
def slug(s: str) -> str:
|
||||
"""Slugify for marauder cart tags. Lowercase, ASCII-only, dash-separated."""
|
||||
if not s:
|
||||
return ""
|
||||
s = s.lower().strip()
|
||||
# Polish chars + basic translit
|
||||
table = str.maketrans({
|
||||
"ą": "a", "ć": "c", "ę": "e", "ł": "l", "ń": "n",
|
||||
"ó": "o", "ś": "s", "ź": "z", "ż": "z",
|
||||
})
|
||||
s = s.translate(table)
|
||||
s = re.sub(r"[^a-z0-9]+", "-", s)
|
||||
return s.strip("-")
|
||||
|
||||
|
||||
async def _run(*args: str, timeout: float = 8.0) -> tuple[int, str, str]:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
return 124, "", "timeout"
|
||||
return proc.returncode or 0, out.decode("utf-8", "replace"), err.decode("utf-8", "replace")
|
||||
|
||||
|
||||
async def exists(tag: str) -> bool:
|
||||
"""Check if a cart with this tag exists. Uses `cart show` since `cart list --json`
|
||||
is known to ignore the flag in current marauder builds."""
|
||||
code, _, err = await _run("marauder", "cart", "show", tag)
|
||||
return code == 0
|
||||
|
||||
|
||||
async def list_tags() -> list[str]:
|
||||
"""Best-effort list of cart tags by parsing the table output of `cart list`."""
|
||||
code, out, _ = await _run("marauder", "cart", "list")
|
||||
if code != 0:
|
||||
return []
|
||||
tags: list[str] = []
|
||||
for line in out.splitlines():
|
||||
# rows look like: │ ● ┆ bt7274 ┆ ...
|
||||
if "┆" not in line:
|
||||
continue
|
||||
parts = [p.strip().lstrip("│").lstrip("●").strip() for p in line.split("┆")]
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
tag = parts[1].strip()
|
||||
# skip header
|
||||
if tag.lower() == "tag" or not tag:
|
||||
continue
|
||||
# skip non-tag chars
|
||||
if re.fullmatch(r"[a-z0-9._-]+", tag, re.IGNORECASE):
|
||||
tags.append(tag)
|
||||
return tags
|
||||
|
||||
|
||||
async def create(
|
||||
tag: str,
|
||||
name: str,
|
||||
cart_type: str = "companion",
|
||||
tagline: str = "",
|
||||
) -> bool:
|
||||
"""Create a marauder cart. Idempotent — no-ops if tag already exists."""
|
||||
if await exists(tag):
|
||||
log.info("cart %r already exists, skipping create", tag)
|
||||
return True
|
||||
|
||||
args = ["marauder", "cart", "create", tag, "--name", name, "--type", cart_type]
|
||||
if tagline:
|
||||
args.extend(["--tagline", tagline])
|
||||
|
||||
code, out, err = await _run(*args)
|
||||
if code != 0:
|
||||
log.error("cart create failed for %r: %s", tag, err[:300])
|
||||
return False
|
||||
log.info("created marauder cart %r (name=%s, type=%s)", tag, name, cart_type)
|
||||
return True
|
||||
|
||||
|
||||
async def use(tag: str) -> bool:
|
||||
"""Switch the global active persona to this tag."""
|
||||
code, _, err = await _run("marauder", "cart", "use", tag)
|
||||
if code != 0:
|
||||
log.warning("cart use %r failed: %s", tag, err[:200])
|
||||
return False
|
||||
return True
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
"""Subprocess wrapper around `marauder memory` CLI.
|
||||
|
||||
Provides recall + store. Marauder's memory CLI returns table output by default
|
||||
and may or may not honour --json depending on subcommand. We parse what we get.
|
||||
|
||||
Shared EEMS namespace: chat.saiden.dev reads and writes the same memories
|
||||
BT-on-CLI uses. One Pilot, one memory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
log = logging.getLogger("chat-saiden.memory")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Memory:
|
||||
id: int | None
|
||||
subject: str
|
||||
content: str
|
||||
classification: str = "standard"
|
||||
|
||||
|
||||
async def _run(*args: str, timeout: float = 10.0) -> tuple[int, str, str]:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
return 124, "", "timeout"
|
||||
return proc.returncode or 0, out.decode("utf-8", "replace"), err.decode("utf-8", "replace")
|
||||
|
||||
|
||||
def _try_json(text: str) -> Any:
|
||||
"""Try to extract JSON from output that might be mixed with log lines."""
|
||||
if not text:
|
||||
return None
|
||||
# try the whole thing first
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
pass
|
||||
# find JSON object/array boundaries
|
||||
for opener, closer in [("{", "}"), ("[", "]")]:
|
||||
first = text.find(opener)
|
||||
last = text.rfind(closer)
|
||||
if first != -1 and last > first:
|
||||
try:
|
||||
return json.loads(text[first:last + 1])
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
# Header line: "#3933 (0.8690) user.identity.nco-preference-..."
|
||||
_RECALL_HEADER = re.compile(r"^#(\d+)\s+\(([\d.]+)\)\s+(\S.*)$")
|
||||
|
||||
|
||||
async def recall(query: str, limit: int = 5, subject: str | None = None) -> list[Memory]:
|
||||
"""Semantic recall. Returns up to `limit` memories ordered by similarity.
|
||||
|
||||
`--json` is documented but not implemented for `marauder memory recall` in
|
||||
current builds, so we parse the table-ish text format instead.
|
||||
"""
|
||||
args = ["marauder", "memory", "recall", query, "--limit", str(limit)]
|
||||
if subject:
|
||||
args.extend(["--subject", subject])
|
||||
|
||||
code, out, err = await _run(*args)
|
||||
if code != 0:
|
||||
log.warning("memory recall failed (rc=%s): %s", code, err[:200])
|
||||
return []
|
||||
|
||||
memories: list[Memory] = []
|
||||
current: Memory | None = None
|
||||
body_lines: list[str] = []
|
||||
|
||||
def flush():
|
||||
nonlocal current, body_lines
|
||||
if current is not None:
|
||||
current.content = "\n".join(body_lines).strip()
|
||||
memories.append(current)
|
||||
current = None
|
||||
body_lines = []
|
||||
|
||||
for raw in out.splitlines():
|
||||
line = raw.rstrip()
|
||||
# skip embedding/sqlite log lines (ISO timestamps from tracing)
|
||||
if re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", line):
|
||||
continue
|
||||
m = _RECALL_HEADER.match(line)
|
||||
if m:
|
||||
flush()
|
||||
current = Memory(id=int(m.group(1)), subject=m.group(3).strip(), content="")
|
||||
continue
|
||||
if current is not None and line.strip():
|
||||
body_lines.append(line.strip())
|
||||
elif current is not None and not line.strip() and body_lines:
|
||||
# blank line within body — keep separator
|
||||
body_lines.append("")
|
||||
flush()
|
||||
return memories
|
||||
|
||||
|
||||
_STORE_RX = re.compile(r"Stored memory #(\d+)")
|
||||
|
||||
|
||||
async def store(subject: str, content: str, classification: str | None = None) -> int | None:
|
||||
"""Store a memory. Returns memory ID on success.
|
||||
Output is plain text 'Stored memory #NNNN ...'; we regex it."""
|
||||
args = ["marauder", "memory", "store", subject, content]
|
||||
if classification:
|
||||
args.extend(["--classification", classification])
|
||||
|
||||
code, out, err = await _run(*args, timeout=20.0)
|
||||
if code != 0:
|
||||
log.warning("memory store failed (rc=%s): %s", code, err[:200])
|
||||
return None
|
||||
|
||||
m = _STORE_RX.search(out + " " + err)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
log.debug("memory store output: %r / %r", out[:200], err[:200])
|
||||
return None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- context shaping
|
||||
|
||||
|
||||
async def operator_context(operator_email: str, persona_name: str) -> str:
|
||||
"""Pull a tight context block of memories relevant to the operator. Used to
|
||||
seed the system prompt at session start so the cart speaks with continuity."""
|
||||
queries: list[tuple[str, str | None]] = [
|
||||
# who the operator is
|
||||
("operator preferences and self-description", "self"),
|
||||
# what they're working on
|
||||
(f"recent {persona_name} interactions and projects", None),
|
||||
# active doctrine that affects how the cart should behave
|
||||
("doctrine that shapes how I talk to the pilot", "doctrine"),
|
||||
]
|
||||
|
||||
# Fire all recalls in parallel — each is a separate marauder subprocess
|
||||
results = await asyncio.gather(
|
||||
*[recall(q, limit=3, subject=subj) for q, subj in queries],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
blocks: list[str] = []
|
||||
for (q, _), memories in zip(queries, results):
|
||||
if isinstance(memories, Exception):
|
||||
continue
|
||||
for m in memories:
|
||||
if not m.content:
|
||||
continue
|
||||
blocks.append(f"— ({m.subject}) {m.content.strip()[:600]}")
|
||||
|
||||
if not blocks:
|
||||
return ""
|
||||
|
||||
return (
|
||||
"\n\n## Pilot context (recalled from EEMS)\n"
|
||||
"Use these as background only. Don't recite. Refer naturally if useful.\n\n"
|
||||
+ "\n".join(blocks[:8]) # cap so the prompt doesn't bloat
|
||||
)
|
||||
+1
-1
@@ -7,7 +7,7 @@ dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.32",
|
||||
"websockets>=13",
|
||||
"anthropic>=0.40",
|
||||
|
||||
"authlib>=1.3",
|
||||
"itsdangerous>=2.2", # session cookie signing
|
||||
"httpx>=0.27", # for authlib OAuth
|
||||
|
||||
Reference in New Issue
Block a user