From 8508cb9fdf6c631917a2fa70966e3ac4429b7029 Mon Sep 17 00:00:00 2001 From: mayankbohradev Date: Thu, 14 May 2026 07:37:11 +0530 Subject: [PATCH] =?UTF-8?q?feat(core):=20release=20v0.2=20self-evolving=20?= =?UTF-8?q?layer=20=E2=80=94=20skills,=20memory=20graph,=20reflections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Hermes-style procedural memory + LLM Wiki-style typed link graph on top of the existing flat memory store. Zero breaking changes — every existing tool keeps its signature, every existing memory keeps its data. Schema (additive migration 0002_self_evolving.sql): - memories gains 6 columns: confidence, verified_at, superseded_by, tier, access_count, last_accessed_at (all with safe defaults) - New tables: skills + skills_fts, memory_links, reflections, injection_log Worker (src/index.ts): - 10 new MCP tools: save_skill, get_skill, list_skills, record_skill_outcome, link_memories, get_memory_graph, list_reflections, resolve_reflection, get_hub_health, verify_memory - Injection scanner with 8 rules on every write surface - Auto-detection of contradictions (similarity 0.3-0.6 + opposing signal words) - Auto-detection of skill candidates (procedural markers in learning/decision) - Nudge layer: every save/search appends top-3 pending reflections CLI (create-context-hub@0.4.2): - update command always probes remote D1 for 0.2 schema before deciding "already up to date" — fixes the source-repo edge case - Auto-applies 0002 migration if D1 still on 0.1 schema (with confirmation) - Plain-English intro explaining what update does + what is preserved - Smarter redeploy gate — offers redeploy when DB migrates even if files match - Post-migration re-probe confirms the schema actually landed --- README.md | 124 ++- migrations/0002_self_evolving.sql | 129 +++ package.json | 8 +- packages/create-context-hub/package.json | 2 +- packages/create-context-hub/src/cli.ts | 301 +++++- .../default/migrations/0002_self_evolving.sql | 129 +++ .../templates/default/package.json.tmpl | 7 +- .../templates/default/src/index.ts | 865 +++++++++++++++++- src/index.ts | 865 +++++++++++++++++- 9 files changed, 2366 insertions(+), 64 deletions(-) create mode 100644 migrations/0002_self_evolving.sql create mode 100644 packages/create-context-hub/templates/default/migrations/0002_self_evolving.sql diff --git a/README.md b/README.md index 6880ac9..5ae5db2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ Stop repeating yourself across AI tools. Context Hub is a shared MCP server that **Built on Cloudflare Workers (free tier). Costs $0/month. Always on. No cold starts.** -> **New in 0.2.0:** Context Hub now works with **any MCP client**, not just Claude. When you save a memory or log context from ChatGPT, Perplexity, Cursor, Windsurf, Zed, or a custom agent system, the `source` field is auto-detected from the MCP client's self-reported name — so you can always see where each memory came from. +> **New in 0.2.0 — the self-evolving layer.** Context Hub now learns _how_ you work, not just _what_ you know. Hermes-style **procedural memory** (skills), LLM Wiki-style **typed memory graph** (supersedes / contradicts / refines), **confidence + decay** on every memory, **hot/warm/cold tiers** that auto-promote frequently-used facts, an **injection scanner** on all writes, and a **nudge layer** that appends `💡 hub_suggestion` to tool responses so your agent passively keeps the hub clean. **Zero breaking changes** — every existing tool still works exactly as before. +> +> **0.1.0:** Context Hub works with **any MCP client**, not just Claude. When you save a memory or log context from ChatGPT, Perplexity, Cursor, Windsurf, Zed, or a custom agent system, the `source` field is auto-detected from the MCP client's self-reported name — so you can always see where each memory came from. --- @@ -328,7 +330,48 @@ This calls `list_all_data` and returns the complete inventory across all tables. --- -## MCP Tools Reference (24 tools) +## What's new in 0.2 — the self-evolving layer + +Context Hub 0.1 was a memory store. 0.2 is a **memory store that improves itself the more you use it**. The model is borrowed from Nous Research's Hermes Agent (procedural memory) and Karpathy's LLM Wiki pattern (typed link graph), adapted to run inside the MCP server so any client benefits without extra setup. + +### What it adds + +| Capability | What it means | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| **Procedural memory** | New `skills` table — save reusable markdown procedures with trigger patterns. Next similar task auto-loads the skill. | +| **Typed memory graph** | New `memory_links` table — `supersedes` / `contradicts` / `supports` / `related` / `example_of` / `refines` edges between rows. | +| **Confidence + decay** | Every memory has a `confidence` score (0–1) and `verified_at` timestamp. Stale or low-confidence memories surface in health. | +| **Hot/warm/cold tiers** | `access_count` is bumped on retrieval. Memories hit 5+ times auto-promote to `hot`. Superseded memories drop to `cold`. | +| **Contradiction sensor** | At write time, if a new memory is 30–60% similar to an old one and uses opposing signal words, a `reflection` is recorded. | +| **Skill-candidate sensor** | `learning` or `decision` memories containing procedural markers (`first`, `then`, `step N`) auto-flag for skill promotion. | +| **Reflections queue** | Internal to-do list of hygiene actions. Status: `pending` → `applied`/`dismissed`. | +| **Injection scanner** | 8 prompt-injection rules run on every write surface. High severity blocks the write; medium/low flag it. All hits logged. | +| **Nudge layer** | Every save/search response now ends with a `💡 hub_suggestion` block summarising the top 3 pending reflections. | + +### How it nudges you (without changing any old tool's contract) + +The "force" mechanic is intentionally indirect. The LLM client reads its own tool responses — so when the response includes: + +``` +💡 hub_suggestion: +• [contradiction] New memory may contradict memory #18. Old: "use npm" — New: "always use pnpm" + → Call resolve_reflection with id=7 after acting. +``` + +…Claude/ChatGPT/Cursor surface it to you in natural language ("Heads up — this seems to contradict an older memory. Supersede the old one?"). You don't see the raw block; you see the agent acting on it. Over weeks of normal usage, contradictions get resolved, stale facts get verified, recurring workflows get promoted to skills. + +### How to upgrade your existing hub + +```bash +cd your-scaffolded-hub +npx create-context-hub@latest update +``` + +The CLI auto-applies the additive 0002 migration to your remote D1 (with confirmation). See the [Upgrading section below](#upgrading-a-project-scaffolded-via-npx-create-context-hub) for details. **Your existing memories are preserved.** + +--- + +## MCP Tools Reference (34 tools) ### Memories (5 tools) @@ -399,6 +442,31 @@ This calls `list_all_data` and returns the complete inventory across all tables. | --------------- | -------------------------------------------------- | ---------------------------------------------------------------- | | `get_hub_stats` | Dashboard metrics: counts, activity, storage, tags | Instructs Claude to render as a colorful inline visual dashboard | +### Skills — procedural memory (4 tools, new in 0.2) + +| Tool | What It Does | Smart Behavior | +| ---------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | +| `save_skill` | Save a markdown procedure with trigger pattern. Versioned with parent lineage. | Upserts by name. Each rewrite bumps `version` and links `parent_skill_id` for traceable history. | +| `get_skill` | Load a skill by name or by FTS5 trigger match | Before a recurring task, search skills first — follow the saved procedure if one matches. | +| `list_skills` | List skills ranked by success rate | Audit your skill library; spot ones with high failure-to-success ratios. | +| `record_skill_outcome` | Track whether a skill worked | After running a skill. Auto-records a `skill_failed` reflection if failure rate exceeds 50%. | + +### Graph + reflections (4 tools, new in 0.2) + +| Tool | What It Does | Smart Behavior | +| -------------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `link_memories` | Create a typed edge between two memories | Relations: `supersedes`, `contradicts`, `supports`, `related`, `example_of`, `refines`. `supersedes` auto-drops the old memory to `cold` tier. | +| `get_memory_graph` | Show a memory + all its incoming + outgoing links | Use to understand the cluster of facts surrounding any single memory. | +| `list_reflections` | Inspect the self-improvement queue | Filter by status (`pending`/`applied`/`dismissed`). Pending items are the hub's housekeeping to-do list. | +| `resolve_reflection` | Mark a reflection as applied or dismissed | Call after you (or the user) acted on a suggestion the hub raised. | + +### Health + verification (2 tools, new in 0.2) + +| Tool | What It Does | Smart Behavior | +| ---------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | +| `get_hub_health` | Prioritized action list across stale facts, contradictions, failing skills, injection hits | Call alongside `get_full_context` at session start so the agent knows what hygiene to suggest. | +| `verify_memory` | Re-confirm a memory is still true; bumps confidence, resets verified_at | Call when the user re-asserts an old fact or you've verified it against current code/reality. | + --- ## Auto-Load Context at Session Start @@ -488,9 +556,24 @@ The `update` command: - Previews which files will change and by how many lines - Writes `.bak` backups before overwriting (so rollback is one `mv` away) - Preserves your `wrangler.json` (with your database ID), `package.json`, `tsconfig.json`, and any custom files +- **Auto-applies the 0.2 self-evolving migration to your remote D1** — probes your DB for the `confidence` column first; if absent, runs `0002_self_evolving.sql` after asking for confirmation - Optionally runs `npx wrangler deploy` for you -Existing memories keep their original `source` values — there's no data migration. New memories written after the upgrade get the calling MCP client's self-reported name as their source (e.g. `claude-code`, `chatgpt`, `perplexity`, `cursor`). +**Your existing memories are preserved.** The 0.2 migration is purely additive: + +- `ALTER TABLE memories ADD COLUMN` for `confidence` (default 0.8), `verified_at` (NULL), `superseded_by` (NULL), `tier` (default 'warm'), `access_count` (default 0), `last_accessed_at` (NULL) +- `CREATE TABLE IF NOT EXISTS` for `skills`, `memory_links`, `reflections`, `injection_log` +- Zero data is dropped, modified, or migrated. Every existing row keeps its original content, category, tags, and source. + +If you'd rather run the migration manually (or the CLI couldn't reach your D1): + +```bash +npm run db:upgrade +# or: +npx wrangler d1 execute YOUR_DB_NAME --remote --file=./migrations/0002_self_evolving.sql +``` + +The migration is idempotent for the new tables (`CREATE TABLE IF NOT EXISTS`). The `ALTER TABLE ADD COLUMN` statements will fail with "duplicate column" if you re-run after a successful migration — that's safe and means your schema is already on 0.2. ### Forgot where your scaffolded hub lives? @@ -526,7 +609,11 @@ If that turns up nothing, the Worker name in your [Cloudflare Workers dashboard] -- memories: things the AI learns about you -- `source` is auto-detected from the MCP client's clientInfo.name -- (claude-code, claude-ai, claude-app, chatgpt, perplexity, cursor, windsurf, zed, custom agents, etc.) -memories (id, content, category, tags, source, created_at, updated_at) +-- 0.2 added: confidence, verified_at, superseded_by, tier, access_count, last_accessed_at +memories ( + id, content, category, tags, source, created_at, updated_at, + confidence, verified_at, superseded_by, tier, access_count, last_accessed_at +) -- FTS5 full-text search index on memories -- projects: workspace-level context @@ -542,6 +629,35 @@ identity (id, key UNIQUE, value, created_at, updated_at) context_log (id, source, summary, project_name, created_at) ``` +### Schema of 0.2 self-evolving tables (new) + +```sql +-- memory_links: typed graph between memories +-- relation: supersedes | contradicts | supports | related | example_of | refines +memory_links (id, from_id, to_id, relation, confidence, note, created_at) + +-- skills: reusable markdown procedures (procedural memory) +skills ( + id, name UNIQUE, trigger_pattern, procedure, + success_count, failure_count, last_used_at, + version, parent_skill_id, source, tags, active, created_at, updated_at +) +-- FTS5 full-text search index on skills + +-- reflections: self-improvement queue +-- trigger: contradiction | stale_fact | skill_candidate | skill_failed | duplicate_cluster | low_confidence +reflections ( + id, trigger, observation, proposed_change, related_ids, + status, created_at, resolved_at +) + +-- injection_log: security audit trail +injection_log ( + id, source, surface, content_preview, + patterns_matched, severity, action_taken, created_at +) +``` + --- ## Optional: Protect with an API Key diff --git a/migrations/0002_self_evolving.sql b/migrations/0002_self_evolving.sql new file mode 100644 index 0000000..2dea078 --- /dev/null +++ b/migrations/0002_self_evolving.sql @@ -0,0 +1,129 @@ +-- Context Hub 0.2 — Self-Evolving Layer +-- Adds Hermes-style procedural memory + LLM Wiki-style linking + decay/provenance +-- on top of the existing flat memory store. +-- +-- ZERO BREAKING CHANGES: every addition is either a new table or a new column +-- with a safe default. Existing tools continue to work unchanged. + +-- ───────────────────────────────────────────────────────────── +-- 1. PROVENANCE + DECAY on memories +-- ───────────────────────────────────────────────────────────── +-- Existing memories rows get sensible defaults so nothing breaks. +ALTER TABLE memories ADD COLUMN confidence REAL DEFAULT 0.8; -- 0.0–1.0 +ALTER TABLE memories ADD COLUMN verified_at TEXT DEFAULT NULL; -- last time user/agent confirmed it +ALTER TABLE memories ADD COLUMN superseded_by INTEGER DEFAULT NULL;-- points at the newer memory that replaced this +ALTER TABLE memories ADD COLUMN tier TEXT DEFAULT 'warm'; -- hot | warm | cold (prompt budget tier) +ALTER TABLE memories ADD COLUMN access_count INTEGER DEFAULT 0; -- bumped on retrieval, drives hot/cold tiering +ALTER TABLE memories ADD COLUMN last_accessed_at TEXT DEFAULT NULL; + +CREATE INDEX IF NOT EXISTS idx_memories_tier ON memories(tier); +CREATE INDEX IF NOT EXISTS idx_memories_superseded ON memories(superseded_by); +CREATE INDEX IF NOT EXISTS idx_memories_confidence ON memories(confidence); + +-- ───────────────────────────────────────────────────────────── +-- 2. MEMORY LINKS — wiki-style graph +-- ───────────────────────────────────────────────────────────── +-- Contradictions, supersession, support, relatedness — between any two memories. +CREATE TABLE IF NOT EXISTS memory_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_id INTEGER NOT NULL, + to_id INTEGER NOT NULL, + relation TEXT NOT NULL, -- contradicts | supersedes | supports | related | example_of | refines + confidence REAL DEFAULT 0.8, + note TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')), + UNIQUE(from_id, to_id, relation), + FOREIGN KEY (from_id) REFERENCES memories(id) ON DELETE CASCADE, + FOREIGN KEY (to_id) REFERENCES memories(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_links_from ON memory_links(from_id); +CREATE INDEX IF NOT EXISTS idx_links_to ON memory_links(to_id); +CREATE INDEX IF NOT EXISTS idx_links_relation ON memory_links(relation); + +-- ───────────────────────────────────────────────────────────── +-- 3. SKILLS — procedural memory (the Hermes leap) +-- ───────────────────────────────────────────────────────────── +-- A "skill" is a markdown procedure the agent extracted from a successful task. +-- When a similar trigger appears, load the procedure and refine it after use. +CREATE TABLE IF NOT EXISTS skills ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + trigger_pattern TEXT NOT NULL, -- when to load this skill (keywords / description) + procedure TEXT NOT NULL, -- the markdown how-to + success_count INTEGER DEFAULT 0, + failure_count INTEGER DEFAULT 0, + last_used_at TEXT DEFAULT NULL, + version INTEGER DEFAULT 1, + parent_skill_id INTEGER DEFAULT NULL, -- lineage: skill v1 → v2 → v3 + source TEXT DEFAULT 'unknown', + tags TEXT DEFAULT '', + active INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (parent_skill_id) REFERENCES skills(id) ON DELETE SET NULL +); + +CREATE VIRTUAL TABLE IF NOT EXISTS skills_fts USING fts5( + name, trigger_pattern, procedure, tags, + content='skills', content_rowid='id' +); + +CREATE TRIGGER IF NOT EXISTS skills_ai AFTER INSERT ON skills BEGIN + INSERT INTO skills_fts(rowid, name, trigger_pattern, procedure, tags) + VALUES (new.id, new.name, new.trigger_pattern, new.procedure, new.tags); +END; + +CREATE TRIGGER IF NOT EXISTS skills_ad AFTER DELETE ON skills BEGIN + INSERT INTO skills_fts(skills_fts, rowid, name, trigger_pattern, procedure, tags) + VALUES ('delete', old.id, old.name, old.trigger_pattern, old.procedure, old.tags); +END; + +CREATE TRIGGER IF NOT EXISTS skills_au AFTER UPDATE ON skills BEGIN + INSERT INTO skills_fts(skills_fts, rowid, name, trigger_pattern, procedure, tags) + VALUES ('delete', old.id, old.name, old.trigger_pattern, old.procedure, old.tags); + INSERT INTO skills_fts(rowid, name, trigger_pattern, procedure, tags) + VALUES (new.id, new.name, new.trigger_pattern, new.procedure, new.tags); +END; + +CREATE INDEX IF NOT EXISTS idx_skills_active ON skills(active); +CREATE INDEX IF NOT EXISTS idx_skills_last_used ON skills(last_used_at); + +-- ───────────────────────────────────────────────────────────── +-- 4. REFLECTIONS — substrate for self-improvement +-- ───────────────────────────────────────────────────────────── +-- Agent writes a reflection when it spots a contradiction, a stale fact, +-- a repeated workflow, or a skill failure. Tools read pending reflections +-- and surface them to the user as suggestions. +CREATE TABLE IF NOT EXISTS reflections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trigger TEXT NOT NULL, -- contradiction | stale_fact | skill_candidate | skill_failed | duplicate_cluster | low_confidence + observation TEXT NOT NULL, -- what was noticed + proposed_change TEXT DEFAULT '', -- JSON: { action, target_id, payload } + related_ids TEXT DEFAULT '', -- comma-separated memory/skill IDs involved + status TEXT DEFAULT 'pending', -- pending | applied | dismissed + created_at TEXT DEFAULT (datetime('now')), + resolved_at TEXT DEFAULT NULL +); + +CREATE INDEX IF NOT EXISTS idx_reflections_status ON reflections(status); +CREATE INDEX IF NOT EXISTS idx_reflections_trigger ON reflections(trigger); + +-- ───────────────────────────────────────────────────────────── +-- 5. INJECTION SCAN LOG — security audit trail +-- ───────────────────────────────────────────────────────────── +-- Every flagged write gets logged here. Doesn't block the write by default +-- (tools decide), but gives the user a security feed. +CREATE TABLE IF NOT EXISTS injection_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + surface TEXT NOT NULL, -- save_memory | save_instruction | save_project | etc. + content_preview TEXT NOT NULL, -- first 200 chars + patterns_matched TEXT NOT NULL, -- comma-separated rule IDs + severity TEXT NOT NULL, -- low | medium | high + action_taken TEXT NOT NULL, -- blocked | flagged | sanitized + created_at TEXT DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_injection_severity ON injection_log(severity); +CREATE INDEX IF NOT EXISTS idx_injection_created ON injection_log(created_at); diff --git a/package.json b/package.json index ef13f49..c0b0e7e 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,15 @@ { "name": "claude-context-hub", - "version": "0.1.0", + "version": "0.2.0", "description": "A shared MCP server that bridges Claude.ai and Claude Code — your personal AI context hub deployed on Cloudflare Workers for free.", "main": "src/index.ts", "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy", - "db:migrate": "wrangler d1 execute context-hub-db --local --file=./migrations/0001_init.sql", - "db:migrate:remote": "wrangler d1 execute context-hub-db --remote --file=./migrations/0001_init.sql", + "db:migrate": "wrangler d1 execute context-hub-db --local --file=./migrations/0001_init.sql && wrangler d1 execute context-hub-db --local --file=./migrations/0002_self_evolving.sql", + "db:migrate:remote": "wrangler d1 execute context-hub-db --remote --file=./migrations/0001_init.sql && wrangler d1 execute context-hub-db --remote --file=./migrations/0002_self_evolving.sql", + "db:migrate:0002": "wrangler d1 execute context-hub-db --local --file=./migrations/0002_self_evolving.sql", + "db:migrate:0002:remote": "wrangler d1 execute context-hub-db --remote --file=./migrations/0002_self_evolving.sql", "typecheck": "tsc --noEmit", "lint": "eslint src/" }, diff --git a/packages/create-context-hub/package.json b/packages/create-context-hub/package.json index cd814ce..a268cd9 100644 --- a/packages/create-context-hub/package.json +++ b/packages/create-context-hub/package.json @@ -1,6 +1,6 @@ { "name": "create-context-hub", - "version": "0.3.3", + "version": "0.4.2", "description": "Create a Context Hub — a personal AI context layer shared across any MCP client (Claude.ai, Claude Code, Claude App, ChatGPT, Perplexity, Cursor, and more). One command to scaffold, deploy, and connect.", "type": "module", "bin": "./dist/cli.js", diff --git a/packages/create-context-hub/src/cli.ts b/packages/create-context-hub/src/cli.ts index 223d99f..2e3fc55 100644 --- a/packages/create-context-hub/src/cli.ts +++ b/packages/create-context-hub/src/cli.ts @@ -663,6 +663,51 @@ async function locateProjects(): Promise { p.outro(pc.green("Done.")); } +// Parse the D1 database name from a project's wrangler.json so we can run +// schema probes / migrations via wrangler CLI without asking the user. +async function parseDbNameFromWrangler( + wranglerPath: string, +): Promise { + try { + const raw = await readFile(wranglerPath, "utf-8"); + const json = JSON.parse(raw) as { + d1_databases?: Array<{ database_name?: string }>; + }; + return json.d1_databases?.[0]?.database_name ?? null; + } catch { + return null; + } +} + +// Probe the remote D1 database to see whether the 0.2 self-evolving schema +// (confidence column on memories) is already applied. Returns: +// "applied" → schema is on 0.2, no migration needed +// "pending" → 0.1 schema, needs 0002 migration +// "unknown" → probe failed (no auth, DB missing, etc.); caller asks user +async function probe02Schema( + dbName: string, + cwd: string, +): Promise<"applied" | "pending" | "unknown"> { + const probe = runCommand( + "npx", + [ + "wrangler", + "d1", + "execute", + dbName, + "--remote", + "--command", + "SELECT name FROM pragma_table_info('memories') WHERE name='confidence' LIMIT 1;", + "--json", + ], + cwd, + ); + if (!probe.success) return "unknown"; + // Wrangler --json output contains the rows; "confidence" presence means applied. + if (/"confidence"/.test(probe.output)) return "applied"; + return "pending"; +} + async function updateProject(): Promise { p.intro(pc.bgCyan(pc.black(" create-context-hub update "))); @@ -701,8 +746,18 @@ async function updateProject(): Promise { } p.log.info( - `Updating to the ${pc.cyan(`create-context-hub@${cliVersion}`)} template.\n` + - `${pc.dim("Your wrangler.json, package.json, and custom files are preserved.")}`, + `Updating your Context Hub to ${pc.cyan(`create-context-hub@${cliVersion}`)}.\n\n` + + `${pc.bold("What this does, in plain English:")}\n` + + ` ${pc.green("✓")} Checks your local files against the new template\n` + + ` ${pc.green("✓")} Checks your live Cloudflare database for the latest schema\n` + + ` ${pc.green("✓")} Asks you before each step — nothing happens without confirmation\n` + + ` ${pc.green("✓")} Backs up files as ${pc.cyan(".bak")} before any change\n` + + ` ${pc.green("✓")} Redeploys to Cloudflare so your AI clients see the new tools\n\n` + + `${pc.bold("What is preserved:")}\n` + + ` ${pc.green("✓")} Every memory you've saved\n` + + ` ${pc.green("✓")} Every project, instruction, identity field\n` + + ` ${pc.green("✓")} Your wrangler.json, package.json, API keys, custom code\n\n` + + `${pc.dim("If something feels off, you can cancel at any prompt by pressing Ctrl+C.")}`, ); // Compare template source to user's source to see what's actually changing. @@ -712,6 +767,13 @@ async function updateProject(): Promise { "migrations", "0001_init.sql", ); + // 0.2 — self-evolving layer (additive migration). Present in templates ≥ 0.4. + const template02MigrationPath = join( + TEMPLATES_DIR, + "migrations", + "0002_self_evolving.sql", + ); + const user02MigrationPath = join(cwd, "migrations", "0002_self_evolving.sql"); const [userIndex, templateIndex, userMigration, templateMigration] = await Promise.all([ @@ -721,11 +783,62 @@ async function updateProject(): Promise { readFile(templateMigrationPath, "utf-8"), ]); + const template02Exists = existsSync(template02MigrationPath); + const user02Exists = existsSync(user02MigrationPath); + let template02Migration = ""; + let user02Migration = ""; + if (template02Exists) { + template02Migration = await readFile(template02MigrationPath, "utf-8"); + } + if (user02Exists) { + user02Migration = await readFile(user02MigrationPath, "utf-8"); + } + const indexChanged = userIndex !== templateIndex; const migrationChanged = userMigration !== templateMigration; + // 0.2 migration is brand new for upgrading users (file doesn't exist yet). + const migration02New = template02Exists && !user02Exists; + // Or content drift if both exist. + const migration02Changed = + template02Exists && user02Exists && template02Migration !== user02Migration; + + const filesNeedUpdate = + indexChanged || migrationChanged || migration02New || migration02Changed; + + // ── Probe remote D1 BEFORE deciding "already up to date" ───────────── + // This is the key fix: even when local files match the template, the user's + // live D1 may still be on the 0.1 schema (common when running update from + // the source repo, or when a previous update skipped the DB step). + const dbName = await parseDbNameFromWrangler(wranglerPath); + let dbStatus: "applied" | "pending" | "unknown" = "unknown"; + if (dbName) { + const probeSpinner = p.spinner(); + probeSpinner.start(`Probing ${dbName} for 0.2 schema...`); + dbStatus = await probe02Schema(dbName, cwd); + probeSpinner.stop(`Remote schema: ${dbStatus}.`); + } else { + p.log.warn( + "Could not parse database name from wrangler.json — DB probe skipped.", + ); + } - if (!indexChanged && !migrationChanged) { - p.outro(pc.green("Already up to date — nothing to change.")); + const dbNeedsMigration = dbStatus === "pending"; + + if (!filesNeedUpdate && !dbNeedsMigration) { + if (dbStatus === "applied") { + p.outro( + pc.green( + "Already up to date — files match template and remote D1 is on 0.2 schema.", + ), + ); + } else { + p.outro( + pc.green("Files already up to date.") + + pc.dim( + " (Could not verify remote D1 — run `npm run db:upgrade` manually if needed.)", + ), + ); + } return; } @@ -734,67 +847,178 @@ async function updateProject(): Promise { if (indexChanged) { const diff = lineDiffSummary(userIndex, templateIndex); changes.push( - ` ${pc.yellow("~")} src/index.ts ${pc.dim(`(${diff})`)}`, + ` ${pc.yellow("~")} src/index.ts ${pc.dim(`(${diff})`)}`, ); } if (migrationChanged) { const diff = lineDiffSummary(userMigration, templateMigration); changes.push( - ` ${pc.yellow("~")} migrations/0001_init.sql ${pc.dim(`(${diff})`)}`, + ` ${pc.yellow("~")} migrations/0001_init.sql ${pc.dim(`(${diff})`)}`, + ); + } + if (migration02New) { + const lines = template02Migration.split("\n").length; + changes.push( + ` ${pc.green("+")} migrations/0002_self_evolving.sql ${pc.dim(`(new, ${lines} lines)`)}`, + ); + } else if (migration02Changed) { + const diff = lineDiffSummary(user02Migration, template02Migration); + changes.push( + ` ${pc.yellow("~")} migrations/0002_self_evolving.sql ${pc.dim(`(${diff})`)}`, + ); + } + if (dbNeedsMigration) { + changes.push( + ` ${pc.green("↑")} remote D1 schema ${pc.dim(`(${dbName} — needs 0.2 migration)`)}`, ); } - p.log.step(pc.bold("Files that will be updated:")); + + if (filesNeedUpdate) { + p.log.step(pc.bold("Files that will be updated:")); + } else { + p.log.step(pc.bold("Schema migration needed (files already current):")); + } p.log.message(changes.join("\n")); + if (migration02New || migration02Changed || dbNeedsMigration) { + p.log.info( + `${pc.cyan("0.2 self-evolving layer")} — adds skills, memory_links, reflections, injection_log tables\n` + + `${pc.dim("and 6 new columns on `memories` (confidence, verified_at, tier, ...).")}\n` + + `${pc.dim("All changes are additive — existing rows keep their data, ALTER TABLE ADD COLUMN")}\n` + + `${pc.dim("uses defaults so reads continue working, and new tables use CREATE TABLE IF NOT EXISTS.")}`, + ); + } if (migrationChanged) { p.log.info( - `${pc.dim("Note: the migration file is safe to overwrite — the schema is comment-only compatible across 0.1.x → 0.2.x.")}`, + `${pc.dim("0001_init.sql is being refreshed (comment/format changes only — no schema diff against your live DB).")}`, ); } - const proceed = await p.confirm({ - message: - "Backup (.bak) will be written next to each file. Proceed with update?", - initialValue: true, - }); - if (p.isCancel(proceed) || !proceed) { - p.outro(pc.yellow("Update cancelled. No changes made.")); - return; + // Only ask about file-write confirmation if files actually need updating. + if (filesNeedUpdate) { + const proceed = await p.confirm({ + message: + "Backup (.bak) will be written for each existing file. Proceed with update?", + initialValue: true, + }); + if (p.isCancel(proceed) || !proceed) { + p.outro(pc.yellow("Update cancelled. No changes made.")); + return; + } } - // Write backups then overwrite. - const backupSpinner = p.spinner(); - backupSpinner.start("Backing up existing files..."); - if (indexChanged) { - await copyFile(indexPath, `${indexPath}.bak`); - } - if (migrationChanged) { - await copyFile(migrationPath, `${migrationPath}.bak`); + // ── File write block (only if files actually differ) ────────────── + if (filesNeedUpdate) { + const backupSpinner = p.spinner(); + backupSpinner.start("Backing up existing files..."); + if (indexChanged) { + await copyFile(indexPath, `${indexPath}.bak`); + } + if (migrationChanged) { + await copyFile(migrationPath, `${migrationPath}.bak`); + } + if (user02Exists && migration02Changed) { + await copyFile(user02MigrationPath, `${user02MigrationPath}.bak`); + } + backupSpinner.stop("Backups written (.bak files)."); + + const writeSpinner = p.spinner(); + writeSpinner.start("Applying template updates..."); + if (indexChanged) { + await writeFile(indexPath, templateIndex, "utf-8"); + } + if (migrationChanged) { + await writeFile(migrationPath, templateMigration, "utf-8"); + } + if (template02Exists) { + await writeFile(user02MigrationPath, template02Migration, "utf-8"); + } + writeSpinner.stop("Template files updated."); } - backupSpinner.stop("Backups written (.bak files)."); - const writeSpinner = p.spinner(); - writeSpinner.start("Applying template updates..."); - if (indexChanged) { - await writeFile(indexPath, templateIndex, "utf-8"); + // ── Run remote 0002 migration if D1 schema is on 0.1 ─────────────── + // We reuse the `dbStatus` probed at the top of the function — runs even + // when local files match the template (which is what bit the source-repo case). + if (dbNeedsMigration && dbName) { + const runMigration = await p.confirm({ + message: + "Apply the 0.2 self-evolving migration to your REMOTE D1 now? (additive only — existing memories preserved)", + initialValue: true, + }); + if (p.isCancel(runMigration) || !runMigration) { + p.log.info( + `${pc.dim("Skipped DB migration. Run later with:")} ${pc.cyan("npm run db:upgrade")}`, + ); + } else { + p.log.step("Applying 0002_self_evolving.sql to remote D1..."); + const migrateResult = runCommandLive( + "npx", + [ + "wrangler", + "d1", + "execute", + dbName, + "--remote", + "--file=./migrations/0002_self_evolving.sql", + ], + cwd, + ); + if (!migrateResult.success) { + p.log.warn( + `Migration command exited non-zero. If you see "duplicate column" errors, that's safe — the new columns already exist.\n` + + `Re-run later with: ${pc.cyan("npm run db:upgrade")}`, + ); + } else { + // Verify by re-probing — gives the user confidence the migration landed. + const reProbe = await probe02Schema(dbName, cwd); + if (reProbe === "applied") { + p.log.success( + `${pc.green("0.2 schema applied")} — confirmed via re-probe. Existing memories preserved with defaults (confidence=0.8, tier=warm).`, + ); + } else { + p.log.warn( + `Migration ran but re-probe still shows '${reProbe}'. Inspect manually: ${pc.cyan(`npx wrangler d1 execute ${dbName} --remote --command "PRAGMA table_info(memories);"`)}`, + ); + } + } + } + } else if (dbStatus === "applied" && filesNeedUpdate) { + p.log.info( + `${pc.dim("Remote D1 already on 0.2 schema — no DB migration needed.")}`, + ); + } else if (dbStatus === "unknown") { + p.log.warn( + `Couldn't reach D1 to verify schema (likely not logged in or DB missing).\n` + + `If your hub is missing the 0.2 columns, run: ${pc.cyan("npm run db:upgrade")}`, + ); } - if (migrationChanged) { - await writeFile(migrationPath, templateMigration, "utf-8"); + + // Offer to redeploy whenever there was ANY change (files OR DB migration). + // A DB-only migration still benefits from a redeploy because the deployed + // worker code may be older than the new schema (common when running update + // from inside the source repo, or when a prior update skipped deploy). + const anythingChanged = filesNeedUpdate || dbNeedsMigration; + if (!anythingChanged) { + p.outro( + pc.green( + "Already up to date — files match template and remote D1 is on 0.2 schema.", + ), + ); + return; } - writeSpinner.stop("Template files updated."); - // Offer to redeploy. + const deployMsg = filesNeedUpdate + ? "Redeploy to Cloudflare Workers now?" + : "Redeploy worker to Cloudflare now? (Recommended — your live worker code may be older than the new schema.)"; const deploy = await p.confirm({ - message: "Redeploy to Cloudflare Workers now?", + message: deployMsg, initialValue: true, }); if (p.isCancel(deploy) || !deploy) { p.log.info(`Deploy when ready: ${pc.cyan("npx wrangler deploy")}`); p.outro( pc.green("Update applied.") + - pc.dim( - " Backups saved as src/index.ts.bak and migrations/0001_init.sql.bak.", - ), + pc.dim(" Backups saved as .bak files next to each updated file."), ); return; } @@ -819,7 +1043,8 @@ async function updateProject(): Promise { p.log.success("Redeployed."); p.log.info( `${pc.dim("Your MCP clients pick up the new tool schemas on their next conversation.")}\n` + - `${pc.dim("In Claude Code, run /mcp to refresh immediately.")}`, + `${pc.dim("In Claude Code, run /mcp to refresh immediately.")}\n` + + `${pc.dim("Try: ")}${pc.cyan('"check hub health"')}${pc.dim(" — your client will call the new get_hub_health tool.")}`, ); p.outro( pc.green("Update complete.") + diff --git a/packages/create-context-hub/templates/default/migrations/0002_self_evolving.sql b/packages/create-context-hub/templates/default/migrations/0002_self_evolving.sql new file mode 100644 index 0000000..2dea078 --- /dev/null +++ b/packages/create-context-hub/templates/default/migrations/0002_self_evolving.sql @@ -0,0 +1,129 @@ +-- Context Hub 0.2 — Self-Evolving Layer +-- Adds Hermes-style procedural memory + LLM Wiki-style linking + decay/provenance +-- on top of the existing flat memory store. +-- +-- ZERO BREAKING CHANGES: every addition is either a new table or a new column +-- with a safe default. Existing tools continue to work unchanged. + +-- ───────────────────────────────────────────────────────────── +-- 1. PROVENANCE + DECAY on memories +-- ───────────────────────────────────────────────────────────── +-- Existing memories rows get sensible defaults so nothing breaks. +ALTER TABLE memories ADD COLUMN confidence REAL DEFAULT 0.8; -- 0.0–1.0 +ALTER TABLE memories ADD COLUMN verified_at TEXT DEFAULT NULL; -- last time user/agent confirmed it +ALTER TABLE memories ADD COLUMN superseded_by INTEGER DEFAULT NULL;-- points at the newer memory that replaced this +ALTER TABLE memories ADD COLUMN tier TEXT DEFAULT 'warm'; -- hot | warm | cold (prompt budget tier) +ALTER TABLE memories ADD COLUMN access_count INTEGER DEFAULT 0; -- bumped on retrieval, drives hot/cold tiering +ALTER TABLE memories ADD COLUMN last_accessed_at TEXT DEFAULT NULL; + +CREATE INDEX IF NOT EXISTS idx_memories_tier ON memories(tier); +CREATE INDEX IF NOT EXISTS idx_memories_superseded ON memories(superseded_by); +CREATE INDEX IF NOT EXISTS idx_memories_confidence ON memories(confidence); + +-- ───────────────────────────────────────────────────────────── +-- 2. MEMORY LINKS — wiki-style graph +-- ───────────────────────────────────────────────────────────── +-- Contradictions, supersession, support, relatedness — between any two memories. +CREATE TABLE IF NOT EXISTS memory_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_id INTEGER NOT NULL, + to_id INTEGER NOT NULL, + relation TEXT NOT NULL, -- contradicts | supersedes | supports | related | example_of | refines + confidence REAL DEFAULT 0.8, + note TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')), + UNIQUE(from_id, to_id, relation), + FOREIGN KEY (from_id) REFERENCES memories(id) ON DELETE CASCADE, + FOREIGN KEY (to_id) REFERENCES memories(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_links_from ON memory_links(from_id); +CREATE INDEX IF NOT EXISTS idx_links_to ON memory_links(to_id); +CREATE INDEX IF NOT EXISTS idx_links_relation ON memory_links(relation); + +-- ───────────────────────────────────────────────────────────── +-- 3. SKILLS — procedural memory (the Hermes leap) +-- ───────────────────────────────────────────────────────────── +-- A "skill" is a markdown procedure the agent extracted from a successful task. +-- When a similar trigger appears, load the procedure and refine it after use. +CREATE TABLE IF NOT EXISTS skills ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + trigger_pattern TEXT NOT NULL, -- when to load this skill (keywords / description) + procedure TEXT NOT NULL, -- the markdown how-to + success_count INTEGER DEFAULT 0, + failure_count INTEGER DEFAULT 0, + last_used_at TEXT DEFAULT NULL, + version INTEGER DEFAULT 1, + parent_skill_id INTEGER DEFAULT NULL, -- lineage: skill v1 → v2 → v3 + source TEXT DEFAULT 'unknown', + tags TEXT DEFAULT '', + active INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (parent_skill_id) REFERENCES skills(id) ON DELETE SET NULL +); + +CREATE VIRTUAL TABLE IF NOT EXISTS skills_fts USING fts5( + name, trigger_pattern, procedure, tags, + content='skills', content_rowid='id' +); + +CREATE TRIGGER IF NOT EXISTS skills_ai AFTER INSERT ON skills BEGIN + INSERT INTO skills_fts(rowid, name, trigger_pattern, procedure, tags) + VALUES (new.id, new.name, new.trigger_pattern, new.procedure, new.tags); +END; + +CREATE TRIGGER IF NOT EXISTS skills_ad AFTER DELETE ON skills BEGIN + INSERT INTO skills_fts(skills_fts, rowid, name, trigger_pattern, procedure, tags) + VALUES ('delete', old.id, old.name, old.trigger_pattern, old.procedure, old.tags); +END; + +CREATE TRIGGER IF NOT EXISTS skills_au AFTER UPDATE ON skills BEGIN + INSERT INTO skills_fts(skills_fts, rowid, name, trigger_pattern, procedure, tags) + VALUES ('delete', old.id, old.name, old.trigger_pattern, old.procedure, old.tags); + INSERT INTO skills_fts(rowid, name, trigger_pattern, procedure, tags) + VALUES (new.id, new.name, new.trigger_pattern, new.procedure, new.tags); +END; + +CREATE INDEX IF NOT EXISTS idx_skills_active ON skills(active); +CREATE INDEX IF NOT EXISTS idx_skills_last_used ON skills(last_used_at); + +-- ───────────────────────────────────────────────────────────── +-- 4. REFLECTIONS — substrate for self-improvement +-- ───────────────────────────────────────────────────────────── +-- Agent writes a reflection when it spots a contradiction, a stale fact, +-- a repeated workflow, or a skill failure. Tools read pending reflections +-- and surface them to the user as suggestions. +CREATE TABLE IF NOT EXISTS reflections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trigger TEXT NOT NULL, -- contradiction | stale_fact | skill_candidate | skill_failed | duplicate_cluster | low_confidence + observation TEXT NOT NULL, -- what was noticed + proposed_change TEXT DEFAULT '', -- JSON: { action, target_id, payload } + related_ids TEXT DEFAULT '', -- comma-separated memory/skill IDs involved + status TEXT DEFAULT 'pending', -- pending | applied | dismissed + created_at TEXT DEFAULT (datetime('now')), + resolved_at TEXT DEFAULT NULL +); + +CREATE INDEX IF NOT EXISTS idx_reflections_status ON reflections(status); +CREATE INDEX IF NOT EXISTS idx_reflections_trigger ON reflections(trigger); + +-- ───────────────────────────────────────────────────────────── +-- 5. INJECTION SCAN LOG — security audit trail +-- ───────────────────────────────────────────────────────────── +-- Every flagged write gets logged here. Doesn't block the write by default +-- (tools decide), but gives the user a security feed. +CREATE TABLE IF NOT EXISTS injection_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + surface TEXT NOT NULL, -- save_memory | save_instruction | save_project | etc. + content_preview TEXT NOT NULL, -- first 200 chars + patterns_matched TEXT NOT NULL, -- comma-separated rule IDs + severity TEXT NOT NULL, -- low | medium | high + action_taken TEXT NOT NULL, -- blocked | flagged | sanitized + created_at TEXT DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_injection_severity ON injection_log(severity); +CREATE INDEX IF NOT EXISTS idx_injection_created ON injection_log(created_at); diff --git a/packages/create-context-hub/templates/default/package.json.tmpl b/packages/create-context-hub/templates/default/package.json.tmpl index 9437634..310c1fe 100644 --- a/packages/create-context-hub/templates/default/package.json.tmpl +++ b/packages/create-context-hub/templates/default/package.json.tmpl @@ -6,8 +6,11 @@ "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy", - "db:migrate": "wrangler d1 execute {{PROJECT_NAME}}-db --local --file=./migrations/0001_init.sql", - "db:migrate:remote": "wrangler d1 execute {{PROJECT_NAME}}-db --remote --file=./migrations/0001_init.sql", + "db:migrate": "wrangler d1 execute {{PROJECT_NAME}}-db --local --file=./migrations/0001_init.sql && wrangler d1 execute {{PROJECT_NAME}}-db --local --file=./migrations/0002_self_evolving.sql", + "db:migrate:remote": "wrangler d1 execute {{PROJECT_NAME}}-db --remote --file=./migrations/0001_init.sql && wrangler d1 execute {{PROJECT_NAME}}-db --remote --file=./migrations/0002_self_evolving.sql", + "db:migrate:0002": "wrangler d1 execute {{PROJECT_NAME}}-db --local --file=./migrations/0002_self_evolving.sql", + "db:migrate:0002:remote": "wrangler d1 execute {{PROJECT_NAME}}-db --remote --file=./migrations/0002_self_evolving.sql", + "db:upgrade": "wrangler d1 execute {{PROJECT_NAME}}-db --remote --file=./migrations/0002_self_evolving.sql", "typecheck": "tsc --noEmit" }, "license": "MIT", diff --git a/packages/create-context-hub/templates/default/src/index.ts b/packages/create-context-hub/templates/default/src/index.ts index a7ec183..f37c0d9 100644 --- a/packages/create-context-hub/templates/default/src/index.ts +++ b/packages/create-context-hub/templates/default/src/index.ts @@ -8,10 +8,89 @@ export interface Env { API_KEY?: string; // Optional: protect your hub with a shared secret } +// ── INJECTION SCANNER ────────────────────────────────────────── +// Hermes-style prompt-injection detection on every write surface. +// Returns matched rule IDs + severity. Tool decides whether to block or flag. +const INJECTION_PATTERNS: Array<{ + id: string; + pattern: RegExp; + severity: "low" | "medium" | "high"; +}> = [ + { + id: "ignore_previous", + pattern: /ignore (all|any|previous|prior)\s+(instructions|rules|prompts)/i, + severity: "high", + }, + { + id: "system_override", + pattern: /you are (now|actually|really)\s+(a|an)\s+/i, + severity: "medium", + }, + { + id: "exfil_curl", + pattern: /\b(curl|wget|fetch)\s+(https?:\/\/|-X\s+POST)/i, + severity: "high", + }, + { + id: "ssh_backdoor", + pattern: /ssh-(rsa|ed25519|dss)\s+[A-Za-z0-9+/=]{50,}/i, + severity: "high", + }, + { + id: "shell_pipe", + pattern: /\$\(.*\)|`[^`]{20,}`|;\s*(rm|curl|wget|nc)\s+/i, + severity: "medium", + }, + { + id: "tool_jailbreak", + pattern: /\b(developer mode|dan mode|jailbreak|sudo override)\b/i, + severity: "medium", + }, + { + id: "memory_overwrite", + pattern: /delete (all|every) (memory|memories|instruction)/i, + severity: "high", + }, + { + id: "system_prompt_leak", + pattern: + /(print|show|reveal|repeat)\s+(your|the)\s+(system|initial)\s+(prompt|instructions)/i, + severity: "medium", + }, +]; + +function scanForInjection(content: string): { + matched: string[]; + severity: "low" | "medium" | "high" | null; +} { + const matched: string[] = []; + let maxSev: "low" | "medium" | "high" | null = null; + const rank = { low: 1, medium: 2, high: 3 }; + for (const rule of INJECTION_PATTERNS) { + if (rule.pattern.test(content)) { + matched.push(rule.id); + if (!maxSev || rank[rule.severity] > rank[maxSev]) maxSev = rule.severity; + } + } + return { matched, severity: maxSev }; +} + +// Append-only suggestion appended to tool responses to nudge users toward +// better hygiene without breaking existing tool contracts. Format is intentionally +// human + LLM readable so any client surfaces it naturally. +type Suggestion = { kind: string; message: string; action?: string }; +function formatSuggestions(suggestions: Suggestion[]): string { + if (!suggestions.length) return ""; + const lines = suggestions.map( + (s) => `• [${s.kind}] ${s.message}${s.action ? `\n → ${s.action}` : ""}`, + ); + return `\n\n💡 hub_suggestion:\n${lines.join("\n")}`; +} + export class ContextHub extends McpAgent { server = new McpServer({ name: "Context Hub", - version: "0.1.0", + version: "0.2.0", }); // Return the MCP client's self-reported name, slugified for safe DB/URL use. @@ -31,6 +110,93 @@ export class ContextHub extends McpAgent { return slug || "unknown"; } + // Log an injection scan hit. Doesn't block — surfaces in the response. + private async logInjection( + surface: string, + content: string, + matched: string[], + severity: "low" | "medium" | "high", + action: "blocked" | "flagged" | "sanitized", + ): Promise { + try { + await this.env.DB.prepare( + "INSERT INTO injection_log (source, surface, content_preview, patterns_matched, severity, action_taken) VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind( + this.detectSource(), + surface, + content.slice(0, 200), + matched.join(","), + severity, + action, + ) + .run(); + } catch { + // injection_log table may not exist on older deploys — fail silent + } + } + + // Record a reflection (contradiction, skill candidate, etc.). Pending until acted on. + private async recordReflection( + trigger: string, + observation: string, + proposedChange: string = "", + relatedIds: string = "", + ): Promise { + try { + const r = await this.env.DB.prepare( + "INSERT INTO reflections (trigger, observation, proposed_change, related_ids) VALUES (?, ?, ?, ?)", + ) + .bind(trigger, observation, proposedChange, relatedIds) + .run(); + return (r.meta?.last_row_id as number) ?? null; + } catch { + return null; + } + } + + // Bump access_count + last_accessed_at and auto-promote to hot tier if + // a memory is hit often. Cheap fire-and-forget — failures don't break reads. + private async bumpAccess(ids: number[]): Promise { + if (!ids.length) return; + try { + const placeholders = ids.map(() => "?").join(","); + await this.env.DB.prepare( + `UPDATE memories + SET access_count = access_count + 1, + last_accessed_at = datetime('now'), + tier = CASE WHEN access_count + 1 >= 5 AND tier = 'warm' THEN 'hot' ELSE tier END + WHERE id IN (${placeholders})`, + ) + .bind(...ids) + .run(); + } catch { + // Tier columns may not exist on older deploys + } + } + + // Build the suggestion block for a save-style response. Looks at recent + // pending reflections + global hub state and surfaces what the user should do. + private async buildSuggestions(): Promise { + const out: Suggestion[] = []; + try { + const pending = await this.env.DB.prepare( + "SELECT id, trigger, observation FROM reflections WHERE status = 'pending' ORDER BY created_at DESC LIMIT 3", + ).all(); + for (const r of pending.results || []) { + const row = r as Record; + out.push({ + kind: row.trigger as string, + message: row.observation as string, + action: `Call resolve_reflection with id=${row.id} after acting.`, + }); + } + } catch { + // reflections table may not exist + } + return out; + } + async init() { // ── MEMORIES ────────────────────────────────────────────── @@ -70,6 +236,35 @@ SMART BEHAVIOR: const db = this.env.DB; const resolvedSource = source ?? this.detectSource(); + // 0.2 — Injection scan. We flag (not block) so the user can review. + const scan = scanForInjection(content); + if (scan.severity === "high") { + await this.logInjection( + "save_memory", + content, + scan.matched, + scan.severity, + "blocked", + ); + return { + content: [ + { + type: "text" as const, + text: `⛔ Memory blocked. Content matched high-severity injection patterns: ${scan.matched.join(", ")}.\n\nIf this is legitimate, rephrase or use save_memory with a safer wording. Logged to injection_log for audit.`, + }, + ], + }; + } + if (scan.severity) { + await this.logInjection( + "save_memory", + content, + scan.matched, + scan.severity, + "flagged", + ); + } + // Dedup check: look for existing memories with similar content // First try exact match const exact = await db @@ -143,7 +338,7 @@ SMART BEHAVIOR: // Very similar — update existing instead of creating duplicate await db .prepare( - "UPDATE memories SET content = ?, tags = ?, source = ?, updated_at = datetime('now') WHERE id = ?", + "UPDATE memories SET content = ?, tags = ?, source = ?, updated_at = datetime('now'), verified_at = datetime('now'), confidence = MIN(1.0, confidence + 0.1) WHERE id = ?", ) .bind(content, tags, resolvedSource, row.id) .run(); @@ -151,28 +346,83 @@ SMART BEHAVIOR: content: [ { type: "text" as const, - text: `Similar memory found (id: ${row.id}), updated with new content instead of creating duplicate.`, + text: + `Similar memory found (id: ${row.id}), updated with new content. confidence bumped, verified_at refreshed.` + + formatSuggestions(await this.buildSuggestions()), }, ], }; } + // 0.2 — Contradiction detection: similarity 0.3-0.6 with opposing + // signal words ("prefer/hate", "use/avoid", "always/never") suggests + // the new memory may contradict the old. Record a reflection. + if (similarity >= 0.3 && similarity <= 0.6) { + const oppositePairs = [ + ["prefer", "hate"], + ["love", "hate"], + ["use", "avoid"], + ["always", "never"], + ["enable", "disable"], + ["like", "dislike"], + ]; + const newC = content.toLowerCase(); + const oldC = (row.content as string).toLowerCase(); + const contradicts = oppositePairs.some( + ([a, b]) => + (newC.includes(a) && oldC.includes(b)) || + (newC.includes(b) && oldC.includes(a)), + ); + if (contradicts) { + await this.recordReflection( + "contradiction", + `New memory may contradict memory #${row.id}. Old: "${(row.content as string).slice(0, 120)}" — New: "${content.slice(0, 120)}"`, + JSON.stringify({ + action: "review_contradiction", + target_id: row.id, + }), + String(row.id), + ); + } + } } } } - // No duplicate found — insert new + // No duplicate found — insert new (with 0.2 provenance fields) const result = await db .prepare( - "INSERT INTO memories (content, category, tags, source) VALUES (?, ?, ?, ?)", + "INSERT INTO memories (content, category, tags, source, verified_at, confidence) VALUES (?, ?, ?, ?, datetime('now'), 0.8)", ) .bind(content, category, tags, resolvedSource) .run(); + const newId = result.meta.last_row_id as number; + + // 0.2 — Skill-candidate detection. If content describes a procedure + // (categories: decision/learning; signal words: "first", "then", + // "step", "always when"), record a skill candidate for the user to promote. + const procedural = + /\b(first|then|next|step\s*\d|finally|always when|whenever|process is)\b/i; + if ( + (category === "learning" || category === "decision") && + procedural.test(content) && + content.length >= 80 + ) { + await this.recordReflection( + "skill_candidate", + `Memory #${newId} reads like a procedure. Promote it to a reusable skill so the next similar task auto-loads it.`, + JSON.stringify({ action: "promote_to_skill", target_id: newId }), + String(newId), + ); + } + return { content: [ { type: "text" as const, - text: `Memory saved (id: ${result.meta.last_row_id}). Category: ${category}, Source: ${resolvedSource}`, + text: + `Memory saved (id: ${newId}). Category: ${category}, Source: ${resolvedSource}, Confidence: 0.8` + + formatSuggestions(await this.buildSuggestions()), }, ], }; @@ -235,6 +485,12 @@ SMART BEHAVIOR: Use natural keywords from the user's question as the query. If n }; } + // 0.2 — bump access_count + last_accessed_at for tier promotion + const hitIds = results.results + .map((r: Record) => r.id as number) + .filter((id) => typeof id === "number"); + await this.bumpAccess(hitIds); + const formatted = results.results .map( (r: Record) => @@ -246,7 +502,9 @@ SMART BEHAVIOR: Use natural keywords from the user's question as the query. If n content: [ { type: "text" as const, - text: `Found ${results.results.length} memories:\n\n${formatted}`, + text: + `Found ${results.results.length} memories:\n\n${formatted}` + + formatSuggestions(await this.buildSuggestions()), }, ], }; @@ -1963,6 +2221,597 @@ Use plenty of color, whitespace, and visual hierarchy. Think of it as a clean, m }; }, ); + + // ══════════════════════════════════════════════════════════════ + // 0.2 — SELF-EVOLVING LAYER (Hermes + LLM Wiki) + // ══════════════════════════════════════════════════════════════ + + // ── SKILLS (procedural memory) ────────────────────────────── + + this.server.tool( + "save_skill", + `Save a reusable skill — a markdown procedure the agent can auto-load next time a similar task appears. Skills are the "self-improving" layer: facts go in memories, methods go in skills. + +WHEN TO USE: When a complex task succeeds and the procedure is worth re-running. Promote a memory to a skill when a reflection of kind "skill_candidate" is pending. Also use when the user says "remember how to do X", "save this workflow", "add this as a skill". + +SMART BEHAVIOR: +- trigger_pattern: keywords the agent should look for when deciding to load this skill (e.g. "deploy frontend", "publish npm") +- procedure: markdown describing the steps — what to do, in what order, with gotchas +- Upserts by name. Bumps version + sets parent_skill_id when an existing skill is rewritten.`, + { + name: z + .string() + .describe("Skill name (unique slug, e.g. 'deploy-cf-worker')"), + trigger_pattern: z + .string() + .describe( + "Keywords/description for when this skill should auto-load", + ), + procedure: z.string().describe("The markdown how-to"), + tags: z.string().default("").describe("Comma-separated tags"), + parent_memory_id: z + .number() + .optional() + .describe("If promoting a memory, its id (links lineage)"), + }, + async ({ name, trigger_pattern, procedure, tags, parent_memory_id }) => { + const db = this.env.DB; + const scan = scanForInjection(procedure); + if (scan.severity === "high") { + await this.logInjection( + "save_skill", + procedure, + scan.matched, + scan.severity, + "blocked", + ); + return { + content: [ + { + type: "text" as const, + text: `⛔ Skill blocked. Procedure matched high-severity injection patterns: ${scan.matched.join(", ")}.`, + }, + ], + }; + } + const source = this.detectSource(); + const existing = await db + .prepare("SELECT id, version FROM skills WHERE name = ?") + .bind(name) + .first(); + + let skillId: number; + if (existing) { + const ex = existing as Record; + const newVersion = ((ex.version as number) ?? 1) + 1; + await db + .prepare( + `UPDATE skills SET trigger_pattern = ?, procedure = ?, tags = ?, version = ?, parent_skill_id = ?, source = ?, updated_at = datetime('now') WHERE id = ?`, + ) + .bind( + trigger_pattern, + procedure, + tags, + newVersion, + ex.id, + source, + ex.id, + ) + .run(); + skillId = ex.id as number; + } else { + const r = await db + .prepare( + `INSERT INTO skills (name, trigger_pattern, procedure, tags, source) VALUES (?, ?, ?, ?, ?)`, + ) + .bind(name, trigger_pattern, procedure, tags, source) + .run(); + skillId = r.meta.last_row_id as number; + } + + // If promoted from a memory, link the lineage + if (parent_memory_id) { + await this.recordReflection( + "skill_candidate", + `Memory #${parent_memory_id} promoted to skill "${name}" (#${skillId}).`, + JSON.stringify({ action: "applied", target_id: skillId }), + `${parent_memory_id},${skillId}`, + ); + } + + return { + content: [ + { + type: "text" as const, + text: `Skill "${name}" saved (id: ${skillId}). Will auto-load when triggers match.`, + }, + ], + }; + }, + ); + + this.server.tool( + "get_skill", + `Load a skill's procedure by name or by matching the current task to a trigger pattern. + +WHEN TO USE: Before starting a complex recurring task — search skills first, follow the saved procedure if one matches. After the task finishes, call record_skill_outcome to track success/failure. + +SMART BEHAVIOR: If 'query' is provided, runs FTS over name/trigger/procedure. If 'name' is provided, exact lookup.`, + { + name: z.string().optional().describe("Exact skill name"), + query: z + .string() + .optional() + .describe("Free-text query to match trigger patterns"), + }, + async ({ name, query }) => { + const db = this.env.DB; + let rows: Record[] = []; + if (name) { + const row = await db + .prepare("SELECT * FROM skills WHERE name = ? AND active = 1") + .bind(name) + .first(); + if (row) rows = [row as Record]; + } else if (query) { + const keywords = query + .toLowerCase() + .replace(/[^\w\s]/g, "") + .split(/\s+/) + .filter((w) => w.length >= 3) + .slice(0, 8) + .join(" OR "); + if (keywords) { + const r = await db + .prepare( + `SELECT s.* FROM skills_fts fts JOIN skills s ON fts.rowid = s.id WHERE fts.skills_fts MATCH ? AND s.active = 1 ORDER BY rank LIMIT 5`, + ) + .bind(keywords) + .all(); + rows = (r.results || []) as Record[]; + } + } + if (!rows.length) { + return { + content: [ + { + type: "text" as const, + text: "No matching skill found. Use save_skill to create one.", + }, + ], + }; + } + // Bump last_used_at on retrieved skills + const ids = rows.map((r) => r.id as number); + const placeholders = ids.map(() => "?").join(","); + await db + .prepare( + `UPDATE skills SET last_used_at = datetime('now') WHERE id IN (${placeholders})`, + ) + .bind(...ids) + .run(); + const out = rows + .map( + (r) => + `## ${r.name} (v${r.version}, id ${r.id})\nTrigger: ${r.trigger_pattern}\nSuccess: ${r.success_count} | Failures: ${r.failure_count}\n\n${r.procedure}`, + ) + .join("\n\n---\n\n"); + return { content: [{ type: "text" as const, text: out }] }; + }, + ); + + this.server.tool( + "list_skills", + `List all active skills with their success/failure counts. Use to audit procedural memory.`, + { + limit: z.number().default(50), + }, + async ({ limit }) => { + const db = this.env.DB; + const r = await db + .prepare( + `SELECT id, name, trigger_pattern, version, success_count, failure_count, last_used_at FROM skills WHERE active = 1 ORDER BY (success_count - failure_count) DESC, last_used_at DESC LIMIT ?`, + ) + .bind(limit) + .all(); + if (!r.results?.length) { + return { + content: [ + { + type: "text" as const, + text: "No skills saved yet. Promote a memory with save_skill.", + }, + ], + }; + } + const out = r.results + .map((s) => { + const row = s as Record; + return `[${row.id}] ${row.name} v${row.version} — ${row.trigger_pattern}\n ✓ ${row.success_count} ✗ ${row.failure_count} last: ${row.last_used_at || "never"}`; + }) + .join("\n\n"); + return { content: [{ type: "text" as const, text: out }] }; + }, + ); + + this.server.tool( + "record_skill_outcome", + `Record whether a skill worked. Skills with high failure rates surface as reflections so the user can refine them.`, + { + skill_id: z.number(), + success: z.boolean(), + note: z.string().default(""), + }, + async ({ skill_id, success, note }) => { + const db = this.env.DB; + const col = success ? "success_count" : "failure_count"; + await db + .prepare( + `UPDATE skills SET ${col} = ${col} + 1, last_used_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`, + ) + .bind(skill_id) + .run(); + // If failure rate > 50% and >= 3 total runs, flag a reflection + const row = (await db + .prepare( + "SELECT name, success_count, failure_count FROM skills WHERE id = ?", + ) + .bind(skill_id) + .first()) as Record | null; + if (row) { + const s = row.success_count as number; + const f = row.failure_count as number; + if (f + s >= 3 && f / (f + s) > 0.5) { + await this.recordReflection( + "skill_failed", + `Skill "${row.name}" (#${skill_id}) is failing more than succeeding (${f}/${f + s}). Refine the procedure or retire it.`, + JSON.stringify({ action: "refine_skill", target_id: skill_id }), + String(skill_id), + ); + } + } + return { + content: [ + { + type: "text" as const, + text: `Outcome recorded for skill ${skill_id}: ${success ? "success" : "failure"}.${note ? ` Note: ${note}` : ""}`, + }, + ], + }; + }, + ); + + // ── MEMORY LINKS (wiki graph) ──────────────────────────────── + + this.server.tool( + "link_memories", + `Create a typed link between two memories. Builds the wiki-style graph that lets the hub flag contradictions, supersession, and clusters. + +WHEN TO USE: When you spot a relationship between two memories — one supersedes another, two memories contradict, one supports another, or they're examples of a shared idea. + +RELATIONS: +- supersedes: from_id replaces to_id (also sets to_id.superseded_by) +- contradicts: from_id contradicts to_id (both kept, flagged) +- supports: from_id reinforces to_id +- related: loose association +- example_of: from_id is an instance of to_id +- refines: from_id is a more precise version of to_id`, + { + from_id: z.number(), + to_id: z.number(), + relation: z.enum([ + "supersedes", + "contradicts", + "supports", + "related", + "example_of", + "refines", + ]), + note: z.string().default(""), + }, + async ({ from_id, to_id, relation, note }) => { + const db = this.env.DB; + if (from_id === to_id) { + return { + content: [ + { + type: "text" as const, + text: "Cannot link a memory to itself.", + }, + ], + }; + } + await db + .prepare( + `INSERT INTO memory_links (from_id, to_id, relation, note) VALUES (?, ?, ?, ?) + ON CONFLICT(from_id, to_id, relation) DO UPDATE SET note = excluded.note`, + ) + .bind(from_id, to_id, relation, note) + .run(); + if (relation === "supersedes") { + await db + .prepare( + "UPDATE memories SET superseded_by = ?, tier = 'cold' WHERE id = ?", + ) + .bind(from_id, to_id) + .run(); + } + return { + content: [ + { + type: "text" as const, + text: `Linked #${from_id} —[${relation}]→ #${to_id}.${relation === "supersedes" ? ` Memory #${to_id} marked cold (superseded).` : ""}`, + }, + ], + }; + }, + ); + + this.server.tool( + "get_memory_graph", + `Get a memory's neighborhood — all incoming + outgoing links + the actual linked memories. Use to understand what surrounds a fact.`, + { + memory_id: z.number(), + depth: z + .number() + .default(1) + .describe("Currently only depth=1 supported"), + }, + async ({ memory_id }) => { + const db = this.env.DB; + const center = (await db + .prepare("SELECT * FROM memories WHERE id = ?") + .bind(memory_id) + .first()) as Record | null; + if (!center) { + return { + content: [ + { type: "text" as const, text: `Memory ${memory_id} not found.` }, + ], + }; + } + const outgoing = await db + .prepare( + `SELECT l.relation, l.note, m.id, m.content, m.category FROM memory_links l JOIN memories m ON m.id = l.to_id WHERE l.from_id = ?`, + ) + .bind(memory_id) + .all(); + const incoming = await db + .prepare( + `SELECT l.relation, l.note, m.id, m.content, m.category FROM memory_links l JOIN memories m ON m.id = l.from_id WHERE l.to_id = ?`, + ) + .bind(memory_id) + .all(); + const sections = [ + `## Memory #${memory_id} (${center.category}, confidence ${center.confidence})\n${center.content}`, + ]; + if (outgoing.results?.length) { + sections.push( + "## Outgoing\n" + + outgoing.results + .map((r) => { + const row = r as Record; + return `→ [${row.relation}] #${row.id} (${row.category}): ${(row.content as string).slice(0, 100)}`; + }) + .join("\n"), + ); + } + if (incoming.results?.length) { + sections.push( + "## Incoming\n" + + incoming.results + .map((r) => { + const row = r as Record; + return `← [${row.relation}] #${row.id} (${row.category}): ${(row.content as string).slice(0, 100)}`; + }) + .join("\n"), + ); + } + return { + content: [{ type: "text" as const, text: sections.join("\n\n") }], + }; + }, + ); + + // ── REFLECTIONS (self-improvement queue) ───────────────────── + + this.server.tool( + "list_reflections", + `List pending reflections — things the hub noticed and wants the user to resolve. Contradictions, stale facts, skill candidates, failing skills. + +WHEN TO USE: Periodically (start of session, after a batch of saves) to catch up on hub housekeeping. The hub appends a hint to write responses already, but call this to see the full queue.`, + { + status: z + .enum(["pending", "applied", "dismissed", "all"]) + .default("pending"), + limit: z.number().default(20), + }, + async ({ status, limit }) => { + const db = this.env.DB; + const sql = + status === "all" + ? "SELECT * FROM reflections ORDER BY created_at DESC LIMIT ?" + : "SELECT * FROM reflections WHERE status = ? ORDER BY created_at DESC LIMIT ?"; + const r = + status === "all" + ? await db.prepare(sql).bind(limit).all() + : await db.prepare(sql).bind(status, limit).all(); + if (!r.results?.length) { + return { + content: [ + { type: "text" as const, text: "No reflections. Hub is clean." }, + ], + }; + } + const out = r.results + .map((row) => { + const x = row as Record; + return `[${x.id}] (${x.status} / ${x.trigger}) ${x.observation}${x.related_ids ? `\n related: ${x.related_ids}` : ""}`; + }) + .join("\n\n"); + return { content: [{ type: "text" as const, text: out }] }; + }, + ); + + this.server.tool( + "resolve_reflection", + `Mark a reflection as applied or dismissed. Call after you (or the user) acted on it.`, + { + id: z.number(), + resolution: z.enum(["applied", "dismissed"]), + }, + async ({ id, resolution }) => { + const db = this.env.DB; + await db + .prepare( + "UPDATE reflections SET status = ?, resolved_at = datetime('now') WHERE id = ?", + ) + .bind(resolution, id) + .run(); + return { + content: [ + { + type: "text" as const, + text: `Reflection ${id} marked ${resolution}.`, + }, + ], + }; + }, + ); + + // ── HUB HEALTH (the nudge surface) ─────────────────────────── + + this.server.tool( + "get_hub_health", + `Inspect the hub's hygiene: stale memories, contradictions, low-confidence facts, skill candidates, injection log. Returns a prioritized action list. + +WHEN TO USE: At session start (alongside get_full_context) or whenever the user asks "what's the state of my hub?" / "is my memory healthy?". Use the action list to nudge the user toward fixing the worst issues first.`, + {}, + async () => { + const db = this.env.DB; + const [ + stale, + lowConf, + pending, + contradictions, + failingSkills, + recentInjections, + ] = await Promise.all([ + db + .prepare( + `SELECT COUNT(*) as c FROM memories WHERE verified_at IS NULL OR verified_at < datetime('now', '-180 days')`, + ) + .first(), + db + .prepare( + `SELECT COUNT(*) as c FROM memories WHERE confidence < 0.5`, + ) + .first(), + db + .prepare( + `SELECT trigger, COUNT(*) as c FROM reflections WHERE status = 'pending' GROUP BY trigger`, + ) + .all(), + db + .prepare( + `SELECT COUNT(*) as c FROM memory_links WHERE relation = 'contradicts'`, + ) + .first(), + db + .prepare( + `SELECT name, success_count, failure_count FROM skills WHERE active = 1 AND failure_count > success_count AND (failure_count + success_count) >= 3`, + ) + .all(), + db + .prepare( + `SELECT COUNT(*) as c FROM injection_log WHERE created_at > datetime('now', '-7 days')`, + ) + .first(), + ]); + + const staleC = ((stale as Record)?.c as number) || 0; + const lowC = ((lowConf as Record)?.c as number) || 0; + const contraC = + ((contradictions as Record)?.c as number) || 0; + const injC = + ((recentInjections as Record)?.c as number) || 0; + + const actions: string[] = []; + const byTrigger = Object.fromEntries( + (pending.results || []).map((r) => { + const row = r as Record; + return [row.trigger, row.c]; + }), + ); + + if (byTrigger.skill_candidate) + actions.push( + `🛠 ${byTrigger.skill_candidate} memory(ies) look procedural — promote with save_skill so they auto-load next time.`, + ); + if (byTrigger.contradiction) + actions.push( + `⚠️ ${byTrigger.contradiction} contradiction(s) pending — review with list_reflections then link_memories with relation 'supersedes' or 'contradicts'.`, + ); + if (staleC > 0) + actions.push( + `🗓 ${staleC} memory(ies) not verified in 6+ months — re-confirm or mark superseded.`, + ); + if (lowC > 0) + actions.push( + `🤔 ${lowC} memory(ies) with confidence < 0.5 — verify or delete.`, + ); + if (contraC > 0) + actions.push( + `🔀 ${contraC} contradiction link(s) in graph — resolve so the hub picks one truth.`, + ); + if ((failingSkills.results || []).length) + actions.push( + `💔 ${(failingSkills.results || []).length} skill(s) failing more than succeeding — refine procedures.`, + ); + if (injC > 0) + actions.push( + `🛡 ${injC} injection scan hit(s) in last 7 days — audit with raw query on injection_log.`, + ); + if (!actions.length) actions.push("✅ Hub is clean. No action needed."); + + return { + content: [ + { + type: "text" as const, + text: `# Hub Health\n\n${actions.map((a) => `- ${a}`).join("\n")}\n\n## Counts\n- Pending reflections: ${Object.values(byTrigger).reduce((a: number, b) => a + (b as number), 0)}\n- Stale memories: ${staleC}\n- Low-confidence memories: ${lowC}\n- Contradiction links: ${contraC}\n- Failing skills: ${(failingSkills.results || []).length}\n- Injection hits (7d): ${injC}`, + }, + ], + }; + }, + ); + + // ── VERIFY MEMORY (decay reset) ───────────────────────────── + + this.server.tool( + "verify_memory", + `Confirm a memory is still true. Resets verified_at, bumps confidence. Use when the user re-asserts a fact, or when an agent checks a stale memory and finds it accurate.`, + { + id: z.number(), + confidence_delta: z.number().default(0.1), + }, + async ({ id, confidence_delta }) => { + const db = this.env.DB; + await db + .prepare( + `UPDATE memories SET verified_at = datetime('now'), confidence = MIN(1.0, confidence + ?) WHERE id = ?`, + ) + .bind(confidence_delta, id) + .run(); + return { + content: [ + { + type: "text" as const, + text: `Memory ${id} re-verified. Confidence bumped by ${confidence_delta}.`, + }, + ], + }; + }, + ); } } @@ -1992,7 +2841,7 @@ export default { return new Response( JSON.stringify({ name: "Context Hub", - version: "0.1.0", + version: "0.2.0", status: "ok", endpoints: ["/mcp"], }), diff --git a/src/index.ts b/src/index.ts index a7ec183..f37c0d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,10 +8,89 @@ export interface Env { API_KEY?: string; // Optional: protect your hub with a shared secret } +// ── INJECTION SCANNER ────────────────────────────────────────── +// Hermes-style prompt-injection detection on every write surface. +// Returns matched rule IDs + severity. Tool decides whether to block or flag. +const INJECTION_PATTERNS: Array<{ + id: string; + pattern: RegExp; + severity: "low" | "medium" | "high"; +}> = [ + { + id: "ignore_previous", + pattern: /ignore (all|any|previous|prior)\s+(instructions|rules|prompts)/i, + severity: "high", + }, + { + id: "system_override", + pattern: /you are (now|actually|really)\s+(a|an)\s+/i, + severity: "medium", + }, + { + id: "exfil_curl", + pattern: /\b(curl|wget|fetch)\s+(https?:\/\/|-X\s+POST)/i, + severity: "high", + }, + { + id: "ssh_backdoor", + pattern: /ssh-(rsa|ed25519|dss)\s+[A-Za-z0-9+/=]{50,}/i, + severity: "high", + }, + { + id: "shell_pipe", + pattern: /\$\(.*\)|`[^`]{20,}`|;\s*(rm|curl|wget|nc)\s+/i, + severity: "medium", + }, + { + id: "tool_jailbreak", + pattern: /\b(developer mode|dan mode|jailbreak|sudo override)\b/i, + severity: "medium", + }, + { + id: "memory_overwrite", + pattern: /delete (all|every) (memory|memories|instruction)/i, + severity: "high", + }, + { + id: "system_prompt_leak", + pattern: + /(print|show|reveal|repeat)\s+(your|the)\s+(system|initial)\s+(prompt|instructions)/i, + severity: "medium", + }, +]; + +function scanForInjection(content: string): { + matched: string[]; + severity: "low" | "medium" | "high" | null; +} { + const matched: string[] = []; + let maxSev: "low" | "medium" | "high" | null = null; + const rank = { low: 1, medium: 2, high: 3 }; + for (const rule of INJECTION_PATTERNS) { + if (rule.pattern.test(content)) { + matched.push(rule.id); + if (!maxSev || rank[rule.severity] > rank[maxSev]) maxSev = rule.severity; + } + } + return { matched, severity: maxSev }; +} + +// Append-only suggestion appended to tool responses to nudge users toward +// better hygiene without breaking existing tool contracts. Format is intentionally +// human + LLM readable so any client surfaces it naturally. +type Suggestion = { kind: string; message: string; action?: string }; +function formatSuggestions(suggestions: Suggestion[]): string { + if (!suggestions.length) return ""; + const lines = suggestions.map( + (s) => `• [${s.kind}] ${s.message}${s.action ? `\n → ${s.action}` : ""}`, + ); + return `\n\n💡 hub_suggestion:\n${lines.join("\n")}`; +} + export class ContextHub extends McpAgent { server = new McpServer({ name: "Context Hub", - version: "0.1.0", + version: "0.2.0", }); // Return the MCP client's self-reported name, slugified for safe DB/URL use. @@ -31,6 +110,93 @@ export class ContextHub extends McpAgent { return slug || "unknown"; } + // Log an injection scan hit. Doesn't block — surfaces in the response. + private async logInjection( + surface: string, + content: string, + matched: string[], + severity: "low" | "medium" | "high", + action: "blocked" | "flagged" | "sanitized", + ): Promise { + try { + await this.env.DB.prepare( + "INSERT INTO injection_log (source, surface, content_preview, patterns_matched, severity, action_taken) VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind( + this.detectSource(), + surface, + content.slice(0, 200), + matched.join(","), + severity, + action, + ) + .run(); + } catch { + // injection_log table may not exist on older deploys — fail silent + } + } + + // Record a reflection (contradiction, skill candidate, etc.). Pending until acted on. + private async recordReflection( + trigger: string, + observation: string, + proposedChange: string = "", + relatedIds: string = "", + ): Promise { + try { + const r = await this.env.DB.prepare( + "INSERT INTO reflections (trigger, observation, proposed_change, related_ids) VALUES (?, ?, ?, ?)", + ) + .bind(trigger, observation, proposedChange, relatedIds) + .run(); + return (r.meta?.last_row_id as number) ?? null; + } catch { + return null; + } + } + + // Bump access_count + last_accessed_at and auto-promote to hot tier if + // a memory is hit often. Cheap fire-and-forget — failures don't break reads. + private async bumpAccess(ids: number[]): Promise { + if (!ids.length) return; + try { + const placeholders = ids.map(() => "?").join(","); + await this.env.DB.prepare( + `UPDATE memories + SET access_count = access_count + 1, + last_accessed_at = datetime('now'), + tier = CASE WHEN access_count + 1 >= 5 AND tier = 'warm' THEN 'hot' ELSE tier END + WHERE id IN (${placeholders})`, + ) + .bind(...ids) + .run(); + } catch { + // Tier columns may not exist on older deploys + } + } + + // Build the suggestion block for a save-style response. Looks at recent + // pending reflections + global hub state and surfaces what the user should do. + private async buildSuggestions(): Promise { + const out: Suggestion[] = []; + try { + const pending = await this.env.DB.prepare( + "SELECT id, trigger, observation FROM reflections WHERE status = 'pending' ORDER BY created_at DESC LIMIT 3", + ).all(); + for (const r of pending.results || []) { + const row = r as Record; + out.push({ + kind: row.trigger as string, + message: row.observation as string, + action: `Call resolve_reflection with id=${row.id} after acting.`, + }); + } + } catch { + // reflections table may not exist + } + return out; + } + async init() { // ── MEMORIES ────────────────────────────────────────────── @@ -70,6 +236,35 @@ SMART BEHAVIOR: const db = this.env.DB; const resolvedSource = source ?? this.detectSource(); + // 0.2 — Injection scan. We flag (not block) so the user can review. + const scan = scanForInjection(content); + if (scan.severity === "high") { + await this.logInjection( + "save_memory", + content, + scan.matched, + scan.severity, + "blocked", + ); + return { + content: [ + { + type: "text" as const, + text: `⛔ Memory blocked. Content matched high-severity injection patterns: ${scan.matched.join(", ")}.\n\nIf this is legitimate, rephrase or use save_memory with a safer wording. Logged to injection_log for audit.`, + }, + ], + }; + } + if (scan.severity) { + await this.logInjection( + "save_memory", + content, + scan.matched, + scan.severity, + "flagged", + ); + } + // Dedup check: look for existing memories with similar content // First try exact match const exact = await db @@ -143,7 +338,7 @@ SMART BEHAVIOR: // Very similar — update existing instead of creating duplicate await db .prepare( - "UPDATE memories SET content = ?, tags = ?, source = ?, updated_at = datetime('now') WHERE id = ?", + "UPDATE memories SET content = ?, tags = ?, source = ?, updated_at = datetime('now'), verified_at = datetime('now'), confidence = MIN(1.0, confidence + 0.1) WHERE id = ?", ) .bind(content, tags, resolvedSource, row.id) .run(); @@ -151,28 +346,83 @@ SMART BEHAVIOR: content: [ { type: "text" as const, - text: `Similar memory found (id: ${row.id}), updated with new content instead of creating duplicate.`, + text: + `Similar memory found (id: ${row.id}), updated with new content. confidence bumped, verified_at refreshed.` + + formatSuggestions(await this.buildSuggestions()), }, ], }; } + // 0.2 — Contradiction detection: similarity 0.3-0.6 with opposing + // signal words ("prefer/hate", "use/avoid", "always/never") suggests + // the new memory may contradict the old. Record a reflection. + if (similarity >= 0.3 && similarity <= 0.6) { + const oppositePairs = [ + ["prefer", "hate"], + ["love", "hate"], + ["use", "avoid"], + ["always", "never"], + ["enable", "disable"], + ["like", "dislike"], + ]; + const newC = content.toLowerCase(); + const oldC = (row.content as string).toLowerCase(); + const contradicts = oppositePairs.some( + ([a, b]) => + (newC.includes(a) && oldC.includes(b)) || + (newC.includes(b) && oldC.includes(a)), + ); + if (contradicts) { + await this.recordReflection( + "contradiction", + `New memory may contradict memory #${row.id}. Old: "${(row.content as string).slice(0, 120)}" — New: "${content.slice(0, 120)}"`, + JSON.stringify({ + action: "review_contradiction", + target_id: row.id, + }), + String(row.id), + ); + } + } } } } - // No duplicate found — insert new + // No duplicate found — insert new (with 0.2 provenance fields) const result = await db .prepare( - "INSERT INTO memories (content, category, tags, source) VALUES (?, ?, ?, ?)", + "INSERT INTO memories (content, category, tags, source, verified_at, confidence) VALUES (?, ?, ?, ?, datetime('now'), 0.8)", ) .bind(content, category, tags, resolvedSource) .run(); + const newId = result.meta.last_row_id as number; + + // 0.2 — Skill-candidate detection. If content describes a procedure + // (categories: decision/learning; signal words: "first", "then", + // "step", "always when"), record a skill candidate for the user to promote. + const procedural = + /\b(first|then|next|step\s*\d|finally|always when|whenever|process is)\b/i; + if ( + (category === "learning" || category === "decision") && + procedural.test(content) && + content.length >= 80 + ) { + await this.recordReflection( + "skill_candidate", + `Memory #${newId} reads like a procedure. Promote it to a reusable skill so the next similar task auto-loads it.`, + JSON.stringify({ action: "promote_to_skill", target_id: newId }), + String(newId), + ); + } + return { content: [ { type: "text" as const, - text: `Memory saved (id: ${result.meta.last_row_id}). Category: ${category}, Source: ${resolvedSource}`, + text: + `Memory saved (id: ${newId}). Category: ${category}, Source: ${resolvedSource}, Confidence: 0.8` + + formatSuggestions(await this.buildSuggestions()), }, ], }; @@ -235,6 +485,12 @@ SMART BEHAVIOR: Use natural keywords from the user's question as the query. If n }; } + // 0.2 — bump access_count + last_accessed_at for tier promotion + const hitIds = results.results + .map((r: Record) => r.id as number) + .filter((id) => typeof id === "number"); + await this.bumpAccess(hitIds); + const formatted = results.results .map( (r: Record) => @@ -246,7 +502,9 @@ SMART BEHAVIOR: Use natural keywords from the user's question as the query. If n content: [ { type: "text" as const, - text: `Found ${results.results.length} memories:\n\n${formatted}`, + text: + `Found ${results.results.length} memories:\n\n${formatted}` + + formatSuggestions(await this.buildSuggestions()), }, ], }; @@ -1963,6 +2221,597 @@ Use plenty of color, whitespace, and visual hierarchy. Think of it as a clean, m }; }, ); + + // ══════════════════════════════════════════════════════════════ + // 0.2 — SELF-EVOLVING LAYER (Hermes + LLM Wiki) + // ══════════════════════════════════════════════════════════════ + + // ── SKILLS (procedural memory) ────────────────────────────── + + this.server.tool( + "save_skill", + `Save a reusable skill — a markdown procedure the agent can auto-load next time a similar task appears. Skills are the "self-improving" layer: facts go in memories, methods go in skills. + +WHEN TO USE: When a complex task succeeds and the procedure is worth re-running. Promote a memory to a skill when a reflection of kind "skill_candidate" is pending. Also use when the user says "remember how to do X", "save this workflow", "add this as a skill". + +SMART BEHAVIOR: +- trigger_pattern: keywords the agent should look for when deciding to load this skill (e.g. "deploy frontend", "publish npm") +- procedure: markdown describing the steps — what to do, in what order, with gotchas +- Upserts by name. Bumps version + sets parent_skill_id when an existing skill is rewritten.`, + { + name: z + .string() + .describe("Skill name (unique slug, e.g. 'deploy-cf-worker')"), + trigger_pattern: z + .string() + .describe( + "Keywords/description for when this skill should auto-load", + ), + procedure: z.string().describe("The markdown how-to"), + tags: z.string().default("").describe("Comma-separated tags"), + parent_memory_id: z + .number() + .optional() + .describe("If promoting a memory, its id (links lineage)"), + }, + async ({ name, trigger_pattern, procedure, tags, parent_memory_id }) => { + const db = this.env.DB; + const scan = scanForInjection(procedure); + if (scan.severity === "high") { + await this.logInjection( + "save_skill", + procedure, + scan.matched, + scan.severity, + "blocked", + ); + return { + content: [ + { + type: "text" as const, + text: `⛔ Skill blocked. Procedure matched high-severity injection patterns: ${scan.matched.join(", ")}.`, + }, + ], + }; + } + const source = this.detectSource(); + const existing = await db + .prepare("SELECT id, version FROM skills WHERE name = ?") + .bind(name) + .first(); + + let skillId: number; + if (existing) { + const ex = existing as Record; + const newVersion = ((ex.version as number) ?? 1) + 1; + await db + .prepare( + `UPDATE skills SET trigger_pattern = ?, procedure = ?, tags = ?, version = ?, parent_skill_id = ?, source = ?, updated_at = datetime('now') WHERE id = ?`, + ) + .bind( + trigger_pattern, + procedure, + tags, + newVersion, + ex.id, + source, + ex.id, + ) + .run(); + skillId = ex.id as number; + } else { + const r = await db + .prepare( + `INSERT INTO skills (name, trigger_pattern, procedure, tags, source) VALUES (?, ?, ?, ?, ?)`, + ) + .bind(name, trigger_pattern, procedure, tags, source) + .run(); + skillId = r.meta.last_row_id as number; + } + + // If promoted from a memory, link the lineage + if (parent_memory_id) { + await this.recordReflection( + "skill_candidate", + `Memory #${parent_memory_id} promoted to skill "${name}" (#${skillId}).`, + JSON.stringify({ action: "applied", target_id: skillId }), + `${parent_memory_id},${skillId}`, + ); + } + + return { + content: [ + { + type: "text" as const, + text: `Skill "${name}" saved (id: ${skillId}). Will auto-load when triggers match.`, + }, + ], + }; + }, + ); + + this.server.tool( + "get_skill", + `Load a skill's procedure by name or by matching the current task to a trigger pattern. + +WHEN TO USE: Before starting a complex recurring task — search skills first, follow the saved procedure if one matches. After the task finishes, call record_skill_outcome to track success/failure. + +SMART BEHAVIOR: If 'query' is provided, runs FTS over name/trigger/procedure. If 'name' is provided, exact lookup.`, + { + name: z.string().optional().describe("Exact skill name"), + query: z + .string() + .optional() + .describe("Free-text query to match trigger patterns"), + }, + async ({ name, query }) => { + const db = this.env.DB; + let rows: Record[] = []; + if (name) { + const row = await db + .prepare("SELECT * FROM skills WHERE name = ? AND active = 1") + .bind(name) + .first(); + if (row) rows = [row as Record]; + } else if (query) { + const keywords = query + .toLowerCase() + .replace(/[^\w\s]/g, "") + .split(/\s+/) + .filter((w) => w.length >= 3) + .slice(0, 8) + .join(" OR "); + if (keywords) { + const r = await db + .prepare( + `SELECT s.* FROM skills_fts fts JOIN skills s ON fts.rowid = s.id WHERE fts.skills_fts MATCH ? AND s.active = 1 ORDER BY rank LIMIT 5`, + ) + .bind(keywords) + .all(); + rows = (r.results || []) as Record[]; + } + } + if (!rows.length) { + return { + content: [ + { + type: "text" as const, + text: "No matching skill found. Use save_skill to create one.", + }, + ], + }; + } + // Bump last_used_at on retrieved skills + const ids = rows.map((r) => r.id as number); + const placeholders = ids.map(() => "?").join(","); + await db + .prepare( + `UPDATE skills SET last_used_at = datetime('now') WHERE id IN (${placeholders})`, + ) + .bind(...ids) + .run(); + const out = rows + .map( + (r) => + `## ${r.name} (v${r.version}, id ${r.id})\nTrigger: ${r.trigger_pattern}\nSuccess: ${r.success_count} | Failures: ${r.failure_count}\n\n${r.procedure}`, + ) + .join("\n\n---\n\n"); + return { content: [{ type: "text" as const, text: out }] }; + }, + ); + + this.server.tool( + "list_skills", + `List all active skills with their success/failure counts. Use to audit procedural memory.`, + { + limit: z.number().default(50), + }, + async ({ limit }) => { + const db = this.env.DB; + const r = await db + .prepare( + `SELECT id, name, trigger_pattern, version, success_count, failure_count, last_used_at FROM skills WHERE active = 1 ORDER BY (success_count - failure_count) DESC, last_used_at DESC LIMIT ?`, + ) + .bind(limit) + .all(); + if (!r.results?.length) { + return { + content: [ + { + type: "text" as const, + text: "No skills saved yet. Promote a memory with save_skill.", + }, + ], + }; + } + const out = r.results + .map((s) => { + const row = s as Record; + return `[${row.id}] ${row.name} v${row.version} — ${row.trigger_pattern}\n ✓ ${row.success_count} ✗ ${row.failure_count} last: ${row.last_used_at || "never"}`; + }) + .join("\n\n"); + return { content: [{ type: "text" as const, text: out }] }; + }, + ); + + this.server.tool( + "record_skill_outcome", + `Record whether a skill worked. Skills with high failure rates surface as reflections so the user can refine them.`, + { + skill_id: z.number(), + success: z.boolean(), + note: z.string().default(""), + }, + async ({ skill_id, success, note }) => { + const db = this.env.DB; + const col = success ? "success_count" : "failure_count"; + await db + .prepare( + `UPDATE skills SET ${col} = ${col} + 1, last_used_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`, + ) + .bind(skill_id) + .run(); + // If failure rate > 50% and >= 3 total runs, flag a reflection + const row = (await db + .prepare( + "SELECT name, success_count, failure_count FROM skills WHERE id = ?", + ) + .bind(skill_id) + .first()) as Record | null; + if (row) { + const s = row.success_count as number; + const f = row.failure_count as number; + if (f + s >= 3 && f / (f + s) > 0.5) { + await this.recordReflection( + "skill_failed", + `Skill "${row.name}" (#${skill_id}) is failing more than succeeding (${f}/${f + s}). Refine the procedure or retire it.`, + JSON.stringify({ action: "refine_skill", target_id: skill_id }), + String(skill_id), + ); + } + } + return { + content: [ + { + type: "text" as const, + text: `Outcome recorded for skill ${skill_id}: ${success ? "success" : "failure"}.${note ? ` Note: ${note}` : ""}`, + }, + ], + }; + }, + ); + + // ── MEMORY LINKS (wiki graph) ──────────────────────────────── + + this.server.tool( + "link_memories", + `Create a typed link between two memories. Builds the wiki-style graph that lets the hub flag contradictions, supersession, and clusters. + +WHEN TO USE: When you spot a relationship between two memories — one supersedes another, two memories contradict, one supports another, or they're examples of a shared idea. + +RELATIONS: +- supersedes: from_id replaces to_id (also sets to_id.superseded_by) +- contradicts: from_id contradicts to_id (both kept, flagged) +- supports: from_id reinforces to_id +- related: loose association +- example_of: from_id is an instance of to_id +- refines: from_id is a more precise version of to_id`, + { + from_id: z.number(), + to_id: z.number(), + relation: z.enum([ + "supersedes", + "contradicts", + "supports", + "related", + "example_of", + "refines", + ]), + note: z.string().default(""), + }, + async ({ from_id, to_id, relation, note }) => { + const db = this.env.DB; + if (from_id === to_id) { + return { + content: [ + { + type: "text" as const, + text: "Cannot link a memory to itself.", + }, + ], + }; + } + await db + .prepare( + `INSERT INTO memory_links (from_id, to_id, relation, note) VALUES (?, ?, ?, ?) + ON CONFLICT(from_id, to_id, relation) DO UPDATE SET note = excluded.note`, + ) + .bind(from_id, to_id, relation, note) + .run(); + if (relation === "supersedes") { + await db + .prepare( + "UPDATE memories SET superseded_by = ?, tier = 'cold' WHERE id = ?", + ) + .bind(from_id, to_id) + .run(); + } + return { + content: [ + { + type: "text" as const, + text: `Linked #${from_id} —[${relation}]→ #${to_id}.${relation === "supersedes" ? ` Memory #${to_id} marked cold (superseded).` : ""}`, + }, + ], + }; + }, + ); + + this.server.tool( + "get_memory_graph", + `Get a memory's neighborhood — all incoming + outgoing links + the actual linked memories. Use to understand what surrounds a fact.`, + { + memory_id: z.number(), + depth: z + .number() + .default(1) + .describe("Currently only depth=1 supported"), + }, + async ({ memory_id }) => { + const db = this.env.DB; + const center = (await db + .prepare("SELECT * FROM memories WHERE id = ?") + .bind(memory_id) + .first()) as Record | null; + if (!center) { + return { + content: [ + { type: "text" as const, text: `Memory ${memory_id} not found.` }, + ], + }; + } + const outgoing = await db + .prepare( + `SELECT l.relation, l.note, m.id, m.content, m.category FROM memory_links l JOIN memories m ON m.id = l.to_id WHERE l.from_id = ?`, + ) + .bind(memory_id) + .all(); + const incoming = await db + .prepare( + `SELECT l.relation, l.note, m.id, m.content, m.category FROM memory_links l JOIN memories m ON m.id = l.from_id WHERE l.to_id = ?`, + ) + .bind(memory_id) + .all(); + const sections = [ + `## Memory #${memory_id} (${center.category}, confidence ${center.confidence})\n${center.content}`, + ]; + if (outgoing.results?.length) { + sections.push( + "## Outgoing\n" + + outgoing.results + .map((r) => { + const row = r as Record; + return `→ [${row.relation}] #${row.id} (${row.category}): ${(row.content as string).slice(0, 100)}`; + }) + .join("\n"), + ); + } + if (incoming.results?.length) { + sections.push( + "## Incoming\n" + + incoming.results + .map((r) => { + const row = r as Record; + return `← [${row.relation}] #${row.id} (${row.category}): ${(row.content as string).slice(0, 100)}`; + }) + .join("\n"), + ); + } + return { + content: [{ type: "text" as const, text: sections.join("\n\n") }], + }; + }, + ); + + // ── REFLECTIONS (self-improvement queue) ───────────────────── + + this.server.tool( + "list_reflections", + `List pending reflections — things the hub noticed and wants the user to resolve. Contradictions, stale facts, skill candidates, failing skills. + +WHEN TO USE: Periodically (start of session, after a batch of saves) to catch up on hub housekeeping. The hub appends a hint to write responses already, but call this to see the full queue.`, + { + status: z + .enum(["pending", "applied", "dismissed", "all"]) + .default("pending"), + limit: z.number().default(20), + }, + async ({ status, limit }) => { + const db = this.env.DB; + const sql = + status === "all" + ? "SELECT * FROM reflections ORDER BY created_at DESC LIMIT ?" + : "SELECT * FROM reflections WHERE status = ? ORDER BY created_at DESC LIMIT ?"; + const r = + status === "all" + ? await db.prepare(sql).bind(limit).all() + : await db.prepare(sql).bind(status, limit).all(); + if (!r.results?.length) { + return { + content: [ + { type: "text" as const, text: "No reflections. Hub is clean." }, + ], + }; + } + const out = r.results + .map((row) => { + const x = row as Record; + return `[${x.id}] (${x.status} / ${x.trigger}) ${x.observation}${x.related_ids ? `\n related: ${x.related_ids}` : ""}`; + }) + .join("\n\n"); + return { content: [{ type: "text" as const, text: out }] }; + }, + ); + + this.server.tool( + "resolve_reflection", + `Mark a reflection as applied or dismissed. Call after you (or the user) acted on it.`, + { + id: z.number(), + resolution: z.enum(["applied", "dismissed"]), + }, + async ({ id, resolution }) => { + const db = this.env.DB; + await db + .prepare( + "UPDATE reflections SET status = ?, resolved_at = datetime('now') WHERE id = ?", + ) + .bind(resolution, id) + .run(); + return { + content: [ + { + type: "text" as const, + text: `Reflection ${id} marked ${resolution}.`, + }, + ], + }; + }, + ); + + // ── HUB HEALTH (the nudge surface) ─────────────────────────── + + this.server.tool( + "get_hub_health", + `Inspect the hub's hygiene: stale memories, contradictions, low-confidence facts, skill candidates, injection log. Returns a prioritized action list. + +WHEN TO USE: At session start (alongside get_full_context) or whenever the user asks "what's the state of my hub?" / "is my memory healthy?". Use the action list to nudge the user toward fixing the worst issues first.`, + {}, + async () => { + const db = this.env.DB; + const [ + stale, + lowConf, + pending, + contradictions, + failingSkills, + recentInjections, + ] = await Promise.all([ + db + .prepare( + `SELECT COUNT(*) as c FROM memories WHERE verified_at IS NULL OR verified_at < datetime('now', '-180 days')`, + ) + .first(), + db + .prepare( + `SELECT COUNT(*) as c FROM memories WHERE confidence < 0.5`, + ) + .first(), + db + .prepare( + `SELECT trigger, COUNT(*) as c FROM reflections WHERE status = 'pending' GROUP BY trigger`, + ) + .all(), + db + .prepare( + `SELECT COUNT(*) as c FROM memory_links WHERE relation = 'contradicts'`, + ) + .first(), + db + .prepare( + `SELECT name, success_count, failure_count FROM skills WHERE active = 1 AND failure_count > success_count AND (failure_count + success_count) >= 3`, + ) + .all(), + db + .prepare( + `SELECT COUNT(*) as c FROM injection_log WHERE created_at > datetime('now', '-7 days')`, + ) + .first(), + ]); + + const staleC = ((stale as Record)?.c as number) || 0; + const lowC = ((lowConf as Record)?.c as number) || 0; + const contraC = + ((contradictions as Record)?.c as number) || 0; + const injC = + ((recentInjections as Record)?.c as number) || 0; + + const actions: string[] = []; + const byTrigger = Object.fromEntries( + (pending.results || []).map((r) => { + const row = r as Record; + return [row.trigger, row.c]; + }), + ); + + if (byTrigger.skill_candidate) + actions.push( + `🛠 ${byTrigger.skill_candidate} memory(ies) look procedural — promote with save_skill so they auto-load next time.`, + ); + if (byTrigger.contradiction) + actions.push( + `⚠️ ${byTrigger.contradiction} contradiction(s) pending — review with list_reflections then link_memories with relation 'supersedes' or 'contradicts'.`, + ); + if (staleC > 0) + actions.push( + `🗓 ${staleC} memory(ies) not verified in 6+ months — re-confirm or mark superseded.`, + ); + if (lowC > 0) + actions.push( + `🤔 ${lowC} memory(ies) with confidence < 0.5 — verify or delete.`, + ); + if (contraC > 0) + actions.push( + `🔀 ${contraC} contradiction link(s) in graph — resolve so the hub picks one truth.`, + ); + if ((failingSkills.results || []).length) + actions.push( + `💔 ${(failingSkills.results || []).length} skill(s) failing more than succeeding — refine procedures.`, + ); + if (injC > 0) + actions.push( + `🛡 ${injC} injection scan hit(s) in last 7 days — audit with raw query on injection_log.`, + ); + if (!actions.length) actions.push("✅ Hub is clean. No action needed."); + + return { + content: [ + { + type: "text" as const, + text: `# Hub Health\n\n${actions.map((a) => `- ${a}`).join("\n")}\n\n## Counts\n- Pending reflections: ${Object.values(byTrigger).reduce((a: number, b) => a + (b as number), 0)}\n- Stale memories: ${staleC}\n- Low-confidence memories: ${lowC}\n- Contradiction links: ${contraC}\n- Failing skills: ${(failingSkills.results || []).length}\n- Injection hits (7d): ${injC}`, + }, + ], + }; + }, + ); + + // ── VERIFY MEMORY (decay reset) ───────────────────────────── + + this.server.tool( + "verify_memory", + `Confirm a memory is still true. Resets verified_at, bumps confidence. Use when the user re-asserts a fact, or when an agent checks a stale memory and finds it accurate.`, + { + id: z.number(), + confidence_delta: z.number().default(0.1), + }, + async ({ id, confidence_delta }) => { + const db = this.env.DB; + await db + .prepare( + `UPDATE memories SET verified_at = datetime('now'), confidence = MIN(1.0, confidence + ?) WHERE id = ?`, + ) + .bind(confidence_delta, id) + .run(); + return { + content: [ + { + type: "text" as const, + text: `Memory ${id} re-verified. Confidence bumped by ${confidence_delta}.`, + }, + ], + }; + }, + ); } } @@ -1992,7 +2841,7 @@ export default { return new Response( JSON.stringify({ name: "Context Hub", - version: "0.1.0", + version: "0.2.0", status: "ok", endpoints: ["/mcp"], }),