Skip to content
Open
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ dist/
.mcp.json
*.log
bun.lockb
.idea/
4 changes: 2 additions & 2 deletions scripts/install-hooks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 ""
Expand Down
25 changes: 19 additions & 6 deletions scripts/register-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)}`);
Expand All @@ -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}`);
Expand All @@ -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<string, unknown[]>;
// 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<string, unknown[]>,
["betterdb", hooksDir],
);
const betterdbHooks: Record<string, unknown[]> = {
SessionStart: [
{ hooks: [{ type: "command", command: cmd("session-start.ts") }] },
Expand All @@ -68,7 +81,7 @@ const betterdbHooks: Record<string, unknown[]> = {
PostToolUse: [
{ matcher: "", hooks: [{ type: "command", command: cmd("post-tool.ts") }] },
],
Stop: [
SessionEnd: [
{ hooks: [{ type: "command", command: cmd("session-end.ts") }] },
],
};
Expand All @@ -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.");
38 changes: 38 additions & 0 deletions src/hook-migration.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown[]>,
markers: string[] = ["betterdb"],
): Record<string, unknown[]> {
const out: Record<string, unknown[]> = { ...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;
}
36 changes: 36 additions & 0 deletions src/hooks/drain.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
});
105 changes: 62 additions & 43 deletions src/hooks/session-end.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 <src>` 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<void> {
// 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();
Comment thread
cursor[bot] marked this conversation as resolved.
return;
}
Comment thread
cursor[bot] marked this conversation as resolved.

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.
Expand All @@ -158,7 +170,11 @@ async function parseTranscriptTurns(path: string): Promise<TranscriptTurn[]> {
.join("\n")
: "";
// Skip system-generated messages (commands, caveats)
if (content && !content.includes("<local-command") && !content.includes("<command-name>")) {
if (
content &&
!content.includes("<local-command") &&
!content.includes("<command-name>")
) {
turns.push({ role: "user", text: `User: ${content}` });
}
} else if (entry.type === "assistant" && entry.message?.content) {
Expand All @@ -172,7 +188,10 @@ async function parseTranscriptTurns(path: string): Promise<TranscriptTurn[]> {
.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
Expand Down
Loading