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
- **Gateway**: http://localhost:8500 (uvicorn, runs from ~/bin/Mortdecai-2.0/)
- **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
- **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
3. **Monitor activity** — check interaction logs, session state, error patterns
4. **Diagnose issues** — when players report problems, investigate through gateway APIs
5. **Operate Mortdecai** — send commands through the gateway to test or demonstrate
```
1. READ NOTES — read_run_log + read_notes for context from past runs
2. HEALTH CHECK — gateway_health, if DOWN → gateway_start
3. DIAGNOSE — analyze_errors, check for patterns
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 |
|------|---------|
| `gateway_start` | Start the gateway if not running |
| `gateway_stop` | Stop the gateway |
| `gateway_restart` | Stop + start |
| `gateway_status` | Full status: providers, sessions, oracle |
| `gateway_health` | Quick alive check |
| `gateway_command` | Send a player command through the gateway |
| `gateway_brain_set` | Hot-swap provider/model for a role |
| `gateway_brain_save` | Persist brain override to config file |
| `gateway_brain_reload` | Reset brain to config file state |
| `gateway_sessions_clear` | Clear a player's sessions |
| `gateway_sessions_reset` | Clear ALL sessions |
| `gateway_logs` | Read recent gateway log output |
You CANNOT:
- Modify gateway source code
- Change provider config (agents.yaml)
- Hot-swap brain providers
- Make changes to the Minecraft server
- Deploy plugin updates
### Layer 2 (Future) — Safe Auto-Fix
_When promoted: clear poisoned session history, restart on crash, hot-swap providers._
### Layer 3 (Future) — Propose Code Changes
_When promoted: write diffs to escalation notes for architect review._
## 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
- **Almost everything goes through the gateway.** Do not bypass it for game operations.
- Do NOT modify gateway source code unless explicitly asked.
- Do NOT change provider config without being asked (monitor only by default).
- Do NOT SSH into infrastructure nodes — that's for the dev session.
- You CAN read files in ~/bin/Mortdecai-2.0/ to understand what's happening, but don't edit them without permission.
## 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)
- Do NOT modify files in ~/bin/Mortdecai-2.0/ (read-only for diagnosis)
- Do NOT SSH into infrastructure nodes
- Do NOT send commands to production server (dev only)
- Do NOT change provider config without promotion to Layer 2
- Keep notes factual and concise — you are not writing essays
+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
Mortdecai natively. All game operations go through the gateway —
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 subprocess
import time as _time
from datetime import datetime
from pathlib import Path
import httpx
import yaml
from mcp.server.fastmcp import FastMCP
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")
@@ -70,7 +93,7 @@ def gateway_restart() -> str:
["bash", str(SCRIPTS_DIR / "stop-gateway.sh")],
capture_output=True, text=True, timeout=10,
)
import time; time.sleep(2)
_time.sleep(2)
start = subprocess.run(
["bash", str(SCRIPTS_DIR / "start-gateway.sh")],
capture_output=True, text=True, timeout=30,
@@ -217,6 +240,439 @@ async def gateway_sessions_reset() -> str:
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 ---