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:
marauder-actual
2026-05-30 10:32:58 +02:00
parent a783da7415
commit 66544f427d
6 changed files with 24 additions and 386 deletions
+4 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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"])
-110
View File
@@ -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
View File
@@ -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
View File
@@ -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