devin plugin: full schema/tool parity with plugins/copilot
Mirror the copilot MCP server: same rich _TOOL_SCHEMA (source, model, tasks_file, target_skill_path, max_sessions, max_tasks, lookback_hours, auto_adopt, json, edit_budget, hour, minute) and generic flag forwarding, plus sleep_schedule / sleep_unschedule. Devin specifics retained: the ATIF-v1.7 harvest step (run before data-reading actions, engine pointed at it via --claude-home, default --source claude) and post-adopt sync into .devin/skills/. Tests + README + rules snippet updated for the 7-tool interface. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -58,6 +58,9 @@ Requires Python ≥ 3.10. No third-party packages — the server is pure stdlib.
|
|||||||
| `sleep_run` | full cycle; stages a proposal for review |
|
| `sleep_run` | full cycle; stages a proposal for review |
|
||||||
| `sleep_adopt` | apply the staged proposal; syncs skill to the workspace |
|
| `sleep_adopt` | apply the staged proposal; syncs skill to the workspace |
|
||||||
| `sleep_harvest` | debug: list the recurring tasks mined |
|
| `sleep_harvest` | debug: list the recurring tasks mined |
|
||||||
|
| `sleep_schedule` | install a nightly cron entry (`--hour` / `--minute`) |
|
||||||
|
| `sleep_unschedule` | remove the nightly cron entry |
|
||||||
|
|
||||||
Default backend is `mock` (no API spend); `--backend claude|codex` uses your own
|
Default backend is `mock` (no API spend); `--backend claude|codex` uses your own
|
||||||
budget. Same engine as the other plugins — all call `python -m skillopt_sleep`.
|
budget. Same engine and `sleep_*` interface as the other plugins — all call
|
||||||
|
`python -m skillopt_sleep`.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ server. Use these tools to improve your long-term skills over time:
|
|||||||
- **`sleep_run`** — run a full cycle; stages a proposal for review
|
- **`sleep_run`** — run a full cycle; stages a proposal for review
|
||||||
- **`sleep_adopt`** — apply the staged proposal to `.devin/skills/skillopt-sleep-learned/SKILL.md`
|
- **`sleep_adopt`** — apply the staged proposal to `.devin/skills/skillopt-sleep-learned/SKILL.md`
|
||||||
- **`sleep_harvest`** — debug: list the recurring tasks mined from recent sessions
|
- **`sleep_harvest`** — debug: list the recurring tasks mined from recent sessions
|
||||||
|
- **`sleep_schedule`** / **`sleep_unschedule`** — install/remove a nightly cron run
|
||||||
|
|
||||||
When a user asks about the sleep cycle, skill evolution, or improving your
|
When a user asks about the sleep cycle, skill evolution, or improving your
|
||||||
long-term memory, prefer calling these tools over explaining the concept.
|
long-term memory, prefer calling these tools over explaining the concept.
|
||||||
|
|||||||
+114
-101
@@ -1,30 +1,22 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""SkillOpt-Sleep — Devin MCP server (stdio, stdlib-only).
|
"""SkillOpt-Sleep — Devin MCP server (stdio, stdlib-only).
|
||||||
|
|
||||||
Exposes the sleep engine as MCP tools so Devin can drive it.
|
Exposes the sleep engine as MCP tools so Devin (Cognition) can drive it. No
|
||||||
Speaks JSON-RPC 2.0 over stdio with just the handful of MCP methods Devin
|
third-party deps: speaks JSON-RPC 2.0 over stdio with just the handful of MCP
|
||||||
needs. No third-party deps beyond the SkillOpt repo itself.
|
methods clients need. Same `sleep_*` interface and engine flags as
|
||||||
|
`plugins/copilot`, plus a Devin-specific harvest step.
|
||||||
|
|
||||||
Before each tool call this server runs ``harvest_devin.py`` to convert
|
Before each data-reading action this server runs `harvest_devin.py` to convert
|
||||||
locally available Devin data (ATIF-v1.7 transcripts, agentmemory memories,
|
locally available Devin data (ATIF-v1.7 transcripts, agentmemory memories, and
|
||||||
and .devin skill files) into the Claude Code-compatible JSONL transcripts
|
.devin skill files) into the Claude Code-compatible JSONL the engine consumes,
|
||||||
that the sleep engine consumes.
|
writing it under SKILLOPT_DEVIN_CLAUDE_HOME and pointing the engine there with
|
||||||
|
`--claude-home`. After `sleep_adopt` the evolved skill is synced back into the
|
||||||
|
workspace's `.devin/skills/`.
|
||||||
|
|
||||||
After ``sleep_adopt`` the evolved SKILL.md is also synced back into the active
|
Tools: sleep_status, sleep_dry_run, sleep_run, sleep_adopt, sleep_harvest,
|
||||||
Devin workspace's ``.devin/skills/`` directory so Devin picks it up immediately.
|
sleep_schedule, sleep_unschedule. Each shells out to
|
||||||
|
`python -m skillopt_sleep <action> ...`. Configure Devin to launch:
|
||||||
Tools exposed (identical interface to the Copilot plugin):
|
python plugins/devin/mcp_server.py
|
||||||
sleep_status show how many nights have run + latest staged proposal
|
|
||||||
sleep_dry_run harvest+mine+replay, report only (no staging)
|
|
||||||
sleep_run full cycle; stages a reviewed proposal
|
|
||||||
sleep_adopt apply the latest staged proposal
|
|
||||||
sleep_harvest debug: list mined recurring tasks
|
|
||||||
|
|
||||||
Configure Devin to launch::
|
|
||||||
|
|
||||||
python plugins/devin/mcp_server.py
|
|
||||||
|
|
||||||
with ``SKILLOPT_SLEEP_REPO`` set to this repo's root.
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -34,8 +26,8 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
# ── constants ─────────────────────────────────────────────────────────────────
|
# expanduser wraps the whole value so a "~/..." env var is expanded too (not
|
||||||
|
# just a default) — otherwise a literal ~ dir gets created.
|
||||||
REPO_ROOT = os.path.expanduser(
|
REPO_ROOT = os.path.expanduser(
|
||||||
os.environ.get("SKILLOPT_SLEEP_REPO")
|
os.environ.get("SKILLOPT_SLEEP_REPO")
|
||||||
or os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
or os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
@@ -48,57 +40,68 @@ MANAGED_SKILL_NAME = os.environ.get("SKILLOPT_MANAGED_SKILL", "skillopt-sleep-le
|
|||||||
PROTOCOL_VERSION = "2024-11-05"
|
PROTOCOL_VERSION = "2024-11-05"
|
||||||
|
|
||||||
TOOLS = [
|
TOOLS = [
|
||||||
{
|
{"name": "sleep_status", "action": "status",
|
||||||
"name": "sleep_status",
|
"description": "Show how many SkillOpt-Sleep nights have run and the latest staged proposal."},
|
||||||
"action": "status",
|
{"name": "sleep_dry_run", "action": "dry-run",
|
||||||
"description": "Show how many SkillOpt-Sleep nights have run and the latest staged proposal.",
|
"description": "Preview a sleep cycle (harvest+mine+replay) without staging anything."},
|
||||||
},
|
{"name": "sleep_run", "action": "run",
|
||||||
{
|
"description": "Run a full sleep cycle; stages a reviewed proposal. Nothing live changes until adopt."},
|
||||||
"name": "sleep_dry_run",
|
{"name": "sleep_adopt", "action": "adopt",
|
||||||
"action": "dry-run",
|
"description": "Apply the latest staged proposal to the managed SKILL.md and sync it into .devin/skills/."},
|
||||||
"description": "Preview a sleep cycle (harvest+mine+replay) without staging anything.",
|
{"name": "sleep_harvest", "action": "harvest",
|
||||||
},
|
"description": "Debug: list the recurring tasks mined from recent Devin sessions."},
|
||||||
{
|
{"name": "sleep_schedule", "action": "schedule",
|
||||||
"name": "sleep_run",
|
"description": "Install a nightly cron entry to run the sleep cycle automatically."},
|
||||||
"action": "run",
|
{"name": "sleep_unschedule", "action": "unschedule",
|
||||||
"description": "Run a full sleep cycle; stages a reviewed proposal. Nothing live changes until adopt.",
|
"description": "Remove the nightly cron entry for a project."},
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sleep_adopt",
|
|
||||||
"action": "adopt",
|
|
||||||
"description": (
|
|
||||||
"Apply the latest staged proposal to the managed SKILL.md. "
|
|
||||||
"Also syncs the evolved skill into the Devin workspace so Devin picks it up immediately."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sleep_harvest",
|
|
||||||
"action": "harvest",
|
|
||||||
"description": "Debug: list the recurring tasks mined from recent Devin sessions.",
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
_BY_NAME = {t["name"]: t for t in TOOLS}
|
_BY_NAME = {t["name"]: t for t in TOOLS}
|
||||||
|
|
||||||
_TOOL_SCHEMA = {
|
_TOOL_SCHEMA = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"project": {
|
"project": {"type": "string",
|
||||||
"type": "string",
|
"description": "Project dir to evolve (default: cwd)."},
|
||||||
"description": "Project dir to evolve (default: cwd).",
|
"backend": {"type": "string", "enum": ["mock", "claude", "codex", "copilot"],
|
||||||
},
|
"description": "mock = no API spend (default); claude/codex/copilot = real."},
|
||||||
"backend": {
|
"scope": {"type": "string", "enum": ["invoked", "all"],
|
||||||
"type": "string",
|
"description": "Harvest scope (default: invoked project only)."},
|
||||||
"enum": ["mock", "claude", "codex"],
|
"source": {"type": "string", "enum": ["claude", "codex", "auto"],
|
||||||
"description": "mock = no API spend (default); claude/codex = real.",
|
"description": "Transcript source (default: claude)."},
|
||||||
},
|
"model": {"type": "string",
|
||||||
"scope": {"type": "string", "enum": ["invoked", "all"]},
|
"description": "Backend-specific model override."},
|
||||||
|
"tasks_file": {"type": "string",
|
||||||
|
"description": "Path to reviewed TaskRecord JSON (skips harvest)."},
|
||||||
|
"target_skill_path": {"type": "string",
|
||||||
|
"description": "Explicit SKILL.md path to evolve/stage/adopt."},
|
||||||
|
"progress": {"type": "boolean",
|
||||||
|
"description": "Print phase progress to stderr."},
|
||||||
|
"max_sessions": {"type": "integer",
|
||||||
|
"description": "Cap harvested sessions per run."},
|
||||||
|
"max_tasks": {"type": "integer",
|
||||||
|
"description": "Cap mined tasks per run."},
|
||||||
|
"lookback_hours": {"type": "integer",
|
||||||
|
"description": "Harvest window in hours (default: 72)."},
|
||||||
|
"auto_adopt": {"type": "boolean",
|
||||||
|
"description": "Auto-adopt if gate passes (default: false)."},
|
||||||
|
"json": {"type": "boolean",
|
||||||
|
"description": "Return machine-readable JSON output."},
|
||||||
|
"edit_budget": {"type": "integer",
|
||||||
|
"description": "Max bounded edits per night (default: 4)."},
|
||||||
|
"hour": {"type": "integer",
|
||||||
|
"description": "Hour for schedule (0-23, default: 3)."},
|
||||||
|
"minute": {"type": "integer",
|
||||||
|
"description": "Minute for schedule (0-59, default: 17)."},
|
||||||
},
|
},
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── harvest step ──────────────────────────────────────────────────────────────
|
# actions that read harvested Devin data (schedule/unschedule/adopt don't)
|
||||||
|
_HARVEST_ACTIONS = {"status", "dry-run", "run", "harvest"}
|
||||||
|
|
||||||
|
|
||||||
def _run_harvest() -> str:
|
def _run_harvest() -> str:
|
||||||
|
"""Convert local Devin data into the JSONL the engine reads, under CLAUDE_HOME."""
|
||||||
harvester = os.path.join(PLUGIN_DIR, "harvest_devin.py")
|
harvester = os.path.join(PLUGIN_DIR, "harvest_devin.py")
|
||||||
env = dict(os.environ)
|
env = dict(os.environ)
|
||||||
env["PYTHONPATH"] = REPO_ROOT + os.pathsep + env.get("PYTHONPATH", "")
|
env["PYTHONPATH"] = REPO_ROOT + os.pathsep + env.get("PYTHONPATH", "")
|
||||||
@@ -113,61 +116,72 @@ def _run_harvest() -> str:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return f"[harvest_devin] warning: {exc}"
|
return f"[harvest_devin] warning: {exc}"
|
||||||
|
|
||||||
# ── post-adopt: sync evolved skill into workspace (.devin) ────────────────────
|
|
||||||
|
|
||||||
def _sync_skill(project: str) -> str:
|
def _sync_skill(project: str) -> str:
|
||||||
|
"""After adopt, copy the evolved skill into the workspace's .devin/skills/."""
|
||||||
src = os.path.join(CLAUDE_HOME, "skills", MANAGED_SKILL_NAME, "SKILL.md")
|
src = os.path.join(CLAUDE_HOME, "skills", MANAGED_SKILL_NAME, "SKILL.md")
|
||||||
if not os.path.isfile(src):
|
if not (os.path.isfile(src) and project and os.path.isdir(project)):
|
||||||
return ""
|
return ""
|
||||||
if not project or not os.path.isdir(project):
|
|
||||||
return ""
|
|
||||||
synced = []
|
|
||||||
dot_root = os.path.join(project, ".devin")
|
dot_root = os.path.join(project, ".devin")
|
||||||
if os.path.isdir(dot_root):
|
if not os.path.isdir(dot_root):
|
||||||
dst_dir = os.path.join(dot_root, "skills", MANAGED_SKILL_NAME)
|
return ""
|
||||||
os.makedirs(dst_dir, exist_ok=True)
|
dst_dir = os.path.join(dot_root, "skills", MANAGED_SKILL_NAME)
|
||||||
dst = os.path.join(dst_dir, "SKILL.md")
|
os.makedirs(dst_dir, exist_ok=True)
|
||||||
shutil.copy2(src, dst)
|
dst = os.path.join(dst_dir, "SKILL.md")
|
||||||
synced.append(dst)
|
shutil.copy2(src, dst)
|
||||||
return ("\n" + "\n".join(f"[sleep] synced evolved skill → {p}" for p in synced)
|
return f"\n[sleep] synced evolved skill → {dst}"
|
||||||
if synced else "")
|
|
||||||
|
|
||||||
# ── engine call ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _run_engine(action: str, args: dict) -> str:
|
def _run_engine(action: str, args: dict) -> str:
|
||||||
harvest_out = _run_harvest()
|
harvest_out = _run_harvest() if action in _HARVEST_ACTIONS else ""
|
||||||
|
|
||||||
project = args.get("project") or os.getcwd()
|
py = sys.executable or "python3"
|
||||||
backend = args.get("backend") or "mock"
|
cmd = [py, "-m", "skillopt_sleep", action, "--claude-home", CLAUDE_HOME]
|
||||||
scope = args.get("scope") or "invoked"
|
# Devin transcripts are converted to the Claude format, so default source=claude
|
||||||
|
if not args.get("source"):
|
||||||
|
cmd += ["--source", "claude"]
|
||||||
|
# String-valued flags
|
||||||
|
for flag, key in [
|
||||||
|
("--project", "project"), ("--backend", "backend"),
|
||||||
|
("--scope", "scope"), ("--source", "source"),
|
||||||
|
("--model", "model"), ("--tasks-file", "tasks_file"),
|
||||||
|
("--target-skill-path", "target_skill_path"),
|
||||||
|
]:
|
||||||
|
val = args.get(key)
|
||||||
|
if val:
|
||||||
|
cmd += [flag, str(val)]
|
||||||
|
# Integer-valued flags
|
||||||
|
for flag, key in [
|
||||||
|
("--max-sessions", "max_sessions"), ("--max-tasks", "max_tasks"),
|
||||||
|
("--lookback-hours", "lookback_hours"), ("--edit-budget", "edit_budget"),
|
||||||
|
("--hour", "hour"), ("--minute", "minute"),
|
||||||
|
]:
|
||||||
|
val = args.get(key)
|
||||||
|
if val is not None:
|
||||||
|
cmd += [flag, str(int(val))]
|
||||||
|
# Boolean flags
|
||||||
|
for flag, key in [
|
||||||
|
("--progress", "progress"), ("--auto-adopt", "auto_adopt"), ("--json", "json"),
|
||||||
|
]:
|
||||||
|
if args.get(key):
|
||||||
|
cmd.append(flag)
|
||||||
|
|
||||||
cmd = [
|
|
||||||
sys.executable, "-m", "skillopt_sleep", action,
|
|
||||||
"--claude-home", CLAUDE_HOME,
|
|
||||||
"--project", project,
|
|
||||||
"--scope", scope,
|
|
||||||
"--backend", backend,
|
|
||||||
"--source", "claude",
|
|
||||||
]
|
|
||||||
env = dict(os.environ)
|
env = dict(os.environ)
|
||||||
env["PYTHONPATH"] = REPO_ROOT + os.pathsep + env.get("PYTHONPATH", "")
|
env["PYTHONPATH"] = REPO_ROOT + os.pathsep + env.get("PYTHONPATH", "")
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(cmd, cwd=REPO_ROOT, capture_output=True,
|
||||||
cmd, capture_output=True, text=True, timeout=3600, env=env,
|
text=True, timeout=3600, env=env)
|
||||||
)
|
except Exception as e:
|
||||||
except Exception as exc:
|
return f"[harvest]\n{harvest_out}\n[error] failed to run engine: {e}"
|
||||||
return f"[harvest]\n{harvest_out}\n[error] failed to run engine: {exc}"
|
|
||||||
|
|
||||||
out = (proc.stdout or "").strip()
|
out = (proc.stdout or "").strip()
|
||||||
err = (proc.stderr or "").strip()
|
err = (proc.stderr or "").strip()
|
||||||
result = f"[harvest]\n{harvest_out}\n\n[engine]\n{out}"
|
result = (f"[harvest]\n{harvest_out}\n\n" if harvest_out else "") + f"[engine]\n{out}"
|
||||||
if err:
|
if err:
|
||||||
result += f"\n[stderr]\n{err}"
|
result += f"\n[stderr]\n{err}"
|
||||||
if action == "adopt":
|
if action == "adopt":
|
||||||
result += _sync_skill(project)
|
result += _sync_skill(args.get("project") or os.getcwd())
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ── JSON-RPC / MCP plumbing ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _result(id_, result):
|
def _result(id_, result):
|
||||||
return {"jsonrpc": "2.0", "id": id_, "result": result}
|
return {"jsonrpc": "2.0", "id": id_, "result": result}
|
||||||
@@ -184,14 +198,13 @@ def handle(req: dict):
|
|||||||
return _result(id_, {
|
return _result(id_, {
|
||||||
"protocolVersion": PROTOCOL_VERSION,
|
"protocolVersion": PROTOCOL_VERSION,
|
||||||
"capabilities": {"tools": {}},
|
"capabilities": {"tools": {}},
|
||||||
"serverInfo": {"name": "skillopt-sleep-devin", "version": "0.1.0"},
|
"serverInfo": {"name": "skillopt-sleep", "version": "0.1.0"},
|
||||||
})
|
})
|
||||||
if method in ("notifications/initialized", "initialized"):
|
if method in ("notifications/initialized", "initialized"):
|
||||||
return None
|
return None
|
||||||
if method == "tools/list":
|
if method == "tools/list":
|
||||||
return _result(id_, {"tools": [
|
return _result(id_, {"tools": [
|
||||||
{"name": t["name"], "description": t["description"],
|
{"name": t["name"], "description": t["description"], "inputSchema": _TOOL_SCHEMA}
|
||||||
"inputSchema": _TOOL_SCHEMA}
|
|
||||||
for t in TOOLS
|
for t in TOOLS
|
||||||
]})
|
]})
|
||||||
if method == "tools/call":
|
if method == "tools/call":
|
||||||
|
|||||||
@@ -33,20 +33,31 @@ class TestDevinMcpSchema(unittest.TestCase):
|
|||||||
def test_tools_are_the_sleep_interface(self):
|
def test_tools_are_the_sleep_interface(self):
|
||||||
names = {t["name"] for t in mcp_server.TOOLS}
|
names = {t["name"] for t in mcp_server.TOOLS}
|
||||||
self.assertEqual(names, {"sleep_status", "sleep_dry_run", "sleep_run",
|
self.assertEqual(names, {"sleep_status", "sleep_dry_run", "sleep_run",
|
||||||
"sleep_adopt", "sleep_harvest"})
|
"sleep_adopt", "sleep_harvest",
|
||||||
|
"sleep_schedule", "sleep_unschedule"})
|
||||||
|
|
||||||
def test_actions_map_to_engine_subcommands(self):
|
def test_actions_map_to_engine_subcommands(self):
|
||||||
expected = {"sleep_status": "status", "sleep_dry_run": "dry-run",
|
expected = {"sleep_status": "status", "sleep_dry_run": "dry-run",
|
||||||
"sleep_run": "run", "sleep_adopt": "adopt",
|
"sleep_run": "run", "sleep_adopt": "adopt",
|
||||||
"sleep_harvest": "harvest"}
|
"sleep_harvest": "harvest", "sleep_schedule": "schedule",
|
||||||
|
"sleep_unschedule": "unschedule"}
|
||||||
for t in mcp_server.TOOLS:
|
for t in mcp_server.TOOLS:
|
||||||
self.assertEqual(t["action"], expected[t["name"]])
|
self.assertEqual(t["action"], expected[t["name"]])
|
||||||
|
|
||||||
def test_backends_in_enum(self):
|
def test_backends_in_enum(self):
|
||||||
backends = mcp_server._TOOL_SCHEMA["properties"]["backend"]["enum"]
|
backends = mcp_server._TOOL_SCHEMA["properties"]["backend"]["enum"]
|
||||||
for b in ["mock", "claude", "codex"]:
|
for b in ["mock", "claude", "codex", "copilot"]:
|
||||||
self.assertIn(b, backends)
|
self.assertIn(b, backends)
|
||||||
|
|
||||||
|
def test_schema_has_key_engine_params(self):
|
||||||
|
# parity with plugins/copilot's schema (tests/test_plugin_sync.py)
|
||||||
|
props = set(mcp_server._TOOL_SCHEMA["properties"].keys())
|
||||||
|
for param in {"project", "backend", "scope", "source", "model",
|
||||||
|
"tasks_file", "target_skill_path", "max_sessions",
|
||||||
|
"max_tasks", "lookback_hours", "auto_adopt", "json",
|
||||||
|
"edit_budget", "hour", "minute"}:
|
||||||
|
self.assertIn(param, props)
|
||||||
|
|
||||||
|
|
||||||
class TestClaudeHomeExpansion(unittest.TestCase):
|
class TestClaudeHomeExpansion(unittest.TestCase):
|
||||||
"""Regression: ~ must be expanded even when CLAUDE_HOME comes from the env
|
"""Regression: ~ must be expanded even when CLAUDE_HOME comes from the env
|
||||||
|
|||||||
Reference in New Issue
Block a user