Files
Shane ab6b5254ef feat: context compaction + skills system
Compaction:
- GemmaLLM.chat() truncates ChatContext to last 30 items (~15 turns)
  before sending to LLM, preventing context window overflow on long
  conversations. Preserves system prompt and removes orphaned tool calls.

Skills:
- agent/skills_mcp.py: MCP server with skill_save, skill_recall,
  skill_list, skill_update tools backed by .md files in /skills
- skills/ dir bind-mounted into container, git-trackable
- System prompt instructs Hope to save repeatable procedures as skills
  and recall them before performing tasks she's done before
- Distinct from memory (facts) — skills are learned *procedures*
2026-08-22 16:42:16 -04:00

183 lines
5.9 KiB
Python

"""Skills MCP server — learned procedures for Hope.
Runs over stdio inside the voice container. The agent attaches it via
MCPServerStdio, so the LLM can recall and save skills during a
conversation. Skills live as plain markdown files in SKILLS_DIR
(default /skills), one file per skill, mounted from the host so they
survive rebuilds and are backed up via git.
Skills are *how to do things* — repeatable procedures distilled from
conversations. Facts and preferences belong in memory, not skills.
Tools:
- skill_save(name, description, steps) -> save a new skill
- skill_recall(query) -> matching skills with their full steps
- skill_list() -> all skills (name + description line)
- skill_update(name, new_content) -> replace an existing skill's content
"""
from __future__ import annotations
import os
import re
from datetime import datetime, timezone
from mcp.server.fastmcp import FastMCP
SKILLS_DIR = os.environ.get("SKILLS_DIR", "/skills")
mcp = FastMCP("skills")
def _slugify(name: str) -> str:
"""Turn a skill name into a safe filename slug."""
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
return slug or "misc"
def _read_files() -> list[tuple[str, str]]:
"""Return (filename, content) for every .md file in SKILLS_DIR."""
files: list[tuple[str, str]] = []
if not os.path.isdir(SKILLS_DIR):
return files
for name in sorted(os.listdir(SKILLS_DIR)):
if not name.endswith(".md"):
continue
path = os.path.join(SKILLS_DIR, name)
try:
with open(path, encoding="utf-8") as f:
files.append((name, f.read()))
except OSError:
continue
return files
def _description_line(content: str) -> str:
"""First non-empty line after the H1 title."""
lines = content.splitlines()
started = False
for line in lines:
if not started:
if line.startswith("# "):
started = True
continue
if line.strip():
return line.strip()
return "(no description)"
@mcp.tool()
def skill_save(name: str, description: str, steps: str) -> str:
"""Save a learned procedure as a reusable skill.
Use this after successfully completing a multi-step task or when the
user teaches you a new procedure, so you can follow it next time. Be
selective: only save repeatable tasks, not one-off facts (those go in
memory). Returns a short confirmation.
"""
name = (name or "").strip()
description = (description or "").strip()
steps = (steps or "").strip()
if not name or not description or not steps:
return "Nothing saved — a name, a description, and steps are all needed."
os.makedirs(SKILLS_DIR, exist_ok=True)
slug = _slugify(name)
path = os.path.join(SKILLS_DIR, f"{slug}.md")
stamp = datetime.now(timezone.utc).isoformat()
with open(path, "w", encoding="utf-8") as f:
f.write(f"# {name}\n\n{description}\n\n## Steps\n{steps}\n\n---\n")
f.write(f"Created: {stamp}\nSource: conversation-learned\n")
return f"Saved skill '{name}'."
@mcp.tool()
def skill_recall(query: str) -> str:
"""Search the skill library for a procedure matching a query.
Use this when you need to perform a task you may have done before —
it returns the matching skills with their full steps so you can follow
them. Returns a message saying nothing was found if there's no match.
"""
q = (query or "").strip().lower()
if not q:
return "I don't have a skill for that."
words = [w for w in re.split(r"\W+", q) if len(w) > 2]
files = _read_files()
if not files:
return "I don't have a skill for that."
scored: list[tuple[int, str, str]] = []
for name, content in files:
lower = content.lower()
score = 0
if q in lower:
score += len(q)
for w in words:
score += lower.count(w)
if score > 0:
scored.append((score, name, content))
if not scored:
return "I don't have a skill for that."
scored.sort(key=lambda s: s[0], reverse=True)
parts = []
for _, name, content in scored[:3]:
parts.append(content.strip())
return "\n\n".join(parts)
@mcp.tool()
def skill_list() -> str:
"""List all saved skills with a brief description of each.
Returns the skill name and its description line, or a message saying
no skills exist yet.
"""
files = _read_files()
if not files:
return "No skills saved yet."
lines = [f"{name.removesuffix('.md')}: {_description_line(content)}" for name, content in files]
return "\n".join(lines)
@mcp.tool()
def skill_update(name: str, new_content: str) -> str:
"""Replace the content of an existing skill.
Use this to refine a learned procedure after you've improved it. The
name must match an existing skill (case-insensitive). Returns a
confirmation, or an error if no such skill exists.
"""
name = (name or "").strip()
new_content = (new_content or "").strip()
if not name or not new_content:
return "Nothing updated — both a name and new content are needed."
slug = _slugify(name)
path = os.path.join(SKILLS_DIR, f"{slug}.md")
if not os.path.exists(path):
# Fall back to matching by the H1 title in case the filename differs.
for fname, content in _read_files():
first = next((l for l in content.splitlines() if l.strip()), "")
if first.lstrip("# ").strip().lower() == name.lower():
path = os.path.join(SKILLS_DIR, fname)
break
else:
return f"No skill named '{name}' found."
stamp = datetime.now(timezone.utc).isoformat()
with open(path, "w", encoding="utf-8") as f:
f.write(new_content.rstrip() + "\n\n---\n")
f.write(f"Updated: {stamp}\nSource: conversation-learned\n")
return f"Updated skill '{name}'."
if __name__ == "__main__":
mcp.run(transport="stdio")