diff --git a/.gitignore b/.gitignore index 09a5b2d..9957289 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ .mcp.json *.log bun.lockb +.idea/ diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh index 5220062..64eec2b 100755 --- a/scripts/install-hooks.sh +++ b/scripts/install-hooks.sh @@ -63,7 +63,7 @@ const hooks = { SessionStart: [{ hooks: [{ type: 'command', command: wrap('session-start') }] }], PreToolUse: [{ matcher: '', hooks: [{ type: 'command', command: wrap('pre-tool') }] }], PostToolUse: [{ matcher: '', hooks: [{ type: 'command', command: wrap('post-tool') }] }], - Stop: [{ hooks: [{ type: 'command', command: wrap('session-end') }] }], + SessionEnd: [{ hooks: [{ type: 'command', command: wrap('session-end') }] }], }; fs.writeFileSync(settingsPath, JSON.stringify({ ...existing, hooks }, null, 2)); console.log('Hook configuration written to: ' + settingsPath); @@ -91,7 +91,7 @@ echo "=== Installation Complete ===" echo "" echo "Hooks written to: ~/.claude/settings.json" echo " SessionStart → $DIST_DIR/session-start" -echo " Stop → $DIST_DIR/session-end" +echo " SessionEnd → $DIST_DIR/session-end" echo " PreToolUse → $DIST_DIR/pre-tool" echo " PostToolUse → $DIST_DIR/post-tool" echo "" diff --git a/scripts/register-hooks.ts b/scripts/register-hooks.ts index d346561..eb01dfd 100644 --- a/scripts/register-hooks.ts +++ b/scripts/register-hooks.ts @@ -12,6 +12,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { join, resolve } from "node:path"; +import { stripLegacyBetterdbHooks } from "../src/hook-migration.js"; const pluginRoot = process.argv[2]; if (!pluginRoot) { @@ -23,7 +24,12 @@ const resolvedRoot = resolve(pluginRoot); const hooksDir = join(resolvedRoot, "src", "hooks"); // Verify hook source files exist -const hookFiles = ["session-start.ts", "pre-tool.ts", "post-tool.ts", "session-end.ts"]; +const hookFiles = [ + "session-start.ts", + "pre-tool.ts", + "post-tool.ts", + "session-end.ts", +]; for (const file of hookFiles) { if (!existsSync(join(hooksDir, file))) { console.error(`ERROR: Hook source not found: ${join(hooksDir, file)}`); @@ -45,7 +51,9 @@ if (existsSync(settingsPath)) { settings = JSON.parse(readFileSync(settingsPath, "utf-8")); } catch { // Corrupted file — start fresh but warn - console.warn("WARNING: Could not parse ~/.claude/settings.json — existing content will be preserved as backup."); + console.warn( + "WARNING: Could not parse ~/.claude/settings.json — existing content will be preserved as backup.", + ); const backupPath = settingsPath + ".bak"; writeFileSync(backupPath, readFileSync(settingsPath)); console.warn(` Backup saved to ${backupPath}`); @@ -56,8 +64,13 @@ function cmd(hookFile: string): string { return `bash -c 'bun run "${join(hooksDir, hookFile)}"'`; } -// Merge hooks — replaces BetterDB entries per event, preserves all others -const existingHooks = (settings["hooks"] ?? {}) as Record; +// Merge hooks — replaces BetterDB entries per event, preserves all others. +// The loop below only visits events in betterdbHooks, so the legacy Stop +// registration must be stripped explicitly or it survives forever. +const existingHooks = stripLegacyBetterdbHooks( + (settings["hooks"] ?? {}) as Record, + ["betterdb", hooksDir], +); const betterdbHooks: Record = { SessionStart: [ { hooks: [{ type: "command", command: cmd("session-start.ts") }] }, @@ -68,7 +81,7 @@ const betterdbHooks: Record = { PostToolUse: [ { matcher: "", hooks: [{ type: "command", command: cmd("post-tool.ts") }] }, ], - Stop: [ + SessionEnd: [ { hooks: [{ type: "command", command: cmd("session-end.ts") }] }, ], }; @@ -89,6 +102,6 @@ console.log("BetterDB Memory — Hooks registered in ~/.claude/settings.json\n") console.log(" SessionStart → session-start.ts"); console.log(" PreToolUse → pre-tool.ts"); console.log(" PostToolUse → post-tool.ts"); -console.log(" Stop → session-end.ts"); +console.log(" SessionEnd → session-end.ts"); console.log(`\n Plugin root: ${resolvedRoot}`); console.log("\n Restart Claude Code for hooks to take effect."); diff --git a/src/hook-migration.ts b/src/hook-migration.ts new file mode 100644 index 0000000..84c8d1c --- /dev/null +++ b/src/hook-migration.ts @@ -0,0 +1,38 @@ +// Events this plugin used to register on and no longer does. mergeHooks only +// touches events present in the current hook map, so without an explicit strip +// a stale registration would survive upgrades forever in a user's +// ~/.claude/settings.json. +const LEGACY_EVENTS = ["Stop"]; + +/** + * `markers` identify an entry as ours. The default catches installed binaries + * under ~/.betterdb; dev registrations point at a plugin checkout whose path + * may not contain "betterdb", so callers pass that path as an extra marker. + */ +export function stripLegacyBetterdbHooks( + hooks: Record, + markers: string[] = ["betterdb"], +): Record { + const out: Record = { ...hooks }; + + for (const event of LEGACY_EVENTS) { + const entries = out[event]; + if (!Array.isArray(entries)) { + continue; + } + // Only ours — a third party's hook on the same event must survive. + const kept = entries.filter((entry) => { + const json = JSON.stringify(entry); + return !markers.some((m) => { + return m.length > 0 && json.includes(m); + }); + }); + if (kept.length > 0) { + out[event] = kept; + } else { + delete out[event]; + } + } + + return out; +} diff --git a/src/hooks/drain.ts b/src/hooks/drain.ts new file mode 100644 index 0000000..69eff47 --- /dev/null +++ b/src/hooks/drain.ts @@ -0,0 +1,36 @@ +import { getValkeyClient } from "../client/valkey.js"; +import { getPluginMemoryStore } from "../client/memory-store.js"; +import { createModelClient } from "../client/model.js"; +import { AgingPipeline } from "../memory/aging.js"; +import { isConfigured } from "../config.js"; + +// Detached drainer: summarizes and stores whatever the SessionEnd hook queued. +// Runs as its own process precisely so the LLM call is not on a hook's clock — +// nothing waits for this. +async function main(): Promise { + if (!isConfigured()) { + return; + } + + const valkeyClient = await getValkeyClient(); + const modelClient = await createModelClient(); + const store = await getPluginMemoryStore((t) => modelClient.embed(t)); + const pipeline = new AgingPipeline(valkeyClient, store, modelClient); + + const { processed, skipped } = await pipeline.processIngestQueue(); + console.error(`[betterdb] drain: processed=${processed} skipped=${skipped}`); + + await store.close(); + await valkeyClient.quit(); +} + +main() + .catch((err: unknown) => { + console.error( + "[betterdb] drain failed:", + err instanceof Error ? err.message : String(err), + ); + }) + .finally(() => { + process.exit(0); + }); diff --git a/src/hooks/session-end.ts b/src/hooks/session-end.ts index a13f1ea..ccf21cf 100644 --- a/src/hooks/session-end.ts +++ b/src/hooks/session-end.ts @@ -1,30 +1,31 @@ import { readRawPayload, runHook } from "./_utils.js"; import { getValkeyClient } from "../client/valkey.js"; -import { getPluginMemoryStore } from "../client/memory-store.js"; -import { createModelClient } from "../client/model.js"; import { SessionCapture, - computeInitialImportance, getGitBranch, getCwdProject, } from "../memory/capture.js"; -import { SessionEventSchema, type EpisodicMemory } from "../memory/schema.js"; +import { SessionEventSchema } from "../memory/schema.js"; import { selectTranscript, type TranscriptTurn } from "../memory/transcript.js"; import { config, isConfigured } from "../config.js"; import { unlink } from "node:fs/promises"; +import { join } from "node:path"; /** - * Stop hook (session-end): Captures the session transcript and stores a memory. + * SessionEnd hook: captures the session transcript and queues it. * * Claude Code hooks contract: - * - Fires when Claude finishes responding (Stop event) - * - Receives JSON on stdin with session_id, transcript_path, cwd + * - Fires once when the session terminates + * - Receives JSON on stdin with session_id, transcript_path, cwd, reason * - Exit 0 for success * + * This hook performs NO model work. It queues the transcript and spawns a + * detached drainer; AgingPipeline.processIngestQueue does the summarization. + * Summarizing here would block the session on an LLM call. + * * Capture strategy: * 1. Prefer transcript_path (complete conversation with user messages) * 2. Fall back to JSONL event file (tool calls only) - * 3. If model client is unavailable, queue for later processing */ runHook(async () => { if (!isConfigured()) return; @@ -92,46 +93,57 @@ runHook(async () => { const project = getCwdProject(); const branch = getGitBranch(); - // Try to summarize; queue on failure - let modelClient; - try { - modelClient = await createModelClient(); - } catch { - console.error( - "[betterdb] Ollama unavailable — transcript queued for later processing", - ); - await valkeyClient.pushIngestQueue(transcript, { - project, - branch, - timestamp: new Date().toISOString(), - sessionId, - }); - await valkeyClient.quit(); - await cleanup(eventFilePath); - return; - } - - const summary = await modelClient.summarize(transcript); - const importance = computeInitialImportance(summary); - - const memory: EpisodicMemory = { - memoryId: crypto.randomUUID(), + await valkeyClient.pushIngestQueue(transcript, { project, branch, timestamp: new Date().toISOString(), - summary, - importanceScore: importance, - accessCount: 0, - lastAccessed: new Date().toISOString(), - }; - - const store = await getPluginMemoryStore((t) => modelClient.embed(t)); - await store.storeMemory(memory); - await store.close(); + sessionId, + }); + + await spawnDrain(); + await valkeyClient.quit(); await cleanup(eventFilePath); }); +/** + * Spawn the detached drainer. unref() releases it from this process's event + * loop, so the hook exits immediately while summarization continues. + * + * Both install shapes must work: `install` compiles binaries into + * ~/.betterdb/bin, while register-hooks.ts registers `bun run ` and + * compiles nothing. Resolving only the compiled path left that second shape + * with no drainer at all — and the exists() guard made it silent. + */ +async function spawnDrain(): Promise { + // HOME is unset on Windows, where install and config both fall back to + // USERPROFILE. + const home = process.env["HOME"] ?? process.env["USERPROFILE"] ?? ""; + const drainBin = join(home, ".betterdb", "bin", "drain"); + if (await Bun.file(drainBin).exists()) { + Bun.spawn([drainBin], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }).unref(); + return; + } + + const drainSrc = join(import.meta.dir, "drain.ts"); + if (await Bun.file(drainSrc).exists()) { + Bun.spawn(["bun", "run", drainSrc], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }).unref(); + return; + } + + console.error( + "[betterdb] no drain binary or source found — queued transcripts stay queued until `betterdb-memory drain` runs", + ); +} + /** * Parse Claude Code's transcript JSONL into role-tagged turns. * The JSONL contains objects with type: "user" | "assistant" and message content. @@ -158,7 +170,11 @@ async function parseTranscriptTurns(path: string): Promise { .join("\n") : ""; // Skip system-generated messages (commands, caveats) - if (content && !content.includes("")) { + if ( + content && + !content.includes("") + ) { turns.push({ role: "user", text: `User: ${content}` }); } } else if (entry.type === "assistant" && entry.message?.content) { @@ -172,7 +188,10 @@ async function parseTranscriptTurns(path: string): Promise { .join("\n") : ""; if (content) { - turns.push({ role: "assistant", text: `Assistant: ${content.slice(0, 2000)}` }); + turns.push({ + role: "assistant", + text: `Assistant: ${content.slice(0, 2000)}`, + }); } } else if (entry.type === "tool_use" || entry.type === "tool_result") { // Include tool names for context but keep it brief diff --git a/src/index.ts b/src/index.ts index 79be3c9..4ffbc16 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,8 +10,16 @@ * betterdb-memory maintain — Run aging/compression manually */ -import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, rmSync } from "node:fs"; +import { + existsSync, + readFileSync, + writeFileSync, + mkdirSync, + chmodSync, + rmSync, +} from "node:fs"; import { join, resolve } from "node:path"; +import { stripLegacyBetterdbHooks } from "./hook-migration.js"; const VERSION = "0.5.0"; const HOME = process.env["HOME"] ?? process.env["USERPROFILE"] ?? ""; @@ -26,6 +34,7 @@ const BINARIES = [ { src: "src/hooks/session-end.ts", out: "session-end" }, { src: "src/hooks/pre-tool.ts", out: "pre-tool" }, { src: "src/hooks/post-tool.ts", out: "post-tool" }, + { src: "src/hooks/drain.ts", out: "drain" }, { src: "src/mcp/server.ts", out: "mcp-server" }, ] as const; @@ -40,6 +49,7 @@ Commands: uninstall Remove hooks, MCP server, and compiled binaries status Check health of Valkey and model providers maintain Run aging/consolidation pipeline manually + drain Summarize and store any queued transcripts forget Bulk-delete memories by scope (dry run; pass --apply) Flags: --project (default: cwd) | --all-projects --branch --tags --apply @@ -71,6 +81,9 @@ switch (command) { case "maintain": await runMaintain(); break; + case "drain": + await runDrain(); + break; case "forget": await runForget(process.argv.slice(3)); break; @@ -125,7 +138,9 @@ async function runInstall() { } if (!commandExists("claude")) { console.error("ERROR: 'claude' not found on PATH."); - console.error("Install Claude Code first: https://docs.anthropic.com/en/docs/claude-code"); + console.error( + "Install Claude Code first: https://docs.anthropic.com/en/docs/claude-code", + ); process.exit(1); } console.log("Preflight checks passed.\n"); @@ -139,7 +154,10 @@ async function runInstall() { process.stdout.write(`Connecting to Valkey at ${valkeyUrl}... `); try { const Redis = (await import("iovalkey")).default; - const client = new Redis(valkeyUrl, { maxRetriesPerRequest: 1, lazyConnect: true }); + const client = new Redis(valkeyUrl, { + maxRetriesPerRequest: 1, + lazyConnect: true, + }); await client.connect(); await client.ping(); console.log("OK"); @@ -147,8 +165,12 @@ async function runInstall() { } catch (err) { console.log("FAILED"); console.error(`\nCould not connect to Valkey at ${valkeyUrl}`); - console.error("Make sure Valkey 8+ is running with the Search module loaded."); - console.error("Quick start: docker run -d -p 6379:6379 valkey/valkey-bundle:8"); + console.error( + "Make sure Valkey 8+ is running with the Search module loaded.", + ); + console.error( + "Quick start: docker run -d -p 6379:6379 valkey/valkey-bundle:8", + ); process.exit(1); } @@ -167,8 +189,14 @@ async function runInstall() { } const result = Bun.spawnSync([ - "bun", "build", "--compile", "--external", "openai", - srcPath, "--outfile", outPath, + "bun", + "build", + "--compile", + "--external", + "openai", + srcPath, + "--outfile", + outPath, ]); if (result.exitCode !== 0) { @@ -184,7 +212,9 @@ async function runInstall() { // Verify all binaries exist const missing = BINARIES.filter((b) => !existsSync(join(BIN_DIR, b.out))); if (missing.length > 0) { - console.error(`\nERROR: Missing binaries: ${missing.map((b) => b.out).join(", ")}`); + console.error( + `\nERROR: Missing binaries: ${missing.map((b) => b.out).join(", ")}`, + ); process.exit(1); } @@ -205,12 +235,30 @@ async function runInstall() { } } - const existingHooks = (settings["hooks"] ?? {}) as Record; + // mergeHooks only touches events present in betterdbHooks, so the legacy + // Stop registration must be stripped explicitly or it survives upgrades. + const existingHooks = stripLegacyBetterdbHooks( + (settings["hooks"] ?? {}) as Record, + ); const betterdbHooks: Record = { - SessionStart: [{ hooks: [{ type: "command", command: join(BIN_DIR, "session-start") }] }], - PreToolUse: [{ matcher: "", hooks: [{ type: "command", command: join(BIN_DIR, "pre-tool") }] }], - PostToolUse: [{ matcher: "", hooks: [{ type: "command", command: join(BIN_DIR, "post-tool") }] }], - Stop: [{ hooks: [{ type: "command", command: join(BIN_DIR, "session-end") }] }], + SessionStart: [ + { hooks: [{ type: "command", command: join(BIN_DIR, "session-start") }] }, + ], + PreToolUse: [ + { + matcher: "", + hooks: [{ type: "command", command: join(BIN_DIR, "pre-tool") }], + }, + ], + PostToolUse: [ + { + matcher: "", + hooks: [{ type: "command", command: join(BIN_DIR, "post-tool") }], + }, + ], + SessionEnd: [ + { hooks: [{ type: "command", command: join(BIN_DIR, "session-end") }] }, + ], }; settings["hooks"] = mergeHooks(existingHooks, betterdbHooks); @@ -221,7 +269,14 @@ async function runInstall() { const mcpBin = join(BIN_DIR, "mcp-server"); Bun.spawnSync(["claude", "mcp", "remove", "-s", "user", "betterdb-memory"]); const mcpResult = Bun.spawnSync([ - "claude", "mcp", "add", "-s", "user", "betterdb-memory", "--", mcpBin, + "claude", + "mcp", + "add", + "-s", + "user", + "betterdb-memory", + "--", + mcpBin, ]); if (mcpResult.exitCode === 0) { console.log(" Registered MCP server: betterdb-memory (global)"); @@ -239,14 +294,19 @@ async function runInstall() { const client = await getValkeyClient(); const modelClient = await createModelClient(); // Record the active provider/dimension so a later provider swap is caught. - await client.assertEmbedDim(modelClient.embedDim, modelClient.preset.embedModel); + await client.assertEmbedDim( + modelClient.embedDim, + modelClient.preset.embedModel, + ); const store = await getPluginMemoryStore((t) => modelClient.embed(t)); await store.ensureIndex(); console.log(" Valkey index ready"); await store.close(); await client.quit(); } catch (err) { - console.log(` WARNING: Index setup failed (${err instanceof Error ? err.message : String(err)})`); + console.log( + ` WARNING: Index setup failed (${err instanceof Error ? err.message : String(err)})`, + ); console.log(" You can create it later: npx @betterdb/memory setup-index"); } @@ -255,7 +315,8 @@ async function runInstall() { const configData: Record = { BETTERDB_VALKEY_URL: valkeyUrl, - BETTERDB_VALKEY_INDEX_NAME: Bun.env["BETTERDB_VALKEY_INDEX_NAME"] ?? "betterdb-memory-index", + BETTERDB_VALKEY_INDEX_NAME: + Bun.env["BETTERDB_VALKEY_INDEX_NAME"] ?? "betterdb-memory-index", BETTERDB_EMBED_DIM: Number(Bun.env["BETTERDB_EMBED_DIM"] ?? 1024), version: VERSION, installedAt: new Date().toISOString(), @@ -263,11 +324,18 @@ async function runInstall() { // Carry forward any extra env vars the user has set const extraKeys = [ - "BETTERDB_EMBED_MODEL", "BETTERDB_SUMMARIZE_MODEL", - "BETTERDB_OLLAMA_URL", "BETTERDB_EMBED_PROVIDER", "BETTERDB_SUMMARIZE_PROVIDER", - "BETTERDB_MAX_CONTEXT_MEMORIES", "BETTERDB_ALLOW_REMOTE_FALLBACK", - "ANTHROPIC_API_KEY", "VOYAGE_API_KEY", "OPENAI_API_KEY", - "GROQ_API_KEY", "TOGETHER_API_KEY", + "BETTERDB_EMBED_MODEL", + "BETTERDB_SUMMARIZE_MODEL", + "BETTERDB_OLLAMA_URL", + "BETTERDB_EMBED_PROVIDER", + "BETTERDB_SUMMARIZE_PROVIDER", + "BETTERDB_MAX_CONTEXT_MEMORIES", + "BETTERDB_ALLOW_REMOTE_FALLBACK", + "ANTHROPIC_API_KEY", + "VOYAGE_API_KEY", + "OPENAI_API_KEY", + "GROQ_API_KEY", + "TOGETHER_API_KEY", ]; for (const key of extraKeys) { const val = Bun.env[key]; @@ -277,7 +345,10 @@ async function runInstall() { writeFileSync(CONFIG_PATH, JSON.stringify(configData, null, 2) + "\n"); const manifest = { - binaries: BINARIES.map((b) => ({ name: b.out, path: join(BIN_DIR, b.out) })), + binaries: BINARIES.map((b) => ({ + name: b.out, + path: join(BIN_DIR, b.out), + })), configPath: CONFIG_PATH, settingsPath, installedAt: new Date().toISOString(), @@ -321,7 +392,14 @@ async function runUninstall() { // Remove MCP server (try both user and local scope) Bun.spawnSync(["claude", "mcp", "remove", "-s", "local", "betterdb-memory"]); - const mcpResult = Bun.spawnSync(["claude", "mcp", "remove", "-s", "user", "betterdb-memory"]); + const mcpResult = Bun.spawnSync([ + "claude", + "mcp", + "remove", + "-s", + "user", + "betterdb-memory", + ]); if (mcpResult.exitCode === 0) { console.log(" Removed MCP server: betterdb-memory"); } else { @@ -341,7 +419,9 @@ async function runUninstall() { } console.log("\n Uninstall complete."); - console.log(` Config preserved at ${CONFIG_PATH} — delete ~/.betterdb/ to remove entirely.`); + console.log( + ` Config preserved at ${CONFIG_PATH} — delete ~/.betterdb/ to remove entirely.`, + ); } // --------------------------------------------------------------------------- @@ -392,7 +472,9 @@ async function runStatus() { if (present.length === BINARIES.length) { console.log(`OK (${present.length}/${BINARIES.length} in ${BIN_DIR}/)`); } else if (present.length > 0) { - console.log(`PARTIAL (${present.length}/${BINARIES.length} — reinstall recommended)`); + console.log( + `PARTIAL (${present.length}/${BINARIES.length} — reinstall recommended)`, + ); } else { console.log("NOT INSTALLED (run: npx @betterdb/memory install)"); } @@ -404,7 +486,9 @@ async function runStatus() { if (existsSync(settingsPath)) { const settings = JSON.parse(readFileSync(settingsPath, "utf-8")); const hookCount = Object.keys(settings.hooks ?? {}).length; - console.log(hookCount > 0 ? `OK (${hookCount} lifecycle events)` : "NOT CONFIGURED"); + console.log( + hookCount > 0 ? `OK (${hookCount} lifecycle events)` : "NOT CONFIGURED", + ); } else { console.log("NOT CONFIGURED (no ~/.claude/settings.json)"); } @@ -422,7 +506,9 @@ async function runStatus() { const output = result.stdout.toString().trim(); if (output.includes("is running")) { const portMatch = output.match(/port (\d+)/); - console.log(`OK (betterdb-valkey, running, port ${portMatch?.[1] ?? "unknown"})`); + console.log( + `OK (betterdb-valkey, running, port ${portMatch?.[1] ?? "unknown"})`, + ); } else if (output.includes("stopped")) { console.log(`STOPPED (run: bunx @betterdb/memory docker-valkey)`); } else { @@ -472,6 +558,29 @@ async function runMaintain() { await valkeyClient.quit(); } +// --------------------------------------------------------------------------- +// drain (summarize + store queued transcripts) + +async function runDrain() { + const { getValkeyClient } = await import("./client/valkey.js"); + const { getPluginMemoryStore } = await import("./client/memory-store.js"); + const { createModelClient } = await import("./client/model.js"); + const { AgingPipeline } = await import("./memory/aging.js"); + + const valkeyClient = await getValkeyClient(); + const modelClient = await createModelClient(); + const store = await getPluginMemoryStore((t) => modelClient.embed(t)); + const pipeline = new AgingPipeline(valkeyClient, store, modelClient); + + const { processed, skipped } = await pipeline.processIngestQueue(); + console.log( + `Processed ${processed} queued transcript(s), skipped ${skipped} empty.`, + ); + + await store.close(); + await valkeyClient.quit(); +} + // --------------------------------------------------------------------------- // forget (bulk delete by scope: project / branch / tags) // --------------------------------------------------------------------------- @@ -486,17 +595,28 @@ async function runForget(argv: string[]) { const apply = argv.includes("--apply"); const allProjects = argv.includes("--all-projects"); const branch = flag("branch"); - const tags = flag("tags")?.split(",").map((t) => t.trim()).filter(Boolean); + const tags = flag("tags") + ?.split(",") + .map((t) => t.trim()) + .filter(Boolean); const { getValkeyClient } = await import("./client/valkey.js"); const { getPluginMemoryStore } = await import("./client/memory-store.js"); const { getCwdProject } = await import("./memory/capture.js"); - const project = allProjects ? undefined : (flag("project") ?? getCwdProject()); + const project = allProjects + ? undefined + : (flag("project") ?? getCwdProject()); // Refuse an unbounded delete: --all-projects must be narrowed by branch/tags. - if (project === undefined && branch === undefined && (!tags || tags.length === 0)) { - console.error("Refusing to delete every memory. Narrow --all-projects with --branch or --tags."); + if ( + project === undefined && + branch === undefined && + (!tags || tags.length === 0) + ) { + console.error( + "Refusing to delete every memory. Narrow --all-projects with --branch or --tags.", + ); process.exit(1); } @@ -504,7 +624,9 @@ async function runForget(argv: string[]) { project !== undefined ? `project=${project}` : "all projects", branch !== undefined ? `branch=${branch}` : null, tags && tags.length > 0 ? `tags=${tags.join(",")}` : null, - ].filter(Boolean).join(", "); + ] + .filter(Boolean) + .join(", "); console.log(`Scope: ${scopeDesc}`); const valkeyClient = await getValkeyClient(); @@ -525,7 +647,8 @@ async function runForget(argv: string[]) { for (const m of candidates.slice(0, 5)) { console.log(` - [${m.branch}] ${m.summary.oneLineSummary.slice(0, 70)}`); } - if (candidates.length > 5) console.log(` ... and ${candidates.length - 5} more`); + if (candidates.length > 5) + console.log(` ... and ${candidates.length - 5} more`); if (!apply) { console.log("\nDry run — re-run with --apply to delete."); @@ -553,7 +676,10 @@ async function runSetupIndex() { const client = await getValkeyClient(); const modelClient = await createModelClient(); // Record the active provider/dimension so a later provider swap is caught. - await client.assertEmbedDim(modelClient.embedDim, modelClient.preset.embedModel); + await client.assertEmbedDim( + modelClient.embedDim, + modelClient.preset.embedModel, + ); const store = await getPluginMemoryStore((t) => modelClient.embed(t)); await store.ensureIndex(); console.log("Index ready: betterdb:mem:idx"); @@ -575,7 +701,9 @@ async function runMigrate(apply: boolean) { const valkeyClient = await getValkeyClient(); const legacyIds = await valkeyClient.listMemoryIds(); - console.log(`Found ${legacyIds.length} legacy memories under betterdb:memory:*`); + console.log( + `Found ${legacyIds.length} legacy memories under betterdb:memory:*`, + ); if (legacyIds.length === 0) { console.log("Nothing to migrate."); @@ -585,10 +713,16 @@ async function runMigrate(apply: boolean) { if (!apply) { console.log("\nDry run — re-run with --apply to migrate."); - console.log("Each legacy memory is re-embedded and written to betterdb:mem:*,"); + console.log( + "Each legacy memory is re-embedded and written to betterdb:mem:*,", + ); console.log("and knowledge entries are re-pointed to the new memory ids."); - console.log("The legacy index is dropped only after the new count is verified;"); - console.log("legacy hashes are left in place for you to delete once satisfied."); + console.log( + "The legacy index is dropped only after the new count is verified;", + ); + console.log( + "legacy hashes are left in place for you to delete once satisfied.", + ); await valkeyClient.quit(); return; } @@ -622,7 +756,10 @@ async function runMigrate(apply: boolean) { console.log(` Migrated ${migrated}/${legacyIds.length}...`); } } catch (err) { - console.error(` Failed to migrate ${id}:`, err instanceof Error ? err.message : String(err)); + console.error( + ` Failed to migrate ${id}:`, + err instanceof Error ? err.message : String(err), + ); failed++; } } @@ -633,15 +770,22 @@ async function runMigrate(apply: boolean) { let remappedKnowledge = 0; for (const project of projects) { for (const entry of await valkeyClient.listKnowledge(project)) { - const remapped = entry.sourceMemoryIds.map((sid) => idMap.get(sid) ?? sid); + const remapped = entry.sourceMemoryIds.map( + (sid) => idMap.get(sid) ?? sid, + ); if (remapped.some((sid, i) => sid !== entry.sourceMemoryIds[i])) { - await valkeyClient.storeKnowledge({ ...entry, sourceMemoryIds: remapped }); + await valkeyClient.storeKnowledge({ + ...entry, + sourceMemoryIds: remapped, + }); remappedKnowledge++; } } } if (remappedKnowledge > 0) { - console.log(`Re-pointed ${remappedKnowledge} knowledge entries to new memory ids.`); + console.log( + `Re-pointed ${remappedKnowledge} knowledge entries to new memory ids.`, + ); } // Verify before dropping the legacy index: the store must have grown by the @@ -649,14 +793,20 @@ async function runMigrate(apply: boolean) { // memories would satisfy even if rows failed to copy). const afterCount = (await store.listMemories()).length; const grew = afterCount - beforeCount; - console.log(`\nMigrated: ${migrated}, failed: ${failed}, store grew by ${grew} (now ${afterCount}).`); + console.log( + `\nMigrated: ${migrated}, failed: ${failed}, store grew by ${grew} (now ${afterCount}).`, + ); if (migrated > 0 && grew >= migrated) { await valkeyClient.dropIndex(); console.log("Verified — dropped the legacy index (betterdb-memory-index)."); - console.log("Legacy hashes (betterdb:memory:*) remain; delete them manually when ready."); + console.log( + "Legacy hashes (betterdb:memory:*) remain; delete them manually when ready.", + ); } else { - console.log("Count mismatch — left the legacy index in place. Re-run after investigating."); + console.log( + "Count mismatch — left the legacy index in place. Re-run after investigating.", + ); } await store.close(); @@ -668,7 +818,9 @@ async function runMigrate(apply: boolean) { // --------------------------------------------------------------------------- async function runIngestClaudeMd(pathArg?: string) { - console.log("BetterDB Memory for Claude Code — Ingest markdown memory file\n"); + console.log( + "BetterDB Memory for Claude Code — Ingest markdown memory file\n", + ); const candidates = pathArg ? [pathArg] @@ -680,7 +832,9 @@ async function runIngestClaudeMd(pathArg?: string) { const filePath = candidates.find((p) => existsSync(p)); if (!filePath) { - console.error(`No memory file found. Looked in:\n ${candidates.join("\n ")}`); + console.error( + `No memory file found. Looked in:\n ${candidates.join("\n ")}`, + ); process.exit(1); } console.log(`Reading ${filePath}`); @@ -737,7 +891,9 @@ async function runIngestClaudeMd(pathArg?: string) { stored++; } - console.log(`\nIngested ${stored} chunks from ${filePath} into project "${project}".`); + console.log( + `\nIngested ${stored} chunks from ${filePath} into project "${project}".`, + ); await store.close(); await valkeyClient.quit(); } diff --git a/src/memory/aging.ts b/src/memory/aging.ts index a2e4d7e..5eeaa01 100644 --- a/src/memory/aging.ts +++ b/src/memory/aging.ts @@ -8,6 +8,7 @@ import { } from "../client/memory-store.js"; import type { EpisodicMemory } from "./schema.js"; import { computeInitialImportance } from "./capture.js"; +import { isEmptySummary } from "./summary-guard.js"; // --- Aging Pipeline --- // @@ -85,9 +86,7 @@ export class AgingPipeline { // --- Distillation --- - async runDistillation( - project: string, - ): Promise<{ distilled: number }> { + async runDistillation(project: string): Promise<{ distilled: number }> { const memories = await this.store.listMemories(project, 0.5); if (memories.length < config.memory.distillMinSessions) { @@ -133,13 +132,25 @@ export class AgingPipeline { // --- Ingest Queue Processing --- - async processIngestQueue(): Promise<{ processed: number }> { + async processIngestQueue(): Promise<{ processed: number; skipped: number }> { const items = await this.valkeyClient.popIngestQueue(20); let processed = 0; + let skipped = 0; for (const item of items) { try { const summary = await this.modelClient.summarize(item.transcript); + + // Dropped, not re-queued: a transcript the model cannot summarize + // would loop forever. + if (isEmptySummary(summary)) { + console.error( + "[betterdb] Summarizer returned no content — dropping transcript instead of storing an empty memory", + ); + skipped++; + continue; + } + const importance = computeInitialImportance(summary); const meta = item.meta as Record; @@ -159,15 +170,12 @@ export class AgingPipeline { } catch (err) { console.error("[betterdb] Failed to process queued transcript:", err); // Re-queue on failure - await this.valkeyClient.pushIngestQueue( - item.transcript, - item.meta, - ); + await this.valkeyClient.pushIngestQueue(item.transcript, item.meta); break; } } - return { processed }; + return { processed, skipped }; } // --- Full Pipeline --- @@ -175,12 +183,12 @@ export class AgingPipeline { async runFullPipeline(project?: string): Promise { console.error("[betterdb] Starting aging pipeline..."); - const { processed: ingested } = await this.processIngestQueue(); - console.error(`[betterdb] Ingest queue: processed ${ingested} items`); + const { processed: ingested, skipped } = await this.processIngestQueue(); + console.error( + `[betterdb] Ingest queue: processed ${ingested} items, skipped ${skipped} empty`, + ); - const projects = project - ? [project] - : await this.allProjects(); + const projects = project ? [project] : await this.allProjects(); for (const p of projects) { const { consolidated, created, deleted } = await this.runConsolidation(p); @@ -189,7 +197,9 @@ export class AgingPipeline { ); const { distilled } = await this.runDistillation(p); - console.error(`[betterdb] Distillation (${p}): distilled ${distilled} entries`); + console.error( + `[betterdb] Distillation (${p}): distilled ${distilled} entries`, + ); } await this.valkeyClient.setLastAgingRun(new Date()); diff --git a/src/memory/schema.ts b/src/memory/schema.ts index 8162533..3e8bdf4 100644 --- a/src/memory/schema.ts +++ b/src/memory/schema.ts @@ -63,9 +63,7 @@ export const SessionSummarySchema = z.object({ .default([]), openThreads: z.array(z.string()).max(5).default([]), filesChanged: z.array(z.string()).default([]), - oneLineSummary: z - .string() - .default("Session recorded — summary unavailable"), + oneLineSummary: z.string().default("Session recorded — summary unavailable"), }); export type SessionSummary = z.infer; @@ -115,8 +113,13 @@ export const SessionStartPayload = BaseHookPayload.extend({ source: z.enum(["startup", "resume", "clear", "compact"]).optional(), }); -export const StopPayload = BaseHookPayload.extend({ - hook_event_name: z.literal("Stop"), +// SessionEnd fires once when the session terminates, unlike Stop which fires +// every time Claude finishes responding. Its payload carries transcript_path +// (verified against a live hook, not the docs — the hooks reference does not +// publish this schema). +export const SessionEndPayload = BaseHookPayload.extend({ + hook_event_name: z.literal("SessionEnd"), + reason: z.string().optional(), }); export const PreToolUsePayload = BaseHookPayload.extend({ @@ -134,13 +137,13 @@ export const PostToolUsePayload = BaseHookPayload.extend({ export const HookPayloadSchema = z.discriminatedUnion("hook_event_name", [ SessionStartPayload, - StopPayload, + SessionEndPayload, PreToolUsePayload, PostToolUsePayload, ]); export type HookPayload = z.infer; export type SessionStartHookPayload = z.infer; -export type StopHookPayload = z.infer; +export type SessionEndHookPayload = z.infer; export type PreToolUseHookPayload = z.infer; export type PostToolUseHookPayload = z.infer; diff --git a/src/memory/summary-guard.ts b/src/memory/summary-guard.ts new file mode 100644 index 0000000..ac9f14c --- /dev/null +++ b/src/memory/summary-guard.ts @@ -0,0 +1,26 @@ +import { SessionSummarySchema, type SessionSummary } from "./schema.js"; + +// Every field of SessionSummarySchema has a `.default()`, so `{}` — and any +// object the model returns that carries no recognised fields — parses +// successfully into an all-defaults husk. Validation therefore cannot tell an +// empty summary from a real one; this predicate is the only thing that can. +const DEFAULT_ONE_LINE = SessionSummarySchema.parse({}).oneLineSummary; + +export function isEmptySummary(summary: SessionSummary): boolean { + if (summary.decisions.length > 0) { + return false; + } + if (summary.patterns.length > 0) { + return false; + } + if (summary.problemsSolved.length > 0) { + return false; + } + if (summary.openThreads.length > 0) { + return false; + } + if (summary.filesChanged.length > 0) { + return false; + } + return summary.oneLineSummary.trim() === DEFAULT_ONE_LINE; +} diff --git a/tests/integration/memory-store.test.ts b/tests/integration/memory-store.test.ts index 33abda6..3e8f850 100644 --- a/tests/integration/memory-store.test.ts +++ b/tests/integration/memory-store.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test, beforeAll, afterAll } from "bun:test"; import Redis from "iovalkey"; import type { EmbedFn } from "@betterdb/agent-memory"; -import { PluginMemoryStore } from "../../src/client/memory-store.js"; +import { PluginMemoryStore, buildEmbedText } from "../../src/client/memory-store.js"; import { config } from "../../src/config.js"; import type { EpisodicMemory } from "../../src/memory/schema.js"; @@ -10,8 +10,10 @@ const SKIP = Bun.env.BETTERDB_SKIP_INTEGRATION === "true"; const EMBED_DIM = 16; // Deterministic, provider-free embedding: same text always yields the same -// vector, so a memory stored from its oneLineSummary is found by re-embedding -// that summary. Cosine distance is scale-invariant, so no normalization needed. +// vector, so a memory is found by re-embedding the exact text storeMemory +// embedded (buildEmbedText). A real model places the one-liner and the full +// summary close together; this charcode hash does not, so recall must embed +// the same text. Cosine distance is scale-invariant, so no normalization needed. const fakeEmbed: EmbedFn = async (text: string) => { const v = Array.from({ length: EMBED_DIM }, () => 0); for (let i = 0; i < text.length; i++) { @@ -47,7 +49,7 @@ describe.skipIf(SKIP)("PluginMemoryStore integration", () => { }); const recallVector = (memory: EpisodicMemory) => - fakeEmbed(memory.summary.oneLineSummary); + fakeEmbed(buildEmbedText(memory)); beforeAll(async () => { redis = new Redis(config.valkey.url, { lazyConnect: true }); diff --git a/tests/unit/aging-guard.test.ts b/tests/unit/aging-guard.test.ts new file mode 100644 index 0000000..9ef6b89 --- /dev/null +++ b/tests/unit/aging-guard.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test"; +import { AgingPipeline } from "../../src/memory/aging.js"; +import { + SessionSummarySchema, + type SessionSummary, +} from "../../src/memory/schema.js"; + +function fakeValkey(items: Array<{ transcript: string; meta: object }>) { + const requeued: string[] = []; + return { + client: { + popIngestQueue: async () => { + return items.splice(0, items.length); + }, + pushIngestQueue: async (t: string) => { + requeued.push(t); + }, + }, + requeued, + }; +} + +function fakeStore(stored: SessionSummary[]) { + return { + storeMemory: async (m: { summary: SessionSummary }) => { + stored.push(m.summary); + return "id"; + }, + }; +} + +function fakeModel(summary: SessionSummary) { + return { + summarize: async () => { + return summary; + }, + }; +} + +describe("processIngestQueue empty-summary guard", () => { + test("does not store a husk", async () => { + const husk = SessionSummarySchema.parse({}); + const stored: SessionSummary[] = []; + const vk = fakeValkey([{ transcript: "some transcript", meta: {} }]); + + const pipeline = new AgingPipeline( + vk.client as never, + fakeStore(stored) as never, + fakeModel(husk) as never, + ); + + const result = await pipeline.processIngestQueue(); + + expect(stored.length).toBe(0); + expect(result.processed).toBe(0); + expect(result.skipped).toBe(1); + }); + + test("does not re-queue a husk (no infinite loop)", async () => { + const husk = SessionSummarySchema.parse({}); + const vk = fakeValkey([{ transcript: "some transcript", meta: {} }]); + + const pipeline = new AgingPipeline( + vk.client as never, + fakeStore([]) as never, + fakeModel(husk) as never, + ); + await pipeline.processIngestQueue(); + + expect(vk.requeued.length).toBe(0); + }); + + test("stores a real summary", async () => { + const real = SessionSummarySchema.parse({ oneLineSummary: "Did a thing" }); + const stored: SessionSummary[] = []; + const vk = fakeValkey([{ transcript: "some transcript", meta: {} }]); + + const pipeline = new AgingPipeline( + vk.client as never, + fakeStore(stored) as never, + fakeModel(real) as never, + ); + const result = await pipeline.processIngestQueue(); + + expect(stored.length).toBe(1); + expect(result.processed).toBe(1); + expect(result.skipped).toBe(0); + }); +}); diff --git a/tests/unit/hook-migration.test.ts b/tests/unit/hook-migration.test.ts new file mode 100644 index 0000000..c2895fd --- /dev/null +++ b/tests/unit/hook-migration.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test"; +import { stripLegacyBetterdbHooks } from "../../src/hook-migration.js"; + +describe("stripLegacyBetterdbHooks", () => { + test("removes a betterdb Stop entry", () => { + const hooks = { + Stop: [ + { + hooks: [ + { type: "command", command: "/Users/x/.betterdb/bin/session-end" }, + ], + }, + ], + }; + const result = stripLegacyBetterdbHooks(hooks); + expect(result["Stop"]).toBeUndefined(); + }); + + test("preserves a third-party Stop hook", () => { + const hooks = { + Stop: [ + { + hooks: [ + { type: "command", command: "/Users/x/.betterdb/bin/session-end" }, + ], + }, + { hooks: [{ type: "command", command: "/other/tool/hook" }] }, + ], + }; + const result = stripLegacyBetterdbHooks(hooks); + expect(result["Stop"]).toHaveLength(1); + expect(JSON.stringify(result["Stop"])).toContain("/other/tool/hook"); + }); + + test("leaves unrelated events untouched", () => { + const hooks = { + PreCompact: [{ hooks: [{ type: "command", command: "/other/hook" }] }], + }; + const result = stripLegacyBetterdbHooks(hooks); + expect(result["PreCompact"]).toHaveLength(1); + }); + + test("is a no-op on settings with no Stop event", () => { + const hooks = { SessionStart: [{ hooks: [] }] }; + expect(stripLegacyBetterdbHooks(hooks)).toEqual(hooks); + }); + + test("strips a dev registration via an extra marker (path lacks 'betterdb')", () => { + const hooksDir = "/Users/x/dev/memory/src/hooks"; + const hooks = { + Stop: [ + { + hooks: [ + { + type: "command", + command: `bash -c 'bun run "${hooksDir}/session-end.ts"'`, + }, + ], + }, + { hooks: [{ type: "command", command: "/other/tool/hook" }] }, + ], + }; + const result = stripLegacyBetterdbHooks(hooks, ["betterdb", hooksDir]); + expect(result["Stop"]).toHaveLength(1); + expect(JSON.stringify(result["Stop"])).toContain("/other/tool/hook"); + }); + + test("ignores empty markers so they cannot match everything", () => { + const hooks = { + Stop: [{ hooks: [{ type: "command", command: "/other/tool/hook" }] }], + }; + const result = stripLegacyBetterdbHooks(hooks, [""]); + expect(result["Stop"]).toHaveLength(1); + }); + + test("does not mutate its input", () => { + const hooks = { + Stop: [ + { + hooks: [ + { type: "command", command: "/Users/x/.betterdb/bin/session-end" }, + ], + }, + ], + }; + stripLegacyBetterdbHooks(hooks); + expect(hooks["Stop"]).toHaveLength(1); + }); +}); diff --git a/tests/unit/schema.test.ts b/tests/unit/schema.test.ts index 39f1ab0..8ba0bd9 100644 --- a/tests/unit/schema.test.ts +++ b/tests/unit/schema.test.ts @@ -6,7 +6,7 @@ import { KnowledgeEntrySchema, HookPayloadSchema, SessionStartPayload, - StopPayload, + SessionEndPayload, PreToolUsePayload, PostToolUsePayload, } from "../../src/memory/schema.js"; @@ -98,7 +98,9 @@ describe("SessionSummarySchema", () => { test("accepts an optional quote on decisions and problemsSolved", () => { const result = SessionSummarySchema.parse({ decisions: [{ text: "d", status: "proposed", quote: "we could do d" }], - problemsSolved: [{ problem: "p", resolution: "r", quote: "fixed p via r" }], + problemsSolved: [ + { problem: "p", resolution: "r", quote: "fixed p via r" }, + ], }); expect(result.decisions[0]?.quote).toBe("we could do d"); expect(result.problemsSolved[0]?.quote).toBe("fixed p via r"); @@ -206,12 +208,32 @@ describe("HookPayloadSchema (discriminated union)", () => { expect(result.hook_event_name).toBe("SessionStart"); }); - test("parses Stop payload", () => { + test("parses SessionEnd payload", () => { const result = HookPayloadSchema.parse({ session_id: "s1", - hook_event_name: "Stop", + hook_event_name: "SessionEnd", }); - expect(result.hook_event_name).toBe("Stop"); + expect(result.hook_event_name).toBe("SessionEnd"); + }); + + // Fixture captured from a live Claude Code SessionEnd hook (2026-07-16), + // not written from the docs — the hooks reference does not publish this + // schema, and transcript_path is load-bearing for capture. + test("parses a real SessionEnd payload including transcript_path and reason", () => { + const result = HookPayloadSchema.parse({ + session_id: "9b5287df-f9b2-44b8-9c3d-422be5a26a46", + transcript_path: + "/Users/pd/.claude/projects/-tmp-hooktest/9b5287df-f9b2-44b8-9c3d-422be5a26a46.jsonl", + cwd: "/tmp/hooktest", + prompt_id: "ae39b092-f563-4787-a786-6254d0acb3fb", + hook_event_name: "SessionEnd", + reason: "other", + }); + expect(result.hook_event_name).toBe("SessionEnd"); + expect(result.transcript_path).toContain(".jsonl"); + if (result.hook_event_name === "SessionEnd") { + expect(result.reason).toBe("other"); + } }); test("parses PreToolUse payload", () => { diff --git a/tests/unit/summary-guard.test.ts b/tests/unit/summary-guard.test.ts new file mode 100644 index 0000000..40a8b9c --- /dev/null +++ b/tests/unit/summary-guard.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; +import { SessionSummarySchema } from "../../src/memory/schema.js"; +import { isEmptySummary } from "../../src/memory/summary-guard.js"; + +describe("isEmptySummary", () => { + test("treats an all-defaults husk as empty", () => { + const husk = SessionSummarySchema.parse({}); + expect(isEmptySummary(husk)).toBe(true); + }); + + test("treats a garbage-parsed object as empty", () => { + const husk = SessionSummarySchema.parse({ foo: "bar" }); + expect(isEmptySummary(husk)).toBe(true); + }); + + test("treats a summary with only the default one-liner as empty", () => { + const husk = SessionSummarySchema.parse({ filesChanged: [] }); + expect(isEmptySummary(husk)).toBe(true); + }); + + test("keeps a summary carrying a real one-line summary", () => { + const real = SessionSummarySchema.parse({ + oneLineSummary: "Fixed the OTel license gate", + }); + expect(isEmptySummary(real)).toBe(false); + }); + + test("keeps a summary carrying only structured detail", () => { + const real = SessionSummarySchema.parse({ + decisions: [{ text: "Switch to qwen2.5:3b", status: "done" }], + }); + expect(isEmptySummary(real)).toBe(false); + }); + + test("keeps a summary carrying only filesChanged", () => { + const real = SessionSummarySchema.parse({ + filesChanged: ["src/index.ts"], + }); + expect(isEmptySummary(real)).toBe(false); + }); +});