From 66544f427da272c64198c7f444f294442aca523b Mon Sep 17 00:00:00 2001 From: marauder-actual Date: Sat, 30 May 2026 10:32:58 +0200 Subject: [PATCH] remove redundant marauder wrappers and anthropic dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 6 +- app/calibration.py | 20 ++++- app/main.py | 98 +----------------------- app/marauder_cart.py | 110 --------------------------- app/memory.py | 174 ------------------------------------------- pyproject.toml | 2 +- 6 files changed, 24 insertions(+), 386 deletions(-) delete mode 100644 app/marauder_cart.py delete mode 100644 app/memory.py diff --git a/.env.example b/.env.example index d599a30..f577ba3 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/calibration.py b/app/calibration.py index dcb5c33..69d2053 100644 --- a/app/calibration.py +++ b/app/calibration.py @@ -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: """`-` — 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 diff --git a/app/main.py b/app/main.py index 07094c7..3d394df 100644 --- a/app/main.py +++ b/app/main.py @@ -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"]) diff --git a/app/marauder_cart.py b/app/marauder_cart.py deleted file mode 100644 index fd8baee..0000000 --- a/app/marauder_cart.py +++ /dev/null @@ -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: `-` — 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 diff --git a/app/memory.py b/app/memory.py deleted file mode 100644 index a9c0e39..0000000 --- a/app/memory.py +++ /dev/null @@ -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 - ) diff --git a/pyproject.toml b/pyproject.toml index 482f7bc..10291b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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