From eff682a71c9ad238d0db2189d8e1fda6b08b355d Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 16 Jul 2026 18:54:40 +0300 Subject: [PATCH 01/10] feat: add isEmptySummary predicate for husk detection --- src/memory/summary-guard.ts | 26 ++++++++++++++++++++ tests/unit/summary-guard.test.ts | 41 ++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 src/memory/summary-guard.ts create mode 100644 tests/unit/summary-guard.test.ts 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/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); + }); +}); From f1f22a82bf21abeba90344328c8fc7050fea1f2c Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 16 Jul 2026 18:56:14 +0300 Subject: [PATCH 02/10] fix: never store an empty summary as a memory Every SessionSummarySchema field has a default, so {} parses into a valid-looking husk. Drop those instead of storing them. --- src/memory/aging.ts | 40 +++++++++------ tests/unit/aging-guard.test.ts | 89 ++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 15 deletions(-) create mode 100644 tests/unit/aging-guard.test.ts 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/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); + }); +}); From f5845c8bc5db697abd415fc3389c6bd7272b9fea Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 16 Jul 2026 18:57:11 +0300 Subject: [PATCH 03/10] fix: remove summarization from the capture hook The hook duplicated AgingPipeline.processIngestQueue with worse error handling. It now only queues the transcript. --- src/hooks/session-end.ts | 63 +++++++++++++--------------------------- 1 file changed, 20 insertions(+), 43 deletions(-) diff --git a/src/hooks/session-end.ts b/src/hooks/session-end.ts index a13f1ea..cc46532 100644 --- a/src/hooks/session-end.ts +++ b/src/hooks/session-end.ts @@ -1,30 +1,30 @@ 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"; /** - * 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,42 +92,12 @@ 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 valkeyClient.quit(); await cleanup(eventFilePath); }); @@ -158,7 +128,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 +146,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 From eaf00f9efa0c4040ad07ea28efeaa5f2d9ecb4b6 Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 16 Jul 2026 18:59:11 +0300 Subject: [PATCH 04/10] feat: drain the ingest queue in a detached process The capture hook now returns immediately and a background drain does the summarization, so no LLM work runs inside a Claude Code hook. --- src/hooks/drain.ts | 36 ++++++ src/hooks/session-end.ts | 13 +++ src/index.ts | 247 +++++++++++++++++++++++++++++++-------- 3 files changed, 248 insertions(+), 48 deletions(-) create mode 100644 src/hooks/drain.ts 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 cc46532..d606ba0 100644 --- a/src/hooks/session-end.ts +++ b/src/hooks/session-end.ts @@ -9,6 +9,7 @@ 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"; /** * SessionEnd hook: captures the session transcript and queues it. @@ -98,6 +99,18 @@ runHook(async () => { timestamp: new Date().toISOString(), sessionId, }); + + // Detached: unref() releases it from this process's event loop so the hook + // exits immediately while summarization continues in the background. + const drainBin = join(process.env["HOME"] ?? "", ".betterdb", "bin", "drain"); + if (await Bun.file(drainBin).exists()) { + Bun.spawn([drainBin], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }).unref(); + } + await valkeyClient.quit(); await cleanup(eventFilePath); }); diff --git a/src/index.ts b/src/index.ts index 79be3c9..5565abe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,14 @@ * 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"; const VERSION = "0.5.0"; @@ -26,6 +33,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 +48,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 +80,9 @@ switch (command) { case "maintain": await runMaintain(); break; + case "drain": + await runDrain(); + break; case "forget": await runForget(process.argv.slice(3)); break; @@ -125,7 +137,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 +153,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 +164,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 +188,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 +211,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); } @@ -207,10 +236,24 @@ async function runInstall() { const existingHooks = (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") }], + }, + ], + Stop: [ + { hooks: [{ type: "command", command: join(BIN_DIR, "session-end") }] }, + ], }; settings["hooks"] = mergeHooks(existingHooks, betterdbHooks); @@ -221,7 +264,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 +289,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 +310,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 +319,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 +340,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 +387,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 +414,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 +467,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 +481,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 +501,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 +553,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 +590,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 +619,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 +642,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 +671,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 +696,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 +708,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 +751,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 +765,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 +788,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 +813,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 +827,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 +886,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(); } From 6cf6aea06bb69961908cd19aeed3a381da21bf77 Mon Sep 17 00:00:00 2001 From: jamby77 Date: Thu, 16 Jul 2026 19:02:10 +0300 Subject: [PATCH 05/10] fix: capture on SessionEnd instead of every turn Stop fires each time Claude finishes responding, so the plugin summarized the whole session on every turn. Also removes the stale Stop registration from existing installs. --- scripts/install-hooks.sh | 2 +- scripts/register-hooks.ts | 25 ++++++--- src/hook-migration.ts | 38 +++++++++++++ src/index.ts | 9 +++- src/memory/schema.ts | 17 +++--- tests/unit/hook-migration.test.ts | 89 +++++++++++++++++++++++++++++++ tests/unit/schema.test.ts | 32 +++++++++-- 7 files changed, 191 insertions(+), 21 deletions(-) create mode 100644 src/hook-migration.ts create mode 100644 tests/unit/hook-migration.test.ts diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh index 5220062..c8130b4 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); 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/index.ts b/src/index.ts index 5565abe..4ffbc16 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ import { 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"] ?? ""; @@ -234,7 +235,11 @@ 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") }] }, @@ -251,7 +256,7 @@ async function runInstall() { hooks: [{ type: "command", command: join(BIN_DIR, "post-tool") }], }, ], - Stop: [ + SessionEnd: [ { hooks: [{ type: "command", command: join(BIN_DIR, "session-end") }] }, ], }; 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/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", () => { From f08a0b192c210d1b4d13acb754aef8abc19b7083 Mon Sep 17 00:00:00 2001 From: jamby77 Date: Fri, 17 Jul 2026 10:05:30 +0300 Subject: [PATCH 06/10] fix: correct stale Stop label in install summary The hook registration was renamed to SessionEnd but the install summary still printed Stop. --- scripts/install-hooks.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh index c8130b4..64eec2b 100755 --- a/scripts/install-hooks.sh +++ b/scripts/install-hooks.sh @@ -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 "" From 47ea421e04c8699bde6badd2f13095335ca68c8e Mon Sep 17 00:00:00 2001 From: jamby77 Date: Fri, 17 Jul 2026 10:05:30 +0300 Subject: [PATCH 07/10] test: recall by the embedded text, not the one-liner storeMemory embeds buildEmbedText (the full summary); the recall helper re-embedded only oneLineSummary, so with the deterministic fake embed the stored and query vectors diverged past the 0.25 distance threshold and KNN returned nothing. Re-embed the same text so the round-trip is faithful. --- tests/integration/memory-store.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 }); From 3479443e6bcaa3396c977938d45d8d915c281094 Mon Sep 17 00:00:00 2001 From: jamby77 Date: Fri, 17 Jul 2026 10:07:48 +0300 Subject: [PATCH 08/10] chore: ignore .idea/ editor directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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/ From c3c469b2401731efad174f2b18a3cd485d96bab5 Mon Sep 17 00:00:00 2001 From: jamby77 Date: Fri, 17 Jul 2026 12:51:16 +0300 Subject: [PATCH 09/10] fix: resolve the drain path via USERPROFILE when HOME is unset Install and config already fall back to USERPROFILE; the drain spawn did not, so on Windows the path never resolved and the queue silently never drained. --- src/hooks/session-end.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/hooks/session-end.ts b/src/hooks/session-end.ts index d606ba0..f7a5efc 100644 --- a/src/hooks/session-end.ts +++ b/src/hooks/session-end.ts @@ -102,7 +102,11 @@ runHook(async () => { // Detached: unref() releases it from this process's event loop so the hook // exits immediately while summarization continues in the background. - const drainBin = join(process.env["HOME"] ?? "", ".betterdb", "bin", "drain"); + // HOME is unset on Windows, where install and config both fall back to + // USERPROFILE — without the same fallback the path never resolves and the + // drain silently never runs. + 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", From 2e3e752c08a178c7ad86000cbb59f6a04cd15756 Mon Sep 17 00:00:00 2001 From: jamby77 Date: Fri, 17 Jul 2026 15:54:25 +0300 Subject: [PATCH 10/10] fix: fall back to the drain source when no binary is installed register-hooks.ts registers hooks as `bun run ` and compiles no binaries, so ~/.betterdb/bin/drain does not exist on that path. The exists() guard then skipped the spawn silently and nothing ever drained the queue. Resolve the compiled binary first, the sibling source second, and report to stderr when neither is found. --- src/hooks/session-end.ts | 39 ++++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/src/hooks/session-end.ts b/src/hooks/session-end.ts index f7a5efc..ccf21cf 100644 --- a/src/hooks/session-end.ts +++ b/src/hooks/session-end.ts @@ -100,11 +100,24 @@ runHook(async () => { sessionId, }); - // Detached: unref() releases it from this process's event loop so the hook - // exits immediately while summarization continues in the background. + 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 — without the same fallback the path never resolves and the - // drain silently never runs. + // USERPROFILE. const home = process.env["HOME"] ?? process.env["USERPROFILE"] ?? ""; const drainBin = join(home, ".betterdb", "bin", "drain"); if (await Bun.file(drainBin).exists()) { @@ -113,11 +126,23 @@ runHook(async () => { stdout: "ignore", stderr: "ignore", }).unref(); + return; } - await valkeyClient.quit(); - await cleanup(eventFilePath); -}); + 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.