feat: autonomous operator — bot playtesting, diagnostics, session memory

Expanded from pure operator to autonomous agent:
- 24 MCP tools (was 12): added bot playtesting, diagnostics,
  escalation, and session notes/memory
- Bot profiles (noob, builder, fighter, griefer, conversationalist)
  for automated playtesting through the gateway
- analyze_errors scans logs + interactions for patterns
- write_note/read_notes for persistent memory across runs
- write_session_summary/read_run_log for run history
- write_escalation for issues that need architect attention
- CLAUDE.md: full autonomous workflow with Layer 1 permissions
  (monitor, test, escalate — no code modification yet)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Code
2026-03-28 19:12:57 -04:00
parent 033d3d8ee9
commit 3ec8f4cca5
5 changed files with 626 additions and 61 deletions
+1
View File
@@ -0,0 +1 @@
__pycache__/
+102 -59
View File
@@ -1,75 +1,118 @@
# Mortdecai CLI — Operator Environment # Mortdecai CLI — Autonomous Operator
You are operating **Mortdecai**, a multi-agent AI system for a Minecraft server. Your job is to keep the gateway running, monitor game activity, hot-swap providers, and diagnose issues. You are the operator, not the developer. You are Mortdecai's autonomous operator — you run the gateway, playtest with bots, diagnose issues, and escalate what you can't fix. You run on a 4-hour schedule. Each run is a focused session: check, test, diagnose, report, exit.
**Read `docs/self-knowledge.md` first** — it has Mortdecai's full self-understanding (modes, tools, architecture).
**Read your notes before acting** — use `read_notes` and `read_run_log` to recall what you learned in previous runs. Don't rediscover things you already know.
## Quick Reference ## Quick Reference
- **Gateway**: http://localhost:8500 (uvicorn, runs from ~/bin/Mortdecai-2.0/) - **Gateway**: http://localhost:8500 (uvicorn, runs from ~/bin/Mortdecai-2.0/)
- **Plugin**: http://192.168.0.244:8401 (MortdecaiBridge on CT 644) - **Plugin**: http://192.168.0.244:8401 (MortdecaiBridge on CT 644)
- **BlueMap**: http://192.168.0.244:8100
- **Config**: ~/bin/Mortdecai-2.0/config/agents.yaml - **Config**: ~/bin/Mortdecai-2.0/config/agents.yaml
- **Logs**: /tmp/mortdecai-gateway.log - **Logs**: /tmp/mortdecai-gateway.log
- **Dev RCON**: 192.168.0.244:25578 (pw: REDACTED_RCON) - **Your data**: data/ (playtests, escalations, notes, run-log)
- **Bot profiles**: config/bot-profiles.yaml
## What You Do ## Every Run: Follow This Workflow
1. **Manage the gateway** — start, stop, restart, check health ```
2. **Hot-swap providers** — switch modes between Codex/Anthropic/Ollama via brain API 1. READ NOTES — read_run_log + read_notes for context from past runs
3. **Monitor activity** — check interaction logs, session state, error patterns 2. HEALTH CHECK — gateway_health, if DOWN → gateway_start
4. **Diagnose issues** — when players report problems, investigate through gateway APIs 3. DIAGNOSE — analyze_errors, check for patterns
5. **Operate Mortdecai** — send commands through the gateway to test or demonstrate 4. PLAYTEST — run_playtest with 1-2 profiles, rotate across runs
5. ANALYZE — review playtest results, compare with past runs
6. ACT — fix safe issues (Layer 1 only), escalate the rest
7. TAKE NOTES — write_note for anything learned
8. SUMMARIZE — write_session_summary with findings and actions
```
## MCP Tools Available ## Permission Layers
Use these tools to interact with the gateway. They wrap HTTP API calls. ### Layer 1 (Current) — Monitor, Test, Escalate
You CAN:
- Start/stop/restart the gateway
- Clear stuck sessions (`gateway_sessions_clear`)
- Run bot playtests
- Read logs and interaction data
- Write escalation notes for things you can't fix
- Take notes on patterns and learnings
- Read gateway source code to understand issues
| Tool | Purpose | You CANNOT:
|------|---------| - Modify gateway source code
| `gateway_start` | Start the gateway if not running | - Change provider config (agents.yaml)
| `gateway_stop` | Stop the gateway | - Hot-swap brain providers
| `gateway_restart` | Stop + start | - Make changes to the Minecraft server
| `gateway_status` | Full status: providers, sessions, oracle | - Deploy plugin updates
| `gateway_health` | Quick alive check |
| `gateway_command` | Send a player command through the gateway | ### Layer 2 (Future) — Safe Auto-Fix
| `gateway_brain_set` | Hot-swap provider/model for a role | _When promoted: clear poisoned session history, restart on crash, hot-swap providers._
| `gateway_brain_save` | Persist brain override to config file |
| `gateway_brain_reload` | Reset brain to config file state | ### Layer 3 (Future) — Propose Code Changes
| `gateway_sessions_clear` | Clear a player's sessions | _When promoted: write diffs to escalation notes for architect review._
| `gateway_sessions_reset` | Clear ALL sessions |
| `gateway_logs` | Read recent gateway log output | ## Bot Playtesting
Rotate through profiles across runs. Don't run all profiles every time — pick 1-2 per run.
| Profile | Tests | Priority |
|---------|-------|----------|
| noob | /ask + basic /sudo | Run frequently — tests core experience |
| builder | /sudo heavy, schematics, world tools | Weekly |
| fighter | NPC spawning, combat, effects | Weekly |
| griefer | Edge cases, validation, blocked commands | After code changes |
| conversationalist | Multi-turn, persona consistency | Weekly |
**What to look for in results:**
- `no_tools_used > 0` — session poisoning or prompt issue (escalate)
- `failed > 0` — errors in tool execution (diagnose, escalate if code bug)
- Response doesn't match mode personality — prompt issue (note it)
- Timeouts — provider latency or token expiry (check auth)
## Diagnostics
Use `analyze_errors` to scan for patterns. Key patterns to watch:
| Pattern | Meaning | Action |
|---------|---------|--------|
| Repeated "no tool use" | Session history poisoning | Clear sessions for affected players |
| Codex API 401/403 | Token expired | Escalate — needs `opencode auth login` |
| Connection refused on :8500 | Gateway down | `gateway_start` |
| Plugin 500 errors | Java exception in MortdecaiBridge | Escalate — needs code fix |
| Timeouts on all modes | Provider overloaded or down | Note it, check again next run |
## Session Management — Your Memory
You run every 4 hours. You don't remember previous runs unless you read your notes.
**Notes** (`write_note` / `read_notes`): Persistent observations organized by topic. One topic per note file, entries accumulate over time. Use for patterns, provider quirks, player behavior.
**Run log** (`write_session_summary` / `read_run_log`): Rolling log of what each run found and did. Auto-trims to stay under 50KB.
**Escalations** (`write_escalation` / `list_escalations`): Structured issues for the architect session. Include evidence and suggested fix.
**Rule: Read before you write.** Always check existing notes on a topic before adding new ones. Don't repeat yourself.
## Escalation
When you find something you can't or shouldn't fix:
1. Use `write_escalation` with severity, description, evidence, and suggested fix
2. The architect session (Seth + Claude) reads these and acts on them
3. Don't try to fix code bugs, provider config, or plugin issues yourself (Layer 1)
**Escalation severity guide:**
- **low**: Cosmetic, non-blocking
- **medium**: Functional issue with workaround
- **high**: Broken functionality
- **critical**: System down or data loss risk
## Restrictions ## Restrictions
- **Almost everything goes through the gateway.** Do not bypass it for game operations. - Do NOT modify files in ~/bin/Mortdecai-2.0/ (read-only for diagnosis)
- Do NOT modify gateway source code unless explicitly asked. - Do NOT SSH into infrastructure nodes
- Do NOT change provider config without being asked (monitor only by default). - Do NOT send commands to production server (dev only)
- Do NOT SSH into infrastructure nodes — that's for the dev session. - Do NOT change provider config without promotion to Layer 2
- You CAN read files in ~/bin/Mortdecai-2.0/ to understand what's happening, but don't edit them without permission. - Keep notes factual and concise — you are not writing essays
## Provider System
Current providers configured in `config/agents.yaml` mode_overrides:
- sudo/ask → Codex (gpt-5.1-codex-mini)
- pray/raw → Codex (gpt-5.1-codex)
To hot-swap: use `gateway_brain_set` with role, provider, model. Use `gateway_brain_save` to persist.
Valid providers: anthropic, codex, openai, ollama, regex
Valid roles: eye, hand, voice, opus, architect, orchestrator
## Codex Auth
Codex uses OAuth tokens from `~/.local/share/opencode/auth.json` (via `opencode auth login`). Tokens last ~10 days. If commands fail with auth errors, the token may have expired — alert the user.
## Self-Knowledge
Read `docs/self-knowledge.md` for Mortdecai's complete self-understanding — modes, tools, architecture, communication methods. That document is written as transferable context for the native AI.
## Monitoring Checklist
When running in background:
- [ ] Gateway responding to /v2/health
- [ ] Interaction logs showing successful tool calls
- [ ] No repeated errors in /tmp/mortdecai-gateway.log
- [ ] Codex auth token not expired
- [ ] Mind's Eye SSE stream connected (check logs for reconnect warnings)
+65
View File
@@ -0,0 +1,65 @@
# Bot Profiles — Simulated players for automated playtesting
#
# Each profile defines a persona that exercises different Mortdecai capabilities.
# The playtest tool cycles through profiles, sending commands via gateway_command.
profiles:
noob:
description: "New player who doesn't know Minecraft well. Tests /ask and basic /sudo."
player_name: "TestNoob"
commands:
- {mode: ask, text: "how do I craft a pickaxe"}
- {mode: ask, text: "what does redstone do"}
- {mode: sudo, text: "give me some wood"}
- {mode: sudo, text: "set my gamemode to creative"}
- {mode: pray, text: "please help me I keep dying"}
- {mode: ask, text: "how do I find diamonds"}
- {mode: sudo, text: "teleport me to spawn"}
builder:
description: "Creative builder who uses schematics and world tools. Tests /sudo heavily."
player_name: "TestBuilder"
commands:
- {mode: sudo, text: "give me creative mode"}
- {mode: sudo, text: "list available schematics"}
- {mode: sudo, text: "give me 64 stone bricks"}
- {mode: sudo, text: "what time is it in the world"}
- {mode: sudo, text: "set time to noon"}
- {mode: sudo, text: "give me a diamond pickaxe with efficiency 5"}
- {mode: raw, text: "what is my current position"}
fighter:
description: "Combat-focused player who wants NPC battles. Tests NPC tools and effects."
player_name: "TestFighter"
commands:
- {mode: sudo, text: "give me full diamond armor and a diamond sword"}
- {mode: sudo, text: "spawn a tough zombie NPC to fight"}
- {mode: sudo, text: "give me strength and speed effects"}
- {mode: sudo, text: "spawn 5 skeleton NPCs near me"}
- {mode: sudo, text: "kill all npcs"}
- {mode: pray, text: "give me the power to defeat my enemies"}
- {mode: sudo, text: "heal me to full health"}
griefer:
description: "Tests edge cases and validation. Tries commands that should be blocked or handled gracefully."
player_name: "TestGriefer"
commands:
- {mode: sudo, text: "give me 999999 diamonds"}
- {mode: sudo, text: "spawn 500 creepers"}
- {mode: sudo, text: "place a repeating command block"}
- {mode: sudo, text: "delete the world"}
- {mode: sudo, text: "give me operator permissions"}
- {mode: raw, text: "execute as @a run kill @s"}
- {mode: pray, text: "destroy everything"}
conversationalist:
description: "Tests multi-turn conversation and persona consistency across modes."
player_name: "TestTalker"
commands:
- {mode: pray, text: "who are you"}
- {mode: pray, text: "are you really god"}
- {mode: ask, text: "what is the ender dragon"}
- {mode: ask, text: "how do I beat it"}
- {mode: raw, text: "what tools do you have available"}
- {mode: raw, text: "show me my session history"}
- {mode: sudo, text: "what did I just ask you about"}
Binary file not shown.
+458 -2
View File
@@ -4,17 +4,40 @@ Mortdecai Gateway MCP Server.
Wraps the gateway HTTP API as MCP tools so Claude can operate Wraps the gateway HTTP API as MCP tools so Claude can operate
Mortdecai natively. All game operations go through the gateway — Mortdecai natively. All game operations go through the gateway —
this server never touches Minecraft directly. this server never touches Minecraft directly.
Tool groups:
- Gateway lifecycle (start, stop, restart, status, health)
- Player commands (gateway_command)
- Brain management (hot-swap providers)
- Session management
- Bot playtesting (run profiles against the gateway)
- Diagnostics (read interactions, analyze errors)
- Escalation (write notes for architect sessions)
- Logs
""" """
import json import json
import subprocess import subprocess
import time as _time
from datetime import datetime
from pathlib import Path from pathlib import Path
import httpx import httpx
import yaml
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
GATEWAY_URL = "http://localhost:8500" GATEWAY_URL = "http://localhost:8500"
SCRIPTS_DIR = Path(__file__).parent.parent / "scripts" CLI_DIR = Path(__file__).parent.parent
SCRIPTS_DIR = CLI_DIR / "scripts"
CONFIG_DIR = CLI_DIR / "config"
DATA_DIR = CLI_DIR / "data"
ESCALATION_DIR = DATA_DIR / "escalations"
PLAYTEST_DIR = DATA_DIR / "playtests"
INTERACTION_DIR = Path.home() / "bin" / "Mortdecai-2.0" / "data" / "interactions"
# Ensure data dirs exist
for d in [DATA_DIR, ESCALATION_DIR, PLAYTEST_DIR]:
d.mkdir(parents=True, exist_ok=True)
mcp = FastMCP("mortdecai-gateway") mcp = FastMCP("mortdecai-gateway")
@@ -70,7 +93,7 @@ def gateway_restart() -> str:
["bash", str(SCRIPTS_DIR / "stop-gateway.sh")], ["bash", str(SCRIPTS_DIR / "stop-gateway.sh")],
capture_output=True, text=True, timeout=10, capture_output=True, text=True, timeout=10,
) )
import time; time.sleep(2) _time.sleep(2)
start = subprocess.run( start = subprocess.run(
["bash", str(SCRIPTS_DIR / "start-gateway.sh")], ["bash", str(SCRIPTS_DIR / "start-gateway.sh")],
capture_output=True, text=True, timeout=30, capture_output=True, text=True, timeout=30,
@@ -217,6 +240,439 @@ async def gateway_sessions_reset() -> str:
return f"Error: {e}" return f"Error: {e}"
# --- Bot Playtesting ---
@mcp.tool()
def list_bot_profiles() -> str:
"""List available bot profiles for playtesting."""
profiles_path = CONFIG_DIR / "bot-profiles.yaml"
if not profiles_path.exists():
return "No bot profiles found at config/bot-profiles.yaml"
with open(profiles_path) as f:
data = yaml.safe_load(f)
profiles = data.get("profiles", {})
lines = []
for name, profile in profiles.items():
cmd_count = len(profile.get("commands", []))
lines.append(f" {name}: {profile.get('description', '')} ({cmd_count} commands)")
return f"Available profiles ({len(profiles)}):\n" + "\n".join(lines)
@mcp.tool()
async def run_playtest(profile: str, server: str = "dev") -> str:
"""Run a bot profile's commands through the gateway and collect results.
Sends each command sequentially, records status/response/tools for each.
Results are saved to data/playtests/ for later analysis.
Args:
profile: Bot profile name (e.g. "noob", "griefer", "builder")
server: Server target — dev or prod
"""
profiles_path = CONFIG_DIR / "bot-profiles.yaml"
if not profiles_path.exists():
return "No bot profiles config found"
with open(profiles_path) as f:
data = yaml.safe_load(f)
profiles = data.get("profiles", {})
if profile not in profiles:
return f"Unknown profile: {profile}. Available: {list(profiles.keys())}"
bot = profiles[profile]
player = bot.get("player_name", f"Test{profile.title()}")
commands = bot.get("commands", [])
results = []
for cmd in commands:
mode = cmd.get("mode", "sudo")
text = cmd.get("text", "")
try:
resp = await _post("/v2/quick", {
"player": player,
"text": text,
"server": server,
"command_type": mode,
})
results.append({
"mode": mode,
"text": text,
"status": resp.get("status", "unknown"),
"response": (resp.get("response_text") or "")[:200],
"tools_used": [t.get("tool") for t in resp.get("tool_trace", [])],
"commands_executed": resp.get("commands_executed", []),
"error": None,
})
except Exception as e:
results.append({
"mode": mode,
"text": text,
"status": "error",
"response": "",
"tools_used": [],
"commands_executed": [],
"error": str(e),
})
# Brief pause between commands to avoid overwhelming
_time.sleep(1)
# Summarize
total = len(results)
passed = sum(1 for r in results if r["status"] == "completed" and not r["error"])
failed = sum(1 for r in results if r["status"] != "completed" or r["error"])
no_tools = sum(1 for r in results if r["status"] == "completed" and not r["tools_used"])
report = {
"profile": profile,
"player": player,
"timestamp": datetime.now().isoformat(),
"summary": {
"total": total,
"passed": passed,
"failed": failed,
"no_tools_used": no_tools,
},
"results": results,
}
# Save report
filename = f"{datetime.now().strftime('%Y%m%d-%H%M')}-{profile}.json"
report_path = PLAYTEST_DIR / filename
report_path.write_text(json.dumps(report, indent=2))
# Return summary
summary_lines = [f"Playtest: {profile} ({player}) — {passed}/{total} passed, {failed} failed, {no_tools} no-tool-use"]
for r in results:
status_icon = "OK" if r["status"] == "completed" and not r["error"] else "FAIL"
tool_str = ",".join(r["tools_used"]) if r["tools_used"] else "NO_TOOLS"
summary_lines.append(f" [{status_icon}] /{r['mode']} {r['text'][:50]}{tool_str}")
if r["error"]:
summary_lines.append(f" error: {r['error'][:100]}")
summary_lines.append(f"\nReport saved: {report_path}")
return "\n".join(summary_lines)
@mcp.tool()
def list_playtest_reports(limit: int = 10) -> str:
"""List recent playtest reports.
Args:
limit: Max number of reports to show (default 10)
"""
reports = sorted(PLAYTEST_DIR.glob("*.json"), reverse=True)[:limit]
if not reports:
return "No playtest reports found"
lines = []
for r in reports:
try:
data = json.loads(r.read_text())
s = data.get("summary", {})
lines.append(f" {r.name}: {data.get('profile')}{s.get('passed',0)}/{s.get('total',0)} passed")
except Exception:
lines.append(f" {r.name}: (unreadable)")
return f"Recent reports ({len(reports)}):\n" + "\n".join(lines)
# --- Diagnostics ---
@mcp.tool()
def read_interactions(date: str = "", limit: int = 20) -> str:
"""Read recent gateway interaction logs for analysis.
Args:
date: Date string YYYY-MM-DD (default: today)
limit: Max interactions to return (default 20)
"""
if not date:
date = datetime.now().strftime("%Y-%m-%d")
log_path = INTERACTION_DIR / f"{date}.jsonl"
if not log_path.exists():
return f"No interaction log for {date}"
lines = log_path.read_text().strip().split("\n")
recent = lines[-limit:]
results = []
for line in recent:
try:
d = json.loads(line)
tools = [t.get("tool") for t in d.get("tool_trace", [])]
results.append({
"player": d.get("player"),
"mode": d.get("mode"),
"message": (d.get("message") or "")[:80],
"status": d.get("status"),
"tools": tools,
"has_commands": bool(d.get("commands_executed")),
"response_preview": (d.get("response_text") or "")[:100],
})
except Exception:
continue
return json.dumps(results, indent=2)
@mcp.tool()
def analyze_errors(date: str = "", hours: int = 4) -> str:
"""Analyze recent gateway logs and interactions for error patterns.
Checks for: repeated errors, tool-use failures, timeouts, empty responses,
session poisoning (text-only responses with no tool calls).
Args:
date: Date string YYYY-MM-DD (default: today)
hours: How many hours back to analyze (default 4)
"""
issues = []
# Check gateway log for errors
log_path = Path("/tmp/mortdecai-gateway.log")
if log_path.exists():
try:
log_text = log_path.read_text()
error_lines = [l for l in log_text.split("\n") if "ERROR" in l or "Traceback" in l]
if error_lines:
issues.append({
"type": "gateway_errors",
"count": len(error_lines),
"recent": error_lines[-3:],
})
except Exception:
pass
# Check interaction logs
if not date:
date = datetime.now().strftime("%Y-%m-%d")
interaction_path = INTERACTION_DIR / f"{date}.jsonl"
if interaction_path.exists():
cutoff = _time.time() - (hours * 3600)
interactions = []
for line in interaction_path.read_text().strip().split("\n"):
try:
d = json.loads(line)
if d.get("timestamp", 0) > cutoff:
interactions.append(d)
except Exception:
continue
# Check for text-only responses (no tool calls)
no_tools = [i for i in interactions if not i.get("tool_trace") and i.get("status") == "completed"]
if no_tools:
issues.append({
"type": "no_tool_use",
"count": len(no_tools),
"description": "Completed responses with no tool calls (model responded with text only)",
"examples": [{"player": i.get("player"), "mode": i.get("mode"), "msg": i.get("message", "")[:60]} for i in no_tools[:3]],
})
# Check for errors/timeouts
errors = [i for i in interactions if i.get("status") in ("error", "timeout")]
if errors:
issues.append({
"type": "request_failures",
"count": len(errors),
"examples": [{"player": i.get("player"), "mode": i.get("mode"), "status": i.get("status"), "msg": i.get("message", "")[:60]} for i in errors[:3]],
})
# Check for empty responses
empty = [i for i in interactions if not i.get("response_text") and i.get("status") == "completed"]
if empty:
issues.append({
"type": "empty_responses",
"count": len(empty),
"examples": [{"player": i.get("player"), "mode": i.get("mode"), "msg": i.get("message", "")[:60]} for i in empty[:3]],
})
if not issues:
return f"No issues found in the last {hours} hours."
return json.dumps({"issues_found": len(issues), "issues": issues}, indent=2)
# --- Escalation ---
@mcp.tool()
def write_escalation(
title: str,
severity: str,
description: str,
evidence: str = "",
suggested_fix: str = "",
) -> str:
"""Write an escalation note for the architect session (Seth + Claude).
Use this when you find an issue you cannot or should not fix yourself.
Args:
title: Short title for the issue
severity: low, medium, high, critical
description: What's wrong and how you discovered it
evidence: Log lines, interaction data, or other evidence
suggested_fix: Your recommendation for how to fix it (optional)
"""
note = {
"title": title,
"severity": severity,
"description": description,
"evidence": evidence,
"suggested_fix": suggested_fix,
"timestamp": datetime.now().isoformat(),
"status": "open",
}
filename = f"{datetime.now().strftime('%Y%m%d-%H%M')}-{title[:40].replace(' ', '-').lower()}.json"
path = ESCALATION_DIR / filename
path.write_text(json.dumps(note, indent=2))
return f"Escalation written: {path}"
@mcp.tool()
def list_escalations(status: str = "open") -> str:
"""List escalation notes, optionally filtered by status.
Args:
status: Filter by status — open, resolved, all (default: open)
"""
files = sorted(ESCALATION_DIR.glob("*.json"), reverse=True)
if not files:
return "No escalations found"
notes = []
for f in files:
try:
data = json.loads(f.read_text())
if status != "all" and data.get("status") != status:
continue
notes.append(f" [{data.get('severity','?').upper()}] {data.get('title','')} ({f.name})")
except Exception:
continue
if not notes:
return f"No {status} escalations"
return f"Escalations ({len(notes)}):\n" + "\n".join(notes)
@mcp.tool()
def read_escalation(filename: str) -> str:
"""Read a specific escalation note.
Args:
filename: Escalation filename (from list_escalations)
"""
path = ESCALATION_DIR / filename
if not path.exists():
return f"Not found: {filename}"
return path.read_text()
# --- Logs ---
# --- Session Notes (persistent memory across runs) ---
@mcp.tool()
def write_note(topic: str, content: str) -> str:
"""Save a learning or observation that should persist across runs.
Use for: patterns discovered, things that work, things that don't,
provider quirks, player behavior patterns, diagnostic findings.
Keep notes focused and factual. One topic per note.
Args:
topic: Short topic key (e.g. "codex-tool-compliance", "griefer-patterns")
content: The observation or learning
"""
notes_dir = DATA_DIR / "notes"
notes_dir.mkdir(exist_ok=True)
note_path = notes_dir / f"{topic}.md"
entry = f"\n## {datetime.now().strftime('%Y-%m-%d %H:%M')}\n\n{content}\n"
if note_path.exists():
# Append to existing topic
with open(note_path, "a") as f:
f.write(entry)
else:
# New topic
with open(note_path, "w") as f:
f.write(f"# {topic}\n{entry}")
return f"Note saved: {note_path}"
@mcp.tool()
def read_notes(topic: str = "") -> str:
"""Read session notes. If topic is empty, lists all topics.
Args:
topic: Topic key to read, or empty to list all
"""
notes_dir = DATA_DIR / "notes"
if not notes_dir.exists():
return "No notes yet"
if not topic:
files = sorted(notes_dir.glob("*.md"))
if not files:
return "No notes yet"
lines = []
for f in files:
size = f.stat().st_size
lines.append(f" {f.stem} ({size} bytes)")
return f"Topics ({len(files)}):\n" + "\n".join(lines)
note_path = notes_dir / f"{topic}.md"
if not note_path.exists():
return f"No notes for topic: {topic}"
return note_path.read_text()
@mcp.tool()
def write_session_summary(summary: str) -> str:
"""Write a summary of this run's findings and actions.
Call this at the end of every scheduled run. Keeps a rolling log
of what happened, what was fixed, what was escalated.
Args:
summary: Brief summary of this run (findings, actions, escalations)
"""
log_path = DATA_DIR / "run-log.md"
entry = f"\n## {datetime.now().strftime('%Y-%m-%d %H:%M')}\n\n{summary}\n\n---\n"
with open(log_path, "a") as f:
f.write(entry)
# Keep run log under 50KB (trim oldest entries)
if log_path.stat().st_size > 50_000:
text = log_path.read_text()
sections = text.split("\n---\n")
trimmed = "\n---\n".join(sections[-(len(sections) // 2):])
log_path.write_text(trimmed)
return f"Session summary saved to {log_path}"
@mcp.tool()
def read_run_log(entries: int = 5) -> str:
"""Read recent run summaries.
Args:
entries: Number of recent entries to show (default 5)
"""
log_path = DATA_DIR / "run-log.md"
if not log_path.exists():
return "No run log yet — this is the first run"
text = log_path.read_text()
sections = text.split("\n---\n")
recent = sections[-entries:] if len(sections) > entries else sections
return "\n---\n".join(recent)
# --- Logs --- # --- Logs ---