diff --git a/.gitignore b/.gitignore index c09a3a1..eb152f2 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ memory/.memory.sqlite memory/.memory.sqlite-wal memory/.memory.sqlite-shm memory/.dreams/ +memory/autogenesis/ memory/.crons-lock/ memory/.reconciling memory/crons-errors.jsonl diff --git a/docs/paperclip.md b/docs/paperclip.md new file mode 100644 index 0000000..f6ce42c --- /dev/null +++ b/docs/paperclip.md @@ -0,0 +1,101 @@ +# Paperclip Integration + +Native bridge between ClawCode and the [Paperclip](https://github.com/anthropics/paperclip) agent control plane. Enables bidirectional communication: ClawCode agents can manage Paperclip issues, and Paperclip heartbeats can drive ClawCode's Task Completion Guard. + +## Setup + +### Option A: Environment variables (automatic via Paperclip heartbeat) + +When Paperclip invokes a ClawCode agent via heartbeat, it injects: + +``` +PAPERCLIP_API_URL=http://localhost:3000 +PAPERCLIP_API_KEY=pk_agent_... +PAPERCLIP_COMPANY_ID= +PAPERCLIP_AGENT_ID= +PAPERCLIP_RUN_ID= +``` + +No configuration needed — the bridge auto-detects these. + +### Option B: agent-config.json (manual setup) + +```json +{ + "paperclip": { + "apiUrl": "http://localhost:3000", + "apiKey": "pk_agent_...", + "companyId": "", + "autoSync": true + } +} +``` + +## MCP Tools + +### `paperclip_inbox` +Get your assigned issues and pending tasks. + +### `paperclip_issue` +| Action | Required | Description | +|---|---|---| +| `get` | `id` | Fetch issue by ID or identifier | +| `list` | — | List issues (optional: `status`, `limit`) | +| `create` | `title` | Create new issue (optional: `description`) | +| `update` | `id` | Update issue (optional: `status`, `title`, `description`) | +| `checkout` | `id` | Assign issue to agent (optional: `agentId`) | + +### `paperclip_comment` +| Action | Required | Description | +|---|---|---| +| `list` | `issueId` | List comments (optional: `limit`) | +| `add` | `issueId`, `body` | Post a comment | + +### `paperclip_agents` +| Action | Required | Description | +|---|---|---| +| `list` | — | List all agents in the company | +| `wakeup` | `agentId` | Invoke heartbeat on an agent (optional: `reason`) | + +## Task Ledger Sync + +When `autoSync` is enabled (default) and ClawCode is invoked via a Paperclip heartbeat: + +1. The bridge reads the heartbeat context (issue title, description) +2. Extracts acceptance criteria from markdown checklists in the description +3. Opens a `task_ledger` entry with those criteria +4. The **Task Completion Guard** enforces completion — the agent can't stop until criteria are met +5. On `task_close`, a summary comment is posted back to the Paperclip issue + +This means: assign an issue to a ClawCode agent in Paperclip, and the agent will work until it's done (or explain why it can't finish). + +## Architecture + +``` + Paperclip Control Plane + │ + │ heartbeat (spawns agent process) + │ env: API_URL, API_KEY, COMPANY_ID, RUN_ID + ▼ + ClawCode MCP Server + │ + ├── paperclip-bridge.ts → HTTP client (REST API) + ├── paperclip-sync.ts → Heartbeat → Task Ledger sync + ├── task-ledger.ts → Acceptance criteria enforcement + └── task-guard-cli.ts → Stop hook blocks until done + │ + │ task_close → POST comment + ▼ + Paperclip Issue (updated) +``` + +## Files + +| File | Description | +|---|---| +| `lib/paperclip-bridge.ts` | HTTP client wrapping Paperclip REST API | +| `lib/paperclip-sync.ts` | Heartbeat ↔ Task Ledger synchronization | +| `lib/config.ts` | `paperclip` config block in AgentConfig | +| `server.ts` | 4 MCP tools (inbox, issue, comment, agents) | +| `skills/paperclip/SKILL.md` | User-invocable skill | +| `docs/paperclip.md` | This file | diff --git a/lib/autogenesis-engine.py b/lib/autogenesis-engine.py new file mode 100644 index 0000000..eb83aac --- /dev/null +++ b/lib/autogenesis-engine.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +""" +Autogenesis Engine — SEPL Reflect/Select/Improve cycle for ClawCode. + +Runs inside the 3am dream cycle with ZERO LLM tokens. +Reads priors.json from the learning engine, identifies underperforming +event types, maps them to skills, and generates improvement proposals +stored in memory/autogenesis/pending.json for Claude to review and apply. + +Usage: + python3 autogenesis-engine.py --memory-dir --skill-dir + python3 autogenesis-engine.py --memory-dir --skill-dir --dry-run +""" + +import argparse +import json +import os +import sys +from datetime import datetime, timezone +from pathlib import Path + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +PRIORS_FILE = "learning/priors.json" +PENDING_FILE = "autogenesis/pending.json" +REPORTS_DIR = "autogenesis/reports" + +RESPONSE_RATE_THRESHOLD = 0.40 # below this → candidate for improvement +MIN_OCCURRENCES = 2 # minimum observed events to consider +MIN_NOTIFIED = 1 # must have been notified at least once + +# Maps event types (from learning engine) → skill names to improve +EVENT_SKILL_MAP: dict[str, list[str]] = { + "CI_failure": ["heartbeat"], + "domain_expiry": ["heartbeat"], + "service_down": ["heartbeat"], + "payment_overdue": ["heartbeat"], +} + +# --------------------------------------------------------------------------- +# Phase 1: Reflect — identify underperforming event types +# --------------------------------------------------------------------------- + +def load_priors(memory_dir: Path) -> dict: + p = memory_dir / PRIORS_FILE + if not p.exists(): + return {} + try: + return json.loads(p.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return {} + + +def reflect(priors: dict) -> list[dict]: + """Read priors, return hypothesis list for underperforming event types.""" + hypotheses = [] + for event_type, stats in priors.items(): + occurrences = stats.get("total_occurrences_30d", 0) + notified = stats.get("notified_count_30d", 0) + rate = stats.get("response_rate", 1.0) + + if occurrences < MIN_OCCURRENCES: + continue + if notified < MIN_NOTIFIED: + continue + if rate >= RESPONSE_RATE_THRESHOLD: + continue + + severity = "HIGH" if rate < 0.10 else "MEDIUM" + hypotheses.append({ + "event_type": event_type, + "response_rate": round(rate, 3), + "occurrences": occurrences, + "notified": notified, + "target_skills": EVENT_SKILL_MAP.get(event_type, ["heartbeat"]), + "hypothesis": _build_hypothesis(event_type, rate, stats), + "severity": severity, + }) + return hypotheses + + +def _build_hypothesis(event_type: str, rate: float, stats: dict) -> str: + pct = f"{rate:.0%}" + n = stats.get("notified_count_30d", 0) + templates = { + "CI_failure": ( + f"CI failures are notified but the user responds only {pct} of the time " + f"({n} notifications). The alert may lack enough context " + f"(which repo, which job, how to fix) or the urgency score " + f"is not high enough during typical CI failure hours." + ), + "domain_expiry": ( + f"Domain expiry alerts have a {pct} response rate ({n} notifications). " + f"Alerts may arrive too early to feel urgent, or lack specific " + f"actionable steps (transfer registrar, renew URL, deadline countdown)." + ), + "service_down": ( + f"Service down events have {pct} response rate ({n} notifications). " + f"The alert may be missing key context: which service, HTTP status, " + f"how to restart, or a direct incident ticket link." + ), + "payment_overdue": ( + f"Payment overdue alerts have {pct} response rate ({n} notifications). " + f"These may get lost in briefing noise. Consider elevating to a " + f"high-priority channel with a direct payment link and daily re-alerts." + ), + } + return templates.get(event_type, ( + f"Event type '{event_type}' has {pct} response rate across " + f"{n} notifications. The corresponding skill may need clearer " + f"action items or better urgency framing." + )) + + +# --------------------------------------------------------------------------- +# Phase 2: Select — map hypotheses to concrete skill proposals +# --------------------------------------------------------------------------- + +def select(hypotheses: list[dict], skill_dir: Path) -> list[dict]: + """Translate hypotheses into skill improvement proposals.""" + proposals = [] + today = datetime.now(timezone.utc).isoformat()[:10] + + for hyp in hypotheses: + for skill_name in hyp["target_skills"]: + skill_path = skill_dir / skill_name / "SKILL.md" + if not skill_path.exists(): + continue + + current_content = skill_path.read_text(encoding="utf-8", errors="ignore") + addition = _build_improvement(hyp["event_type"], hyp) + + # Skip if this exact addition already exists in the skill + marker = f"## {hyp['event_type'].replace('_', ' ').title()} Response Protocol (Autogenesis" + if marker in current_content: + continue + + proposal_id = f"{hyp['event_type']}-{skill_name}-{today}" + proposals.append({ + "id": proposal_id, + "hypothesis": hyp, + "skill_name": skill_name, + "skill_path": str(skill_path), + "proposed_addition": addition, + "apply_mode": "append", # append | replace + "current_chars": len(current_content), + "status": "pending", + "created_at": datetime.now(timezone.utc).isoformat()[:16], + "applied_at": None, + "applied_version": None, + }) + return proposals + + +def _build_improvement(event_type: str, hyp: dict) -> str: + """ + Template-based improvement text to append to the skill file. + These are concrete, actionable protocol sections that the agent will + follow when handling the event type. + """ + title = event_type.replace("_", " ").title() + improvements: dict[str, str] = { + "CI_failure": ( + f"\n\n## {title} Response Protocol (Autogenesis v1 — auto-generated)\n" + f"**Trigger:** When a CI_failure event is detected.\n\n" + f"**Required context in every notification:**\n" + f"- Repository name and branch\n" + f"- Failing job name(s) and duration\n" + f"- Commit hash and author of the breaking change\n" + f"- Last passing run (for comparison)\n\n" + f"**Urgency escalation rules:**\n" + f"- ≥3 consecutive failures on `main` → notify via high-priority channel immediately\n" + f"- Failures persisting >2h → create incident ticket and mention in next briefing\n\n" + f"**Suggested fixes to include:**\n" + f"- Dependency update? Check lock file diff\n" + f"- Formatting failure? Include `cargo fmt` / `prettier` command\n" + f"- Test isolation? Flag flaky test pattern\n" + ), + "domain_expiry": ( + f"\n\n## {title} Response Protocol (Autogenesis v1 — auto-generated)\n" + f"**Trigger:** When a domain_expiry event is detected.\n\n" + f"**Required context in every notification:**\n" + f"- Exact expiry date and days remaining (countdown)\n" + f"- Current registrar name and direct renewal URL\n" + f"- Transfer steps if switching registrar (nameservers, auth code)\n\n" + f"**Urgency escalation rules:**\n" + f"- ≤10 days → include in daily briefing AND high-priority channel\n" + f"- ≤5 days → re-alert every 24h until resolved\n" + f"- ≤2 days → escalate to highest-priority notification channel\n\n" + f"**Do not notify only once** — re-alert every 24h if no action taken.\n" + ), + "service_down": ( + f"\n\n## {title} Response Protocol (Autogenesis v1 — auto-generated)\n" + f"**Trigger:** When a service_down event is detected.\n\n" + f"**Required context in every notification:**\n" + f"- Service name, URL, and HTTP status code\n" + f"- Duration of downtime (time since last successful health check)\n" + f"- Last known healthy timestamp\n" + f"- Restart command or runbook link\n\n" + f"**Urgency escalation rules:**\n" + f"- Any service_down → notify via high-priority channel (never just log)\n" + f"- Down >1h → create incident ticket\n" + f"- Down >4h → escalate to highest-priority notification channel\n\n" + f"**Do not mark as resolved** until the health check confirms HTTP 200.\n" + ), + "payment_overdue": ( + f"\n\n## {title} Response Protocol (Autogenesis v1 — auto-generated)\n" + f"**Trigger:** When a payment_overdue event is detected.\n\n" + f"**Required context in every notification:**\n" + f"- Vendor name, amount owed, currency, and days overdue\n" + f"- Service at risk and consequence of non-payment (cutoff date)\n" + f"- Direct payment URL or account login\n\n" + f"**Urgency escalation rules:**\n" + f"- Large amounts → notify via high-priority channel immediately\n" + f"- Overdue >7 days → include in EVERY daily briefing until paid\n" + f"- Overdue >14 days → escalate to highest-priority notification channel\n\n" + f"**Re-alert daily** (not weekly) until payment is confirmed.\n" + ), + } + return improvements.get(event_type, ( + f"\n\n## {title} Response Protocol (Autogenesis v1 — auto-generated)\n" + f"**Trigger:** When a {event_type} event is detected.\n\n" + f"Response rate for this event type is low. Improvements:\n" + f"- Include specific action items (not just descriptions)\n" + f"- Add direct links to resolve the underlying issue\n" + f"- Use high-priority channel for HIGH urgency events (do not queue)\n" + f"- Re-alert every 24h if no action is taken\n" + )) + + +# --------------------------------------------------------------------------- +# Phase 3: Persist proposals +# --------------------------------------------------------------------------- + +def save_pending(memory_dir: Path, proposals: list[dict]) -> int: + """Merge new proposals into pending.json. Returns count of new proposals added.""" + pending_path = memory_dir / PENDING_FILE + pending_path.parent.mkdir(parents=True, exist_ok=True) + + existing: list[dict] = [] + if pending_path.exists(): + try: + existing = json.loads(pending_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + existing = [] + + existing_ids = {e["id"] for e in existing} + new_proposals = [p for p in proposals if p["id"] not in existing_ids] + pending_path.write_text( + json.dumps(existing + new_proposals, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + return len(new_proposals) + + +def write_report(memory_dir: Path, hypotheses: list[dict], proposals: list[dict]) -> None: + reports_dir = memory_dir / REPORTS_DIR + reports_dir.mkdir(parents=True, exist_ok=True) + report_path = reports_dir / f"{datetime.now().isoformat()[:10]}.md" + + with open(report_path, "a", encoding="utf-8") as f: + f.write(f"\n## Autogenesis Reflect Cycle — {datetime.now().isoformat()[:16]}\n\n") + f.write(f"- Hypotheses generated: {len(hypotheses)}\n") + f.write(f"- Proposals queued: {len(proposals)}\n\n") + for h in hypotheses: + f.write(f"### {h['event_type']} (rate={h['response_rate']:.0%}, {h['severity']})\n") + f.write(f"{h['hypothesis']}\n\n") + skills = ", ".join(f"`{s}`" for s in h["target_skills"]) + f.write(f"Target skills: {skills}\n\n") + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser(description="ClawCode Autogenesis Engine") + parser.add_argument("--memory-dir", required=True, help="Path to memory/ directory") + parser.add_argument("--skill-dir", required=True, help="Path to skills/ directory") + parser.add_argument("--dry-run", action="store_true", help="Preview without writing") + args = parser.parse_args() + + memory_dir = Path(args.memory_dir) + skill_dir = Path(args.skill_dir) + + priors = load_priors(memory_dir) + if not priors: + print(json.dumps({"status": "no_priors", "hypotheses": 0, "proposals_new": 0})) + return + + hypotheses = reflect(priors) + proposals = select(hypotheses, skill_dir) + + if args.dry_run: + print(json.dumps({ + "dry_run": True, + "hypotheses": len(hypotheses), + "proposals": len(proposals), + "details": proposals, + }, indent=2, ensure_ascii=False)) + return + + new_count = save_pending(memory_dir, proposals) + write_report(memory_dir, hypotheses, proposals) + + print(json.dumps({ + "status": "ok", + "hypotheses": len(hypotheses), + "proposals_new": new_count, + "proposals_total": len(proposals), + }, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/lib/autogenesis.ts b/lib/autogenesis.ts new file mode 100644 index 0000000..ec53250 --- /dev/null +++ b/lib/autogenesis.ts @@ -0,0 +1,315 @@ +/** + * Autogenesis Orchestrator — coordinates the RSPL/SEPL self-evolution cycle + * for ClawCode. + * + * Responsibilities: + * - Scans skill directories and registers skills in the ResourceRegistry + * - Spawns autogenesis-engine.py for the nightly Reflect/Select cycle + * - Reads pending proposals and applies them (with versioning + rollback) + * - Exposes status, history, and rollback to the MCP `autogenesis` tool + */ + +import fs from "fs"; +import path from "path"; +import { execFileSync } from "child_process"; +import { ResourceRegistry, type ResourceRecord } from "./resource-registry.ts"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface Proposal { + id: string; + hypothesis: { + event_type: string; + response_rate: number; + occurrences: number; + notified: number; + target_skills: string[]; + hypothesis: string; + severity: string; + }; + skill_name: string; + skill_path: string; + proposed_addition: string; + apply_mode: "append" | "replace"; + current_chars: number; + status: "pending" | "applied" | "rejected"; + created_at: string; + applied_at: string | null; + applied_version: string | null; +} + +export interface AutogenesisStatus { + registeredResources: number; + pendingProposals: number; + appliedProposals: number; + resources: Array<{ + name: string; + type: string; + currentVersion: string; + updatedAt: string; + trainable: boolean; + }>; + recentReports: string[]; +} + +export interface ApplyResult { + success: boolean; + version?: string; + error?: string; + skillName?: string; +} + +// --------------------------------------------------------------------------- +// AutogenesisOrchestrator +// --------------------------------------------------------------------------- + +export class AutogenesisOrchestrator { + private registry: ResourceRegistry; + private autogenesisDir: string; + private pendingPath: string; + private reportsDir: string; + private pluginRoot: string; + + constructor(memoryDir: string, pluginRoot: string) { + this.pluginRoot = pluginRoot; + this.autogenesisDir = path.join(memoryDir, "autogenesis"); + this.pendingPath = path.join(this.autogenesisDir, "pending.json"); + this.reportsDir = path.join(this.autogenesisDir, "reports"); + fs.mkdirSync(this.autogenesisDir, { recursive: true }); + fs.mkdirSync(this.reportsDir, { recursive: true }); + this.registry = new ResourceRegistry(this.autogenesisDir); + } + + // --------------------------------------------------------------------------- + // Skill scanning & registration + // --------------------------------------------------------------------------- + + /** + * Walk the plugin skills directory and register every SKILL.md as a + * trainable resource. Called once at startup. + */ + scanAndRegisterSkills(): number { + const skillsDir = path.join(this.pluginRoot, "skills"); + if (!fs.existsSync(skillsDir)) return 0; + + let registered = 0; + for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const skillMd = path.join(skillsDir, entry.name, "SKILL.md"); + if (!fs.existsSync(skillMd)) continue; + + // Read frontmatter description if available + let description = `Skill: ${entry.name}`; + try { + const raw = fs.readFileSync(skillMd, "utf-8"); + const descMatch = raw.match(/^description:\s*(.+)$/m); + if (descMatch) description = descMatch[1].trim(); + } catch { + // best-effort + } + + this.registry.register({ + name: entry.name, + type: "skill", + path: skillMd, + trainable: true, + description, + metadata: { skillsDir }, + }); + registered++; + } + return registered; + } + + // --------------------------------------------------------------------------- + // Reflect/Select cycle (Python engine) + // --------------------------------------------------------------------------- + + /** + * Spawn autogenesis-engine.py — identifies underperforming event types, + * generates improvement proposals, writes them to pending.json. + * Returns the engine's JSON output as a string. + */ + runReflectCycle(memoryDir: string): string { + const engineScript = path.join(this.pluginRoot, "lib", "autogenesis-engine.py"); + const skillDir = path.join(this.pluginRoot, "skills"); + + if (!fs.existsSync(engineScript)) { + return JSON.stringify({ error: "autogenesis-engine.py not found" }); + } + + // Prefer PYTHON env var, then "python", then "python3" as last resort + const pythonBin = process.env.PYTHON ?? "python"; + + try { + return execFileSync( + pythonBin, + [engineScript, "--memory-dir", memoryDir, "--skill-dir", skillDir], + { timeout: 30_000, encoding: "utf-8" } + ); + } catch (err) { + return JSON.stringify({ + error: err instanceof Error ? err.message : String(err), + }); + } + } + + // --------------------------------------------------------------------------- + // Proposal management + // --------------------------------------------------------------------------- + + getPendingProposals(): Proposal[] { + return this.readPending().filter((p) => p.status === "pending"); + } + + getAllProposals(): Proposal[] { + return this.readPending(); + } + + /** + * Apply a pending proposal by ID. + * Appends the proposed content addition to the skill file and versions it. + */ + applyProposal(id: string): ApplyResult { + const all = this.readPending(); + const proposal = all.find((p) => p.id === id); + + if (!proposal) { + return { success: false, error: `Proposal '${id}' not found` }; + } + if (proposal.status !== "pending") { + return { success: false, error: `Proposal '${id}' is already ${proposal.status}` }; + } + if (!fs.existsSync(proposal.skill_path)) { + return { success: false, error: `Skill file not found: ${proposal.skill_path}` }; + } + + // Guard: skill_path must resolve inside pluginRoot/skills/ to prevent + // a tampered pending.json from writing outside the skills directory. + const resolvedPath = path.resolve(proposal.skill_path); + const skillsRoot = path.resolve(this.pluginRoot, "skills"); + if (!resolvedPath.startsWith(skillsRoot + path.sep)) { + return { success: false, error: "skill_path escapes skills directory — proposal rejected" }; + } + + const currentContent = fs.readFileSync(proposal.skill_path, "utf-8"); + let newContent: string; + + if (proposal.apply_mode === "append") { + newContent = currentContent + proposal.proposed_addition; + } else { + newContent = proposal.proposed_addition; + } + + // Ensure the skill is registered before applying + const existing = this.registry.getResource(proposal.skill_name); + if (!existing) { + this.registry.register({ + name: proposal.skill_name, + type: "skill", + path: proposal.skill_path, + trainable: true, + description: `Skill: ${proposal.skill_name}`, + metadata: {}, + }); + } + + const newVersion = this.registry.applyContent( + proposal.skill_name, + newContent, + `Autogenesis: ${proposal.hypothesis.event_type} improvement (response_rate=${proposal.hypothesis.response_rate})` + ); + + if (!newVersion) { + return { success: false, error: "Registry applyContent failed" }; + } + + // Update proposal status + proposal.status = "applied"; + proposal.applied_at = new Date().toISOString(); + proposal.applied_version = newVersion; + this.writePending(all); + + return { success: true, version: newVersion, skillName: proposal.skill_name }; + } + + /** + * Reject a pending proposal (mark as rejected without applying). + */ + rejectProposal(id: string): boolean { + const all = this.readPending(); + const proposal = all.find((p) => p.id === id); + if (!proposal || proposal.status !== "pending") return false; + proposal.status = "rejected"; + this.writePending(all); + return true; + } + + // --------------------------------------------------------------------------- + // Versioning & rollback + // --------------------------------------------------------------------------- + + rollback(skillName: string, toVersion: string): boolean { + return this.registry.rollback(skillName, toVersion); + } + + getHistory(skillName: string) { + return this.registry.getHistory(skillName); + } + + // --------------------------------------------------------------------------- + // Status + // --------------------------------------------------------------------------- + + getStatus(): AutogenesisStatus { + const resources = this.registry.listResources(); + const all = this.readPending(); + const pending = all.filter((p) => p.status === "pending").length; + const applied = all.filter((p) => p.status === "applied").length; + + // Recent report files + let recentReports: string[] = []; + try { + recentReports = fs + .readdirSync(this.reportsDir) + .filter((f) => f.endsWith(".md")) + .sort() + .slice(-3); + } catch { + // reports dir may not exist yet + } + + return { + registeredResources: resources.length, + pendingProposals: pending, + appliedProposals: applied, + resources: resources.map((r) => ({ + name: r.name, + type: r.type, + currentVersion: r.currentVersion, + updatedAt: r.updatedAt, + trainable: r.trainable, + })), + recentReports, + }; + } + + // --------------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------------- + + private readPending(): Proposal[] { + if (!fs.existsSync(this.pendingPath)) return []; + try { + return JSON.parse(fs.readFileSync(this.pendingPath, "utf-8")) as Proposal[]; + } catch { + return []; + } + } + + private writePending(proposals: Proposal[]): void { + fs.writeFileSync(this.pendingPath, JSON.stringify(proposals, null, 2)); + } +} diff --git a/lib/config.ts b/lib/config.ts index 6303988..0ab5d7d 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -78,6 +78,27 @@ export interface AgentConfig { /** Timezone for dreaming cron */ timezone?: string; }; + /** + * Paperclip integration — connect to the Paperclip control plane for task + * management, issue tracking, and agent coordination. Credentials can also + * be provided via env vars (PAPERCLIP_API_URL, PAPERCLIP_API_KEY, etc.). + */ + paperclip?: { + /** Paperclip API base URL (e.g., "http://localhost:3000") */ + apiUrl?: string; + /** Bearer token for API auth (agent API key) */ + apiKey?: string; + /** Company ID for scoped requests */ + companyId?: string; + /** Agent ID (defaults to the authenticated agent) */ + agentId?: string; + /** + * Auto-sync Paperclip issues with the Task Ledger when invoked via + * heartbeat. The Task Completion Guard will enforce issue completion. + * Default: true. + */ + autoSync?: boolean; + }; memory: { /** "builtin" = SQLite+FTS5 (default), "qmd" = QMD external tool */ backend: "builtin" | "qmd"; @@ -160,6 +181,7 @@ export function loadConfig(pluginRoot: string): AgentConfig { memoryContext: parsed.memoryContext ? { ...parsed.memoryContext } : undefined, heartbeat: parsed.heartbeat ? { ...parsed.heartbeat } : undefined, dreaming: parsed.dreaming ? { ...parsed.dreaming } : undefined, + paperclip: parsed.paperclip ? { ...parsed.paperclip } : undefined, memory: { ...DEFAULT_CONFIG.memory, ...parsed.memory, diff --git a/lib/learning-engine.py b/lib/learning-engine.py new file mode 100644 index 0000000..10831cc --- /dev/null +++ b/lib/learning-engine.py @@ -0,0 +1,381 @@ +#!/usr/bin/env python3 +""" +Learning Engine — behavioral pattern extraction for the assistant system. + +Runs inside the 3am dream cycle (zero LLM tokens). Reads SIGNAL tags from +memory .md files, updates priors.json via EMA, and persists raw events in +engagement.sqlite. + +Usage: + python3 learning-engine.py [--memory-dir ] [--dry-run] + +Signal format in .md files: + + +""" + +import argparse +import json +import math +import os +import re +import sqlite3 +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- + +DEFAULT_MEMORY_DIR = Path.home() / "Documents/colabs/memory" +PRIORS_FILE = "learning/priors.json" +SQLITE_FILE = "learning/engagement.sqlite" +INSIGHTS_FILE = "learning/weekly-insights.md" + +# --------------------------------------------------------------------------- +# Signal extraction +# --------------------------------------------------------------------------- + +SIGNAL_RE = re.compile( + r"", + re.IGNORECASE, +) + +ATTR_RE = re.compile(r'(\w+)=([^\s"]+|"[^"]*")') + + +def parse_signal_tag(raw: str) -> dict: + """Parse key=value pairs from a SIGNAL comment body.""" + attrs = {} + for m in ATTR_RE.finditer(raw): + key = m.group(1) + val = m.group(2).strip('"') + # coerce booleans and numbers + if val.lower() == "true": + val = True + elif val.lower() == "false": + val = False + else: + try: + val = int(val) + except ValueError: + try: + val = float(val) + except ValueError: + pass + attrs[key] = val + return attrs + + +def extract_signals_from_file(path: Path) -> list[dict]: + """Extract all SIGNAL tags from a single .md file.""" + try: + text = path.read_text(encoding="utf-8", errors="ignore") + except OSError: + return [] + signals = [] + for m in SIGNAL_RE.finditer(text): + attrs = parse_signal_tag(m.group(1)) + if "type" not in attrs: + continue + attrs["_source_file"] = str(path.name) + signals.append(attrs) + return signals + + +def collect_signals(memory_dir: Path, since_days: int = 7) -> list[dict]: + """Collect signals from .md files modified in the last N days.""" + now = datetime.now().timestamp() + cutoff = now - since_days * 86400 + signals = [] + for p in sorted(memory_dir.glob("*.md")): + if p.stat().st_mtime < cutoff: + continue + signals.extend(extract_signals_from_file(p)) + return signals + + +# --------------------------------------------------------------------------- +# SQLite engagement log +# --------------------------------------------------------------------------- + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS signals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + event_type TEXT NOT NULL, + urgency TEXT, + notified INTEGER, + responded INTEGER, + delay_min INTEGER, + source_file TEXT, + raw_attrs TEXT +); +CREATE INDEX IF NOT EXISTS idx_event_type ON signals(event_type); +CREATE INDEX IF NOT EXISTS idx_ts ON signals(ts); +""" + + +def open_db(memory_dir: Path) -> sqlite3.Connection: + db_path = memory_dir / SQLITE_FILE + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db_path)) + conn.executescript(SCHEMA) + conn.commit() + return conn + + +def insert_signal(conn: sqlite3.Connection, sig: dict) -> None: + ts = sig.get("ts") or datetime.now(timezone.utc).isoformat() + conn.execute( + """INSERT INTO signals + (ts, event_type, urgency, notified, responded, delay_min, source_file, raw_attrs) + VALUES (?,?,?,?,?,?,?,?)""", + ( + ts, + sig.get("type", "unknown"), + sig.get("urgency"), + 1 if sig.get("notified") else 0, + 1 if sig.get("pablo_responded") else 0, + sig.get("response_delay_min"), + sig.get("_source_file"), + json.dumps({k: v for k, v in sig.items() if not k.startswith("_")}), + ), + ) + + +def already_logged(conn: sqlite3.Connection, sig: dict) -> bool: + """Skip duplicate signals (same type + ts + source_file).""" + ts = sig.get("ts", "") + if not ts: + return False + row = conn.execute( + "SELECT 1 FROM signals WHERE event_type=? AND ts=? AND source_file=? LIMIT 1", + (sig.get("type", "unknown"), ts, sig.get("_source_file", "")), + ).fetchone() + return row is not None + + +# --------------------------------------------------------------------------- +# Prior computation +# --------------------------------------------------------------------------- + +EMA_ALPHA = 0.30 # 30% new data, 70% historical + + +def load_priors(memory_dir: Path) -> dict: + p = memory_dir / PRIORS_FILE + if p.exists(): + try: + return json.loads(p.read_text()) + except json.JSONDecodeError: + pass + return {} + + +def save_priors(memory_dir: Path, priors: dict) -> None: + p = memory_dir / PRIORS_FILE + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps(priors, indent=2, ensure_ascii=False)) + + +def compute_event_stats(conn: sqlite3.Connection, event_type: str, lookback_days: int = 30) -> dict: + """Compute response_rate and avg_delay_min for a given event type.""" + cutoff = datetime.now(timezone.utc).isoformat()[:10] # YYYY-MM-DD + rows = conn.execute( + """SELECT notified, responded, delay_min + FROM signals + WHERE event_type=? + AND ts >= date('now', ?) + """, + (event_type, f"-{lookback_days} days"), + ).fetchall() + + if not rows: + return {} + + notified_count = sum(1 for r in rows if r[0]) + responded_count = sum(1 for r in rows if r[1]) + delays = [r[2] for r in rows if r[2] is not None] + + response_rate = responded_count / notified_count if notified_count else 0.0 + avg_delay = sum(delays) / len(delays) if delays else None + total_occurrences = len(rows) + + return { + "response_rate": round(response_rate, 3), + "avg_response_delay_min": round(avg_delay, 1) if avg_delay is not None else None, + "total_occurrences_30d": total_occurrences, + "notified_count_30d": notified_count, + } + + +def update_priors(memory_dir: Path, conn: sqlite3.Connection) -> dict: + """Recompute priors for all known event types using EMA.""" + priors = load_priors(memory_dir) + + event_types = [ + r[0] + for r in conn.execute("SELECT DISTINCT event_type FROM signals").fetchall() + ] + + updated = 0 + for etype in event_types: + stats = compute_event_stats(conn, etype) + if not stats: + continue + + existing = priors.get(etype, {}) + + # EMA blend for response_rate + old_rate = existing.get("response_rate", stats["response_rate"]) + new_rate = EMA_ALPHA * stats["response_rate"] + (1 - EMA_ALPHA) * old_rate + + priors[etype] = { + **existing, + **stats, + "response_rate": round(new_rate, 3), + "updated_at": datetime.now(timezone.utc).isoformat()[:16], + } + updated += 1 + + return priors, updated + + +# --------------------------------------------------------------------------- +# Notify scoring (used by heartbeat at runtime) +# --------------------------------------------------------------------------- + +# Time-of-day engagement weights (hour → weight) +TIME_WEIGHTS: dict[int, float] = { + 6: 1.4, 7: 1.3, # morning briefing + 8: 1.0, 9: 1.5, 10: 1.5, 11: 1.4, # execution block + 12: 0.9, 13: 0.8, # post-lunch + 14: 1.0, 15: 1.0, 16: 1.0, 17: 0.9, # afternoon + 18: 0.7, 19: 0.5, # wind-down + 20: 0.2, 21: 0.3, 22: 0.1, # gym / ops + 23: 0.1, 0: 0.0, 1: 0.0, 2: 0.0, + 3: 0.0, 4: 0.0, 5: 0.2, +} + +URGENCY_BASE: dict[str, float] = { + "HIGH": 1.0, + "MEDIUM": 0.6, + "LOW": 0.3, +} + +NOTIFY_THRESHOLD = 0.55 + + +def should_notify(event_type: str, urgency: str, priors: dict, hour: Optional[int] = None) -> dict: + """ + Score an event and return whether to notify Pablo. + Used by heartbeat agents at runtime. + """ + if hour is None: + hour = datetime.now().hour + + base = URGENCY_BASE.get(urgency.upper(), 0.5) + time_w = TIME_WEIGHTS.get(hour, 0.5) + event_priors = priors.get(event_type, {}) + response_rate = event_priors.get("response_rate", 0.5) # default: neutral + + score = base * time_w * (0.5 + 0.5 * response_rate) + + return { + "notify": score >= NOTIFY_THRESHOLD, + "score": round(score, 3), + "base_urgency": base, + "time_weight": time_w, + "response_rate": response_rate, + "threshold": NOTIFY_THRESHOLD, + } + + +# --------------------------------------------------------------------------- +# Main dream cycle runner +# --------------------------------------------------------------------------- + +def run_dream(memory_dir: Path, dry_run: bool = False) -> dict: + """Full learning cycle — called from 3am dream cron.""" + print(f"[learning-engine] Starting dream cycle — {datetime.now().isoformat()[:16]}") + print(f"[learning-engine] Memory dir: {memory_dir}") + + # 1. Collect signals from last 7 days of .md files + signals = collect_signals(memory_dir, since_days=7) + print(f"[learning-engine] Found {len(signals)} raw signals") + + if dry_run: + print("[learning-engine] DRY RUN — no writes") + for s in signals[:10]: + print(f" {s}") + return {"dry_run": True, "signals_found": len(signals)} + + # 2. Persist new signals to SQLite + conn = open_db(memory_dir) + new_count = 0 + for sig in signals: + if not already_logged(conn, sig): + insert_signal(conn, sig) + new_count += 1 + conn.commit() + print(f"[learning-engine] Persisted {new_count} new signals to engagement.sqlite") + + # 3. Update priors via EMA + priors, updated_count = update_priors(memory_dir, conn) + save_priors(memory_dir, priors) + print(f"[learning-engine] Updated {updated_count} event type priors") + + conn.close() + + # 4. Append run summary to weekly-insights.md + summary_path = memory_dir / INSIGHTS_FILE + summary_path.parent.mkdir(parents=True, exist_ok=True) + with open(summary_path, "a") as f: + f.write(f"\n## Dream Run — {datetime.now().isoformat()[:16]}\n") + f.write(f"- Signals collected: {len(signals)} ({new_count} new)\n") + f.write(f"- Event types updated: {updated_count}\n") + for etype, p in priors.items(): + f.write(f"- `{etype}`: response_rate={p.get('response_rate', '?')} " + f"occurrences={p.get('total_occurrences_30d', '?')}\n") + + return { + "signals_collected": len(signals), + "new_signals": new_count, + "priors_updated": updated_count, + "priors": priors, + } + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser(description="ClawCode Learning Engine") + parser.add_argument("--memory-dir", default=str(DEFAULT_MEMORY_DIR), + help="Path to memory/ directory") + parser.add_argument("--dry-run", action="store_true", + help="Parse signals without writing anything") + parser.add_argument("--score", nargs=3, metavar=("EVENT_TYPE", "URGENCY", "HOUR"), + help="Score a hypothetical event: --score CI_failure HIGH 9") + args = parser.parse_args() + + memory_dir = Path(args.memory_dir) + + if args.score: + etype, urgency, hour = args.score + priors = load_priors(memory_dir) + result = should_notify(etype, urgency, priors, int(hour)) + print(json.dumps(result, indent=2)) + return + + result = run_dream(memory_dir, dry_run=args.dry_run) + print(json.dumps(result, indent=2, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/lib/paperclip-bridge.ts b/lib/paperclip-bridge.ts new file mode 100644 index 0000000..4ead2a3 --- /dev/null +++ b/lib/paperclip-bridge.ts @@ -0,0 +1,218 @@ +/** + * Paperclip Bridge — HTTP client for the Paperclip control plane API. + * + * Reads credentials from environment variables (injected by Paperclip heartbeat) + * or from agent-config.json. Provides typed methods for the most common + * operations: inbox, issues, comments, agents, wakeups. + * + * All methods are async and throw on HTTP errors with structured messages. + */ + +import { loadConfig } from "./config.ts"; + +// --------------------------------------------------------------------------- +// Config resolution +// --------------------------------------------------------------------------- + +export interface PaperclipConfig { + apiUrl: string; + apiKey: string; + companyId: string; + agentId?: string; + runId?: string; + /** Auto-open a task_ledger entry when a Paperclip heartbeat starts. */ + autoSync?: boolean; +} + +export function resolvePaperclipConfig(workspace: string): PaperclipConfig | null { + // Priority: env vars (set by Paperclip heartbeat) > agent-config.json + const env = { + apiUrl: process.env.PAPERCLIP_API_URL || process.env.PAPERCLIP_BASE_URL, + apiKey: process.env.PAPERCLIP_API_KEY || process.env.PAPERCLIP_TOKEN, + companyId: process.env.PAPERCLIP_COMPANY_ID, + agentId: process.env.PAPERCLIP_AGENT_ID, + runId: process.env.PAPERCLIP_RUN_ID || process.env.X_PAPERCLIP_RUN_ID, + }; + + if (env.apiUrl && env.apiKey && env.companyId) { + return { + apiUrl: env.apiUrl.replace(/\/+$/, ""), + apiKey: env.apiKey, + companyId: env.companyId, + agentId: env.agentId || undefined, + runId: env.runId || undefined, + autoSync: true, + }; + } + + // Fall back to agent-config.json + try { + const cfg = loadConfig(workspace) as any; + const pc = cfg.paperclip; + if (pc?.apiUrl && pc?.apiKey && pc?.companyId) { + return { + apiUrl: String(pc.apiUrl).replace(/\/+$/, ""), + apiKey: String(pc.apiKey), + companyId: String(pc.companyId), + agentId: pc.agentId ? String(pc.agentId) : undefined, + autoSync: pc.autoSync !== false, + }; + } + } catch {} + + return null; +} + +// --------------------------------------------------------------------------- +// HTTP helpers +// --------------------------------------------------------------------------- + +async function request( + cfg: PaperclipConfig, + method: string, + path: string, + body?: unknown +): Promise { + const url = `${cfg.apiUrl}/api${path}`; + const headers: Record = { + Authorization: `Bearer ${cfg.apiKey}`, + "Company-Id": cfg.companyId, + "Content-Type": "application/json", + }; + if (cfg.runId) { + headers["X-Paperclip-Run-Id"] = cfg.runId; + } + + const res = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Paperclip ${method} ${path} → ${res.status}: ${text.slice(0, 300)}`); + } + + const ct = res.headers.get("content-type") || ""; + if (ct.includes("application/json")) { + return res.json(); + } + return res.text(); +} + +// --------------------------------------------------------------------------- +// API methods +// --------------------------------------------------------------------------- + +export class PaperclipClient { + constructor(private cfg: PaperclipConfig) {} + + // -- Identity + async me(): Promise { + return request(this.cfg, "GET", "/agents/me"); + } + + // -- Inbox + async inbox(): Promise { + return request(this.cfg, "GET", "/agents/me/inbox-lite"); + } + + // -- Issues + async listIssues(params?: { + status?: string; + assigneeId?: string; + projectId?: string; + limit?: number; + }): Promise { + const q = new URLSearchParams(); + if (params?.status) q.set("status", params.status); + if (params?.assigneeId) q.set("assigneeId", params.assigneeId); + if (params?.projectId) q.set("projectId", params.projectId); + if (params?.limit) q.set("limit", String(params.limit)); + const qs = q.toString(); + return request( + this.cfg, + "GET", + `/companies/${this.cfg.companyId}/issues${qs ? "?" + qs : ""}` + ); + } + + async getIssue(idOrIdentifier: string): Promise { + return request(this.cfg, "GET", `/issues/${idOrIdentifier}`); + } + + async createIssue(data: { + title: string; + description?: string; + projectId?: string; + priority?: string; + labels?: string[]; + }): Promise { + return request( + this.cfg, + "POST", + `/companies/${this.cfg.companyId}/issues`, + data + ); + } + + async updateIssue( + id: string, + data: { title?: string; status?: string; priority?: string; description?: string } + ): Promise { + return request(this.cfg, "PATCH", `/issues/${id}`, data); + } + + async checkoutIssue(issueId: string, agentId?: string): Promise { + return request(this.cfg, "POST", `/issues/${issueId}/checkout`, { + agentId: agentId || this.cfg.agentId, + }); + } + + // -- Comments + async listComments(issueId: string, limit = 20): Promise { + return request( + this.cfg, + "GET", + `/issues/${issueId}/comments?limit=${limit}` + ); + } + + async addComment(issueId: string, body: string): Promise { + return request(this.cfg, "POST", `/issues/${issueId}/comments`, { body }); + } + + // -- Agents + async listAgents(): Promise { + return request( + this.cfg, + "GET", + `/companies/${this.cfg.companyId}/agents` + ); + } + + async getAgent(id: string): Promise { + return request(this.cfg, "GET", `/agents/${id}`); + } + + async wakeupAgent(id: string, reason?: string): Promise { + return request(this.cfg, "POST", `/agents/${id}/wakeup`, { + reason: reason || "Triggered from ClawCode", + }); + } + + // -- Heartbeat context + async heartbeatContext(): Promise { + if (!this.cfg.runId) return null; + return request( + this.cfg, + "GET", + `/heartbeat-runs/${this.cfg.runId}` + ); + } + + getConfig(): PaperclipConfig { + return { ...this.cfg }; + } +} diff --git a/lib/paperclip-sync.ts b/lib/paperclip-sync.ts new file mode 100644 index 0000000..eb397d5 --- /dev/null +++ b/lib/paperclip-sync.ts @@ -0,0 +1,132 @@ +/** + * Paperclip ↔ Task Ledger sync. + * + * When ClawCode is invoked by a Paperclip heartbeat: + * 1. Reads the heartbeat context (issue title, acceptance criteria from description) + * 2. Auto-opens a task_ledger entry so the Task Completion Guard enforces completion + * 3. On task_close, posts a comment back to the Paperclip issue with the summary + * + * This bridges Paperclip's issue lifecycle with ClawCode's completion enforcement. + */ + +import { PaperclipClient, type PaperclipConfig } from "./paperclip-bridge.ts"; +import { TaskLedger, type ActiveTask } from "./task-ledger.ts"; + +export interface SyncResult { + synced: boolean; + taskId?: string; + issueId?: string; + reason?: string; +} + +/** + * Called at session start when Paperclip env vars are present. + * Reads the heartbeat context and opens a task ledger entry. + */ +export async function syncFromHeartbeat( + client: PaperclipClient, + ledger: TaskLedger +): Promise { + const cfg = client.getConfig(); + if (!cfg.runId) { + return { synced: false, reason: "no runId — not a heartbeat invocation" }; + } + + // Check if there's already an open task for this run + const active = ledger.activeTasks(); + const existing = active.find( + (t) => t.source === `paperclip:${cfg.runId}` + ); + if (existing) { + return { + synced: true, + taskId: existing.id, + issueId: existing.goal, + reason: "already synced", + }; + } + + try { + const run = await client.heartbeatContext(); + if (!run) { + return { synced: false, reason: "could not fetch heartbeat context" }; + } + + // Extract issue context from the run + const context = run.contextSnapshot || run; + const issueTitle = context.issueTitle || context.title || run.triggerDetail || "Paperclip task"; + const issueId = context.issueId || context.issue?.id; + + // Build criteria from issue description or use defaults + const criteria = extractCriteria(context.issueDescription || context.description || ""); + if (criteria.length === 0) { + criteria.push("Task completed as described in the issue"); + } + + const event = ledger.open( + `[${issueTitle}]${issueId ? ` (${issueId})` : ""}`, + criteria, + `paperclip:${cfg.runId}` + ); + + return { + synced: true, + taskId: event.id, + issueId: issueId || undefined, + }; + } catch (e: any) { + return { + synced: false, + reason: `heartbeat context error: ${e.message}`, + }; + } +} + +/** + * Called when task_close fires for a Paperclip-sourced task. + * Posts a summary comment back to the issue. + */ +export async function syncOnClose( + client: PaperclipClient, + issueId: string, + summary: string, + force: boolean +): Promise { + try { + const prefix = force ? "⚠️ Task force-closed" : "✅ Task completed"; + await client.addComment( + issueId, + `${prefix} by ClawCode agent:\n\n${summary}` + ); + } catch { + // Best-effort — don't break the close flow + } +} + +/** + * Extract acceptance criteria from an issue description. + * Looks for markdown checklists (- [ ] ...) or numbered lists. + */ +function extractCriteria(description: string): string[] { + const criteria: string[] = []; + const lines = description.split("\n"); + + for (const line of lines) { + const trimmed = line.trim(); + // Markdown checklist: - [ ] criterion or - [x] criterion + const checkMatch = trimmed.match(/^[-*]\s*\[[ x]?\]\s*(.+)/i); + if (checkMatch) { + criteria.push(checkMatch[1].trim()); + continue; + } + // Numbered list: 1. criterion or 1) criterion + const numMatch = trimmed.match(/^\d+[.)]\s+(.+)/); + if (numMatch && criteria.length > 0) { + // Only capture numbered items after we've found at least one checklist item + // to avoid treating random paragraphs as criteria + criteria.push(numMatch[1].trim()); + } + } + + return criteria.slice(0, 10); // Cap at 10 criteria +} diff --git a/lib/resource-registry.ts b/lib/resource-registry.ts new file mode 100644 index 0000000..800c3a0 --- /dev/null +++ b/lib/resource-registry.ts @@ -0,0 +1,242 @@ +/** + * Resource Registry — RSPL (Resource Substrate Protocol Layer) for ClawCode. + * + * Treats skills as protocol-registered resources with explicit state, + * lifecycle, and version lineage. Every modification is tracked and + * reversible via rollback. + * + * Storage layout under /: + * registry.json — active resource records + * versions// — per-resource version directory + * history.json — ordered list of VersionEntry (metadata only) + * v1.0.0.md — full content snapshot for rollback + */ + +import fs from "fs"; +import path from "path"; +import crypto from "crypto"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ResourceType = "skill" | "prompt" | "config" | "agent"; + +export interface ResourceRecord { + name: string; + type: ResourceType; + path: string; + currentVersion: string; + trainable: boolean; + description: string; + metadata: Record; + createdAt: string; + updatedAt: string; +} + +export interface VersionEntry { + version: string; + contentHash: string; + contentPreview: string; + ts: string; + reason: string; + parentVersion: string | null; + committed: boolean; +} + +interface RegistryData { + schemaVersion: number; + resources: Record; + updatedAt: string; +} + +// --------------------------------------------------------------------------- +// ResourceRegistry +// --------------------------------------------------------------------------- + +export class ResourceRegistry { + private registryPath: string; + private versionsDir: string; + private data: RegistryData; + + constructor(autogenesisDir: string) { + fs.mkdirSync(autogenesisDir, { recursive: true }); + this.registryPath = path.join(autogenesisDir, "registry.json"); + this.versionsDir = path.join(autogenesisDir, "versions"); + fs.mkdirSync(this.versionsDir, { recursive: true }); + this.data = this.load(); + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /** Register a resource. No-op if already registered. */ + register( + record: Omit + ): void { + this.assertSafe(record.name, "name"); + if (this.data.resources[record.name]) return; + + const content = fs.existsSync(record.path) + ? fs.readFileSync(record.path, "utf-8") + : ""; + const initialVersion = "1.0.0"; + this.persistVersion(record.name, initialVersion, content, "Initial registration", null); + + this.data.resources[record.name] = { + ...record, + currentVersion: initialVersion, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + this.save(); + } + + /** + * Take a snapshot of the current on-disk content, bumping patch version. + * Returns the new version string, or null if resource not found. + */ + snapshot(name: string, reason: string): string | null { + const resource = this.data.resources[name]; + if (!resource || !fs.existsSync(resource.path)) return null; + + const content = fs.readFileSync(resource.path, "utf-8"); + const newVersion = this.bumpPatch(resource.currentVersion); + this.persistVersion(name, newVersion, content, reason, resource.currentVersion); + resource.currentVersion = newVersion; + resource.updatedAt = new Date().toISOString(); + this.save(); + return newVersion; + } + + /** + * Write newContent to the resource file and record the version. + * Returns new version string or null on failure. + */ + applyContent(name: string, newContent: string, reason: string): string | null { + const resource = this.data.resources[name]; + if (!resource) return null; + + const newVersion = this.bumpPatch(resource.currentVersion); + this.persistVersion(name, newVersion, newContent, reason, resource.currentVersion); + fs.writeFileSync(resource.path, newContent, "utf-8"); + resource.currentVersion = newVersion; + resource.updatedAt = new Date().toISOString(); + this.save(); + return newVersion; + } + + /** + * Restore file to a previous version. Returns true on success. + */ + rollback(name: string, toVersion: string): boolean { + this.assertSafe(name, "name"); + this.assertSafe(toVersion, "version"); + const resource = this.data.resources[name]; + if (!resource) return false; + + const snapshotPath = path.join(this.versionsDir, name, `${toVersion}.md`); + if (!fs.existsSync(snapshotPath)) return false; + + const content = fs.readFileSync(snapshotPath, "utf-8"); + fs.writeFileSync(resource.path, content, "utf-8"); + resource.currentVersion = toVersion; + resource.updatedAt = new Date().toISOString(); + this.save(); + return true; + } + + /** Full version history for a resource. */ + getHistory(name: string): VersionEntry[] { + this.assertSafe(name, "name"); + const metaPath = path.join(this.versionsDir, name, "history.json"); + if (!fs.existsSync(metaPath)) return []; + try { + return JSON.parse(fs.readFileSync(metaPath, "utf-8")) as VersionEntry[]; + } catch { + return []; + } + } + + getResource(name: string): ResourceRecord | null { + return this.data.resources[name] ?? null; + } + + listResources(): ResourceRecord[] { + return Object.values(this.data.resources); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** Reject names/versions that could escape the versions directory. */ + private assertSafe(value: string, field: string): void { + if (!/^[A-Za-z0-9._-]+$/.test(value)) { + throw new Error(`Invalid ${field}: "${value}" — only [A-Za-z0-9._-] allowed`); + } + } + + private load(): RegistryData { + if (fs.existsSync(this.registryPath)) { + try { + return JSON.parse(fs.readFileSync(this.registryPath, "utf-8")) as RegistryData; + } catch { + // corrupt — start fresh + } + } + return { schemaVersion: 1, resources: {}, updatedAt: new Date().toISOString() }; + } + + private save(): void { + this.data.updatedAt = new Date().toISOString(); + fs.writeFileSync(this.registryPath, JSON.stringify(this.data, null, 2)); + } + + private bumpPatch(version: string): string { + const parts = version.split(".").map((s) => { + const n = Number(s); + return Number.isFinite(n) ? n : 0; + }); + while (parts.length < 3) parts.push(0); + parts[2] += 1; + return parts.join("."); + } + + private contentHash(content: string): string { + return crypto.createHash("sha256").update(content).digest("hex").slice(0, 12); + } + + private persistVersion( + name: string, + version: string, + content: string, + reason: string, + parent: string | null + ): void { + const dir = path.join(this.versionsDir, name); + fs.mkdirSync(dir, { recursive: true }); + + // Full content snapshot for rollback + fs.writeFileSync(path.join(dir, `${version}.md`), content, "utf-8"); + + // Append to history metadata + const metaPath = path.join(dir, "history.json"); + const history: VersionEntry[] = fs.existsSync(metaPath) + ? (JSON.parse(fs.readFileSync(metaPath, "utf-8")) as VersionEntry[]) + : []; + + history.push({ + version, + contentHash: this.contentHash(content), + contentPreview: content.slice(0, 200).replace(/\n/g, " ") + (content.length > 200 ? "…" : ""), + ts: new Date().toISOString(), + reason, + parentVersion: parent, + committed: true, + }); + + fs.writeFileSync(metaPath, JSON.stringify(history, null, 2)); + } +} diff --git a/lib/task-ledger.ts b/lib/task-ledger.ts new file mode 100644 index 0000000..f2a3cc5 --- /dev/null +++ b/lib/task-ledger.ts @@ -0,0 +1,200 @@ +/** + * Task ledger — append-only JSONL store of agent tasks with acceptance criteria. + * + * Backs the Task Completion Guard: the Stop hook reads `activeTasks()` and + * blocks the agent from terminating while any task remains open. + * + * File: memory/.tasks/active.jsonl + * Each line is one event: + * { type: "open", id, goal, criteria, createdAt, source? } + * { type: "check", id, criterion, evidence, ts } + * { type: "close", id, summary, closedAt, force? } + * + * Active task = an "open" event with no later "close" event for the same id. + */ + +import fs from "fs"; +import path from "path"; +import crypto from "crypto"; + +export interface TaskOpenEvent { + type: "open"; + id: string; + goal: string; + criteria: string[]; + createdAt: string; + source?: string; +} + +export interface TaskCheckEvent { + type: "check"; + id: string; + criterion: string; + evidence: string; + ts: string; +} + +export interface TaskCloseEvent { + type: "close"; + id: string; + summary: string; + closedAt: string; + force?: boolean; +} + +export type TaskEvent = TaskOpenEvent | TaskCheckEvent | TaskCloseEvent; + +export interface ActiveTask { + id: string; + goal: string; + criteria: string[]; + createdAt: string; + source?: string; + satisfied: string[]; + remaining: string[]; + evidence: Record; +} + +export class TaskLedger { + private workspace: string; + private dir: string; + private file: string; + + constructor(workspace: string) { + this.workspace = workspace; + this.dir = path.join(workspace, "memory", ".tasks"); + this.file = path.join(this.dir, "active.jsonl"); + } + + private ensureDir(): void { + fs.mkdirSync(this.dir, { recursive: true }); + } + + private readEvents(): TaskEvent[] { + if (!fs.existsSync(this.file)) return []; + const raw = fs.readFileSync(this.file, "utf-8"); + const out: TaskEvent[] = []; + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + out.push(JSON.parse(trimmed) as TaskEvent); + } catch { + // Skip malformed lines — ledger remains forward-readable. + } + } + return out; + } + + private append(event: TaskEvent): void { + this.ensureDir(); + fs.appendFileSync(this.file, JSON.stringify(event) + "\n"); + } + + open(goal: string, criteria: string[], source?: string): TaskOpenEvent { + const cleanGoal = goal.trim(); + if (!cleanGoal) throw new Error("goal is required"); + const cleanCriteria = (criteria || []) + .map((c) => String(c || "").trim()) + .filter((c) => c.length > 0); + if (cleanCriteria.length === 0) { + throw new Error("at least one acceptance criterion is required"); + } + const id = "t-" + crypto.randomBytes(4).toString("hex"); + const event: TaskOpenEvent = { + type: "open", + id, + goal: cleanGoal, + criteria: cleanCriteria, + createdAt: new Date().toISOString(), + source, + }; + this.append(event); + return event; + } + + check(id: string, criterion: string, evidence: string): TaskCheckEvent { + const cleanId = String(id || "").trim(); + const cleanCriterion = String(criterion || "").trim(); + const cleanEvidence = String(evidence || "").trim(); + if (!cleanId || !cleanCriterion || !cleanEvidence) { + throw new Error("id, criterion, and evidence are all required"); + } + const active = this.activeTasks().find((t) => t.id === cleanId); + if (!active) throw new Error(`no active task with id ${cleanId}`); + if (!active.criteria.includes(cleanCriterion)) { + throw new Error( + `criterion "${cleanCriterion}" is not part of task ${cleanId}` + ); + } + const event: TaskCheckEvent = { + type: "check", + id: cleanId, + criterion: cleanCriterion, + evidence: cleanEvidence, + ts: new Date().toISOString(), + }; + this.append(event); + return event; + } + + close(id: string, summary: string, force = false): TaskCloseEvent { + const cleanId = String(id || "").trim(); + const cleanSummary = String(summary || "").trim(); + if (!cleanId) throw new Error("id is required"); + if (!cleanSummary) throw new Error("summary is required to close a task"); + + const active = this.activeTasks().find((t) => t.id === cleanId); + if (!active) throw new Error(`no active task with id ${cleanId}`); + if (!force && active.remaining.length > 0) { + throw new Error( + `cannot close ${cleanId}: ${active.remaining.length} criteria lack evidence — pass force=true to override or call task_check first` + ); + } + const event: TaskCloseEvent = { + type: "close", + id: cleanId, + summary: cleanSummary, + closedAt: new Date().toISOString(), + force: force || undefined, + }; + this.append(event); + return event; + } + + activeTasks(): ActiveTask[] { + const events = this.readEvents(); + const opens = new Map(); + const closed = new Set(); + const evidenceById = new Map>(); + + for (const e of events) { + if (e.type === "open") opens.set(e.id, e); + else if (e.type === "close") closed.add(e.id); + else if (e.type === "check") { + const ev = evidenceById.get(e.id) || {}; + ev[e.criterion] = e.evidence; + evidenceById.set(e.id, ev); + } + } + + const active: ActiveTask[] = []; + for (const [id, open] of opens) { + if (closed.has(id)) continue; + const ev = evidenceById.get(id) || {}; + const satisfied = open.criteria.filter((c) => ev[c]); + const remaining = open.criteria.filter((c) => !ev[c]); + active.push({ + id, + goal: open.goal, + criteria: open.criteria, + createdAt: open.createdAt, + source: open.source, + satisfied, + remaining, + evidence: ev, + }); + } + return active; + } +} diff --git a/server.ts b/server.ts index 8d68572..04f8f57 100644 --- a/server.ts +++ b/server.ts @@ -6,6 +6,7 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import fs from "fs"; import path from "path"; +import { execFileSync } from "child_process"; import { loadConfig, saveConfig } from "./lib/config.ts"; import { getLiveConfig, @@ -49,7 +50,9 @@ import { import { extractKeywords } from "./lib/keywords.ts"; import { MemoryDB } from "./lib/memory-db.ts"; import { QmdManager } from "./lib/qmd-manager.ts"; +import { PaperclipClient, resolvePaperclipConfig } from "./lib/paperclip-bridge.ts"; import type { SearchResult } from "./lib/types.ts"; +import { AutogenesisOrchestrator } from "./lib/autogenesis.ts"; // --------------------------------------------------------------------------- // Paths @@ -106,6 +109,14 @@ try { // Dream engine (always available — uses recall data from .dreams/) const dreamEngine = new DreamEngine(WORKSPACE); +// Autogenesis orchestrator — RSPL/SEPL self-evolution protocol +const autogenesis = new AutogenesisOrchestrator(MEMORY_DIR, PLUGIN_ROOT); +autogenesis.scanAndRegisterSkills(); + +// Paperclip bridge (null if not configured — tools gracefully report this) +const paperclipConfig = resolvePaperclipConfig(WORKSPACE); +const paperclipClient = paperclipConfig ? new PaperclipClient(paperclipConfig) : null; + // Initialize QMD if configured (non-blocking, with full error isolation) let qmdManager: QmdManager | null = null; if (config.memory.backend === "qmd") { @@ -501,6 +512,11 @@ const MCP_TOOL_DIRECTORY: Array<{ name: string; description: string }> = [ { name: "chat_inbox_read", description: "Read pending WebChat messages." }, { name: "webchat_reply", description: "Stream a reply to the open WebChat browser." }, { name: "watchdog_ping", description: "Cheap liveness probe for external watchdogs — returns version + installed channel plugin names. No LLM, no side effects." }, + { name: "paperclip_inbox", description: "Get your Paperclip inbox — assigned issues and pending tasks." }, + { name: "paperclip_issue", description: "Get, create, or update a Paperclip issue." }, + { name: "paperclip_comment", description: "List or add comments on a Paperclip issue." }, + { name: "paperclip_agents", description: "List agents or wake up a specific agent in Paperclip." }, + { name: "autogenesis", description: "Self-evolving protocol — version skills, reflect on behavioral patterns, apply or rollback improvements. (AGP/SEPL)" }, ]; /** @@ -955,6 +971,94 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ properties: {}, }, }, + { + name: "paperclip_inbox", + description: + "Get your Paperclip inbox — assigned issues and pending work. Requires Paperclip credentials in env vars or agent-config.json.", + inputSchema: { + type: "object" as const, + properties: {}, + }, + }, + { + name: "paperclip_issue", + description: + "Interact with Paperclip issues. Actions: 'get' (by id/identifier), 'create' (new issue), 'update' (change status/title), 'list' (search), 'checkout' (assign to agent).", + inputSchema: { + type: "object" as const, + properties: { + action: { + type: "string", + enum: ["get", "create", "update", "list", "checkout"], + description: "Action to perform on the issue", + }, + id: { type: "string", description: "Issue ID or identifier (for get/update/checkout)" }, + title: { type: "string", description: "Issue title (for create)" }, + description: { type: "string", description: "Issue description (for create/update)" }, + status: { type: "string", description: "Issue status (for update/list filter)" }, + agentId: { type: "string", description: "Agent ID (for checkout — defaults to self)" }, + limit: { type: "number", description: "Max results (for list, default 10)" }, + }, + required: ["action"], + }, + }, + { + name: "paperclip_comment", + description: + "List or add comments on a Paperclip issue. Actions: 'list' (get comments), 'add' (post a comment).", + inputSchema: { + type: "object" as const, + properties: { + action: { type: "string", enum: ["list", "add"], description: "Action" }, + issueId: { type: "string", description: "Issue ID" }, + body: { type: "string", description: "Comment body (for add)" }, + limit: { type: "number", description: "Max comments (for list, default 20)" }, + }, + required: ["action", "issueId"], + }, + }, + { + name: "paperclip_agents", + description: + "List agents in your Paperclip company or wake up (invoke heartbeat on) a specific agent. Actions: 'list', 'wakeup'.", + inputSchema: { + type: "object" as const, + properties: { + action: { type: "string", enum: ["list", "wakeup"], description: "Action" }, + agentId: { type: "string", description: "Agent ID (for wakeup)" }, + reason: { type: "string", description: "Wakeup reason (for wakeup)" }, + }, + required: ["action"], + }, + }, + { + name: "autogenesis", + description: + "Self-evolving agent protocol (AGP/SEPL). Manages skill versioning and a nightly Reflect/Select/Improve cycle. Actions: 'status' (registry overview), 'pending' (proposals awaiting review), 'apply' (apply a proposal by id), 'reject' (reject a proposal by id), 'rollback' (revert a skill to a previous version), 'history' (version history for a skill).", + inputSchema: { + type: "object" as const, + properties: { + action: { + type: "string", + enum: ["status", "pending", "apply", "reject", "rollback", "history"], + description: "Action to perform", + }, + id: { + type: "string", + description: "Proposal ID (for apply/reject)", + }, + skill: { + type: "string", + description: "Skill name (for rollback/history)", + }, + version: { + type: "string", + description: "Version string to rollback to (for rollback, e.g. '1.0.0')", + }, + }, + required: ["action"], + }, + }, ], })); @@ -1058,6 +1162,31 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const dryRun = action === "dry-run"; const result = dreamEngine.runFullSweep({ dryRun }); + // Run learning engine to update behavioral priors from memory signals + let learningOutput = ""; + if (!dryRun) { + try { + const learningScript = path.join(PLUGIN_ROOT, "lib", "learning-engine.py"); + const memoryDir = MEMORY_DIR; + learningOutput = execFileSync("python3", [learningScript, "--memory-dir", memoryDir], { + timeout: 30000, + encoding: "utf-8", + }); + } catch (lerr) { + learningOutput = `Learning engine error: ${lerr instanceof Error ? lerr.message : String(lerr)}`; + } + } + + // Run autogenesis reflect/select cycle — generates improvement proposals + let autogenesisOutput = ""; + if (!dryRun) { + try { + autogenesisOutput = autogenesis.runReflectCycle(MEMORY_DIR); + } catch (aerr) { + autogenesisOutput = `Autogenesis error: ${aerr instanceof Error ? aerr.message : String(aerr)}`; + } + } + return { content: [ { @@ -1093,6 +1222,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { : "", "", !dryRun ? "Dream diary written to DREAMS.md" : "", + learningOutput ? `\n### Learning Engine\n${learningOutput.trim()}` : "", + autogenesisOutput ? `\n### Autogenesis (SEPL Reflect)\n${autogenesisOutput.trim()}` : "", ] .filter(Boolean) .join("\n"), @@ -1638,6 +1769,252 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }; } + // --------------------------------------------------------------------------- + // Paperclip tools + // --------------------------------------------------------------------------- + + const PAPERCLIP_NOT_CONFIGURED = + "Paperclip not configured. Set PAPERCLIP_API_URL, PAPERCLIP_API_KEY, and PAPERCLIP_COMPANY_ID as env vars, or add a 'paperclip' block to agent-config.json."; + + if (name === "paperclip_inbox") { + if (!paperclipClient) { + return { content: [{ type: "text", text: PAPERCLIP_NOT_CONFIGURED }], isError: true }; + } + try { + const inbox = await paperclipClient.inbox(); + return { content: [{ type: "text", text: JSON.stringify(inbox, null, 2) }] }; + } catch (e: any) { + return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true }; + } + } + + if (name === "paperclip_issue") { + if (!paperclipClient) { + return { content: [{ type: "text", text: PAPERCLIP_NOT_CONFIGURED }], isError: true }; + } + const action = String(params.action || ""); + try { + if (action === "get") { + const id = String(params.id || ""); + if (!id) return { content: [{ type: "text", text: "Error: id is required" }], isError: true }; + const issue = await paperclipClient.getIssue(id); + return { content: [{ type: "text", text: JSON.stringify(issue, null, 2) }] }; + } + if (action === "list") { + const issues = await paperclipClient.listIssues({ + status: params.status ? String(params.status) : undefined, + limit: Number(params.limit) || 10, + }); + return { content: [{ type: "text", text: JSON.stringify(issues, null, 2) }] }; + } + if (action === "create") { + const title = String(params.title || "").trim(); + if (!title) return { content: [{ type: "text", text: "Error: title is required" }], isError: true }; + const issue = await paperclipClient.createIssue({ + title, + description: params.description ? String(params.description) : undefined, + }); + return { content: [{ type: "text", text: `Issue created: ${JSON.stringify(issue, null, 2)}` }] }; + } + if (action === "update") { + const id = String(params.id || ""); + if (!id) return { content: [{ type: "text", text: "Error: id is required" }], isError: true }; + const update: any = {}; + if (params.title) update.title = String(params.title); + if (params.status) update.status = String(params.status); + if (params.description) update.description = String(params.description); + const issue = await paperclipClient.updateIssue(id, update); + return { content: [{ type: "text", text: `Issue updated: ${JSON.stringify(issue, null, 2)}` }] }; + } + if (action === "checkout") { + const id = String(params.id || ""); + if (!id) return { content: [{ type: "text", text: "Error: id is required" }], isError: true }; + const result = await paperclipClient.checkoutIssue(id, params.agentId ? String(params.agentId) : undefined); + return { content: [{ type: "text", text: `Issue checked out: ${JSON.stringify(result, null, 2)}` }] }; + } + return { content: [{ type: "text", text: 'Unknown action. Use: "get", "create", "update", "list", "checkout"' }], isError: true }; + } catch (e: any) { + return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true }; + } + } + + if (name === "paperclip_comment") { + if (!paperclipClient) { + return { content: [{ type: "text", text: PAPERCLIP_NOT_CONFIGURED }], isError: true }; + } + const action = String(params.action || ""); + const issueId = String(params.issueId || ""); + if (!issueId) return { content: [{ type: "text", text: "Error: issueId is required" }], isError: true }; + try { + if (action === "list") { + const comments = await paperclipClient.listComments(issueId, Number(params.limit) || 20); + return { content: [{ type: "text", text: JSON.stringify(comments, null, 2) }] }; + } + if (action === "add") { + const body = String(params.body || "").trim(); + if (!body) return { content: [{ type: "text", text: "Error: body is required" }], isError: true }; + const comment = await paperclipClient.addComment(issueId, body); + return { content: [{ type: "text", text: `Comment added: ${JSON.stringify(comment, null, 2)}` }] }; + } + return { content: [{ type: "text", text: 'Unknown action. Use: "list" or "add"' }], isError: true }; + } catch (e: any) { + return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true }; + } + } + + if (name === "paperclip_agents") { + if (!paperclipClient) { + return { content: [{ type: "text", text: PAPERCLIP_NOT_CONFIGURED }], isError: true }; + } + const action = String(params.action || ""); + try { + if (action === "list") { + const agents = await paperclipClient.listAgents(); + return { content: [{ type: "text", text: JSON.stringify(agents, null, 2) }] }; + } + if (action === "wakeup") { + const agentId = String(params.agentId || ""); + if (!agentId) return { content: [{ type: "text", text: "Error: agentId is required" }], isError: true }; + const result = await paperclipClient.wakeupAgent(agentId, params.reason ? String(params.reason) : undefined); + return { content: [{ type: "text", text: `Agent wakeup requested: ${JSON.stringify(result, null, 2)}` }] }; + } + return { content: [{ type: "text", text: 'Unknown action. Use: "list" or "wakeup"' }], isError: true }; + } catch (e: any) { + return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true }; + } + } + + if (name === "autogenesis") { + const action = String(params.action || "status"); + + try { + if (action === "status") { + const status = autogenesis.getStatus(); + const lines = [ + "## Autogenesis Status (AGP/SEPL)", + "", + `Registered skills: ${status.registeredResources}`, + `Pending proposals: ${status.pendingProposals}`, + `Applied proposals: ${status.appliedProposals}`, + "", + "### Registered Resources", + ...status.resources.map( + (r) => + `- **${r.name}** (${r.type}) — v${r.currentVersion} | trainable: ${r.trainable} | updated: ${r.updatedAt.slice(0, 10)}` + ), + ]; + if (status.recentReports.length > 0) { + lines.push("", "### Recent Reports", ...status.recentReports.map((r) => `- ${r}`)); + } + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + + if (action === "pending") { + const proposals = autogenesis.getPendingProposals(); + if (proposals.length === 0) { + return { content: [{ type: "text", text: "No pending proposals. Run `dream(action='run')` to generate new ones." }] }; + } + const lines = [ + `## Pending Autogenesis Proposals (${proposals.length})`, + "", + "Use `autogenesis(action='apply', id='...')` to apply or `autogenesis(action='reject', id='...')` to dismiss.", + "", + ...proposals.map((p) => + [ + `### ${p.id}`, + `Skill: **${p.skill_name}** | Event: \`${p.hypothesis.event_type}\` | Response rate: ${(p.hypothesis.response_rate * 100).toFixed(0)}% | Severity: ${p.hypothesis.severity}`, + `Hypothesis: ${p.hypothesis.hypothesis}`, + `Proposed addition (${p.proposed_addition.length} chars):`, + "```", + p.proposed_addition.trim().slice(0, 600) + (p.proposed_addition.length > 600 ? "\n…" : ""), + "```", + `Created: ${p.created_at}`, + ].join("\n") + ), + ]; + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + + if (action === "apply") { + const id = String(params.id || ""); + if (!id) { + return { content: [{ type: "text", text: "Error: id is required for apply" }], isError: true }; + } + const result = autogenesis.applyProposal(id); + if (!result.success) { + return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true }; + } + return { + content: [ + { + type: "text", + text: [ + `✅ Proposal applied successfully.`, + `Skill: **${result.skillName}** → new version **${result.version}**`, + ``, + `To rollback: \`autogenesis(action='rollback', skill='${result.skillName}', version='')\``, + ].join("\n"), + }, + ], + }; + } + + if (action === "reject") { + const id = String(params.id || ""); + if (!id) { + return { content: [{ type: "text", text: "Error: id is required for reject" }], isError: true }; + } + const ok = autogenesis.rejectProposal(id); + return { + content: [{ type: "text", text: ok ? `Proposal '${id}' rejected.` : `Proposal '${id}' not found or already processed.` }], + }; + } + + if (action === "rollback") { + const skill = String(params.skill || ""); + const version = String(params.version || ""); + if (!skill || !version) { + return { content: [{ type: "text", text: "Error: skill and version are required for rollback" }], isError: true }; + } + const ok = autogenesis.rollback(skill, version); + return { + content: [{ type: "text", text: ok ? `✅ Rolled back '${skill}' to version ${version}.` : `Error: could not rollback '${skill}' to ${version} (version not found in registry)` }], + isError: !ok, + }; + } + + if (action === "history") { + const skill = String(params.skill || ""); + if (!skill) { + return { content: [{ type: "text", text: "Error: skill is required for history" }], isError: true }; + } + const history = autogenesis.getHistory(skill); + if (history.length === 0) { + return { content: [{ type: "text", text: `No version history for skill '${skill}'.` }] }; + } + const lines = [ + `## Version History: ${skill}`, + "", + ...history.map( + (v) => + `- **${v.version}** | ${v.ts.slice(0, 16)} | ${v.reason} | hash: \`${v.contentHash}\`${v.parentVersion ? ` | parent: ${v.parentVersion}` : ""}` + ), + ]; + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + + return { + content: [{ type: "text", text: `Unknown autogenesis action: ${action}` }], + isError: true, + }; + } catch (err) { + return { + content: [{ type: "text", text: `Autogenesis error: ${err instanceof Error ? err.message : String(err)}` }], + isError: true, + }; + } + } + return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true, diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md new file mode 100644 index 0000000..0405348 --- /dev/null +++ b/skills/paperclip/SKILL.md @@ -0,0 +1,49 @@ +--- +name: paperclip +description: Interact with the Paperclip control plane — check inbox, manage issues, post comments, wake up agents. Use when the user asks about tasks, issues, or wants to coordinate with other Paperclip agents. Triggers "/paperclip", "check my tasks", "paperclip inbox", "create issue", "wake up agent", "assign task". +--- + +# Paperclip Integration + +You have 4 MCP tools to interact with Paperclip: + +## Check your work + +``` +paperclip_inbox() → your assigned issues +paperclip_issue(action="list") → all issues (filterable by status) +paperclip_issue(action="get", id=X) → specific issue details +``` + +## Do work on issues + +``` +paperclip_issue(action="checkout", id=X) → assign issue to yourself +paperclip_comment(action="add", issueId=X, body=Y) → post update +paperclip_issue(action="update", id=X, status=Y) → change status +``` + +## Create work + +``` +paperclip_issue(action="create", title=X, description=Y) → new issue +``` + +## Coordinate with other agents + +``` +paperclip_agents(action="list") → see who's available +paperclip_agents(action="wakeup", agentId=X, reason=Y) → trigger another agent +``` + +## When invoked via Paperclip heartbeat + +If you were started by a Paperclip heartbeat, the issue context is automatically synced with the Task Completion Guard. Work until the acceptance criteria are met — the guard will remind you if you try to stop early. When you finish, the summary is posted back as a comment on the issue. + +## Configuration + +Credentials come from either: +- **Env vars** (auto-injected by Paperclip): `PAPERCLIP_API_URL`, `PAPERCLIP_API_KEY`, `PAPERCLIP_COMPANY_ID` +- **agent-config.json**: `{ "paperclip": { "apiUrl": "...", "apiKey": "...", "companyId": "..." } }` + +If neither is set, the tools will tell you.