Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a855a42
feat: add nextChunk checkpoint decision function
jamby77 Jul 17, 2026
325bed1
feat: add checkpoint file read/write with atomic rename
jamby77 Jul 17, 2026
9a34e64
feat: add shared transcript line parser and byte-offset reader
jamby77 Jul 17, 2026
7e02543
test: cover final-line endByte with no trailing newline
jamby77 Jul 17, 2026
2fe703a
feat: add Stop checkpoint hook that queues without summarizing
jamby77 Jul 17, 2026
f565b71
fix: flush only the post-checkpoint tail at session end
jamby77 Jul 17, 2026
378389c
feat: register the Stop checkpoint hook in all install paths
jamby77 Jul 17, 2026
1032670
test: assert checkpoint loop queues distinct segments
jamby77 Jul 17, 2026
b732a78
test: exercise the tail-flush path in checkpoint capture
jamby77 Jul 17, 2026
1d80bc3
refactor: derive hook registration from a single typed spec
jamby77 Jul 17, 2026
1d1068e
fix: chunk the session-end tail instead of capping it once
jamby77 Jul 17, 2026
d0310ba
fix: spawn the drain when only Stop queued segments
jamby77 Jul 17, 2026
cce235b
fix: identify our hook entries by HOOK_SPECS, not a path substring
jamby77 Jul 17, 2026
ef0e300
refactor: derive install-hooks.sh registration from HOOK_SPECS
jamby77 Jul 17, 2026
d045272
fix: stop install-hooks.sh clobbering third-party hooks
jamby77 Jul 17, 2026
a1be843
Add build:hooks script derived from HOOK_SPECS
jamby77 Jul 21, 2026
5181364
fix: resolve the drain next to the running hook binary
jamby77 Jul 24, 2026
99342fb
fix: re-queue the whole unprocessed batch when the drain fails
jamby77 Jul 24, 2026
c73af27
fix: stream Ollama summaries so idle timeouts cannot kill them
jamby77 Jul 24, 2026
1867efa
fix: retry a timed-out summarize once and keep the model warm
jamby77 Jul 24, 2026
861e47e
fix: fail fast when Valkey cannot be reached
jamby77 Jul 24, 2026
7608ebc
feat: register hooks with explicit timeouts
jamby77 Jul 24, 2026
b22d3e5
feat: make Ollama keep_alive configurable, default 5m
jamby77 Jul 24, 2026
7bbe861
feat: add macOS compose file without Ollama
jamby77 Jul 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ dist/
.betterdb_context.md
.mcp.json
*.log
*.bun-build
bun.lockb
.idea/
18 changes: 18 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,24 @@ bunx @betterdb/memory install

## Setup (for development)

### macOS

**Never run Ollama in Docker on macOS.** Docker on macOS has no GPU
passthrough, so models run CPU-only inside the VM — measured 388s to first
token vs 10s native. Use the native Ollama.app and the Mac compose file,
which runs only Valkey (with AOF persistence enabled):

```bash
docker compose -f docker-compose.mac.yml up -d # Valkey only
open -a Ollama # Native Ollama on :11434
ollama pull mxbai-embed-large
ollama pull qwen2.5:3b
bun install
bun run setup-index
```

### Linux

```bash
docker compose up -d # Start Valkey (with search module) + Ollama
bun install # Install dependencies
Expand Down
22 changes: 22 additions & 0 deletions docker-compose.mac.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
services:
valkey:
image: valkey/valkey-bundle:9.1-alpine
command:
- valkey-server
- --dir
- /data
- --appendonly
- "yes"
- --save
- "60 1"
container_name: betterdb-valkey
ports:
- "6390:6379"
volumes:
- valkey-data:/data
restart: unless-stopped

volumes:
valkey-data:
name: betterdb-valkey-data
external: true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mac compose volume never created

Medium Severity

valkey-data is marked external: true, so Compose will not create betterdb-valkey-data. The new Mac setup path runs docker compose -f docker-compose.mac.yml up -d with no prior docker volume create, so first-time setup fails immediately.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7bbe861. Configure here.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"test": "bun test",
"test:unit": "bun test tests/unit",
"test:integration": "bun test tests/integration",
"build:hooks": "bun run scripts/build-hooks.ts",
"validate": "bash scripts/validate-pack.sh",
"prepublishOnly": "bash scripts/validate-pack.sh",
"setup-index": "bun run scripts/setup-index.ts",
Expand Down
57 changes: 57 additions & 0 deletions scripts/build-hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/usr/bin/env bun

/**
* Compile each hook entry point in HOOK_SPECS, plus the companion binaries
* hooks spawn at runtime, to standalone binaries.
*
* Usage:
* bun run scripts/build-hooks.ts
*
* Sources live in src/hooks/<spec.source>; binaries are written to
* dist/hooks/<spec.binary>, the paths install-hooks.sh registers.
*/

import { mkdirSync, readdirSync, rmSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { COMPANION_BINARIES, HOOK_SPECS } from "../src/hook-spec.js";

const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const hooksDir = join(projectRoot, "src", "hooks");
const outDir = join(projectRoot, "dist", "hooks");

const OPTIONAL_PROVIDERS = ["openai"];

mkdirSync(outDir, { recursive: true });

const specs = [...HOOK_SPECS, ...COMPANION_BINARIES];

for (const spec of specs) {
const source = join(hooksDir, spec.source);
const outfile = join(outDir, spec.binary);
console.log(`Compiling ${spec.source} → dist/hooks/${spec.binary}`);
const result = Bun.spawnSync(
[
"bun",
"build",
"--compile",
...OPTIONAL_PROVIDERS.flatMap((pkg) => ["--external", pkg]),
source,
"--outfile",
outfile,
],
{ stdout: "inherit", stderr: "inherit" },
);
if (!result.success) {
console.error(`ERROR: failed to compile ${spec.source}`);
process.exit(1);
}
}

for (const name of readdirSync(projectRoot)) {
if (name.endsWith(".bun-build")) {
rmSync(join(projectRoot, name), { force: true });
}
}

console.log(`Compiled ${specs.length} binaries to dist/hooks/`);
39 changes: 26 additions & 13 deletions scripts/install-hooks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,16 @@ if [ ! -f "$GLOBAL_SETTINGS" ]; then
echo '{}' > "$GLOBAL_SETTINGS"
fi

# Merge hooks into existing settings using Bun (preserves other fields, overwrites hooks block)
# Merge hooks into existing settings using Bun — replaces our own entries per
# event and preserves every other field, including third-party hooks
# Each hook command sources the .env file first so compiled binaries get the right env vars
# (bun build --compile binaries don't auto-load .env like `bun run` does)
DIST_DIR="$PROJECT_DIR/dist/hooks"
ENV_FILE="$PROJECT_DIR/.env"

bun -e "
const fs = require('fs');
const { buildHookMap, isOwnHookEntry } = require('$PROJECT_DIR/src/hook-spec.ts');
const settingsPath = '$GLOBAL_SETTINGS';
const envFile = '$ENV_FILE';
const distDir = '$DIST_DIR';
Expand All @@ -59,12 +61,12 @@ const existing = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
const wrap = (bin) =>
'bash -c ' + JSON.stringify('set -a; [ -f ' + envFile + ' ] && . ' + envFile + '; set +a; ' + distDir + '/' + bin);

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') }] }],
SessionEnd: [{ hooks: [{ type: 'command', command: wrap('session-end') }] }],
};
const hooks = { ...(existing.hooks || {}) };
for (const [event, entries] of Object.entries(buildHookMap((spec) => wrap(spec.binary)))) {
const prev = Array.isArray(hooks[event]) ? hooks[event] : [];
const kept = prev.filter((entry) => !isOwnHookEntry(entry, [distDir, 'betterdb']));
hooks[event] = [...kept, ...entries];
}
fs.writeFileSync(settingsPath, JSON.stringify({ ...existing, hooks }, null, 2));
console.log('Hook configuration written to: ' + settingsPath);
"
Expand All @@ -80,20 +82,31 @@ echo ""
echo "Verifying global settings..."
bun -e "
const fs = require('fs');
const { HOOK_SPECS } = require('$PROJECT_DIR/src/hook-spec.ts');
const settings = JSON.parse(fs.readFileSync('$HOME/.claude/settings.json', 'utf8'));
const hookCount = Object.keys(settings.hooks || {}).length;
console.log('Hooks registered: ' + hookCount + ' lifecycle events');
const missing = HOOK_SPECS.filter((spec) => {
const entries = (settings.hooks || {})[spec.event] || [];
return !JSON.stringify(entries).includes(spec.binary);
});
console.log('Hooks registered: ' + (HOOK_SPECS.length - missing.length) + '/' + HOOK_SPECS.length + ' lifecycle events');
if (missing.length > 0) {
console.error('ERROR: not registered: ' + missing.map((spec) => spec.event).join(', '));
process.exit(1);
}
"

# Summary
echo ""
echo "=== Installation Complete ==="
echo ""
echo "Hooks written to: ~/.claude/settings.json"
echo " SessionStart → $DIST_DIR/session-start"
echo " SessionEnd → $DIST_DIR/session-end"
echo " PreToolUse → $DIST_DIR/pre-tool"
echo " PostToolUse → $DIST_DIR/post-tool"
bun -e "
const { formatHookSummary } = require('$PROJECT_DIR/src/hook-spec.ts');
const distDir = '$DIST_DIR';
for (const line of formatHookSummary((spec) => distDir + '/' + spec.binary)) {
console.log(line);
}
"
echo ""
echo "MCP server: betterdb-memory (stdio)"
echo ""
Expand Down
58 changes: 20 additions & 38 deletions scripts/register-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@

import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join, resolve } from "node:path";
import { stripLegacyBetterdbHooks } from "../src/hook-migration.js";
import {
HOOK_SPECS,
buildHookMap,
formatHookSummary,
isOwnHookEntry,
type HookSpec,
} from "../src/hook-spec.js";

const pluginRoot = process.argv[2];
if (!pluginRoot) {
Expand All @@ -24,15 +30,11 @@ 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",
];
for (const file of hookFiles) {
if (!existsSync(join(hooksDir, file))) {
console.error(`ERROR: Hook source not found: ${join(hooksDir, file)}`);
for (const spec of HOOK_SPECS) {
if (!existsSync(join(hooksDir, spec.source))) {
console.error(
`ERROR: Hook source not found: ${join(hooksDir, spec.source)}`,
);
process.exit(1);
}
}
Expand Down Expand Up @@ -60,37 +62,18 @@ if (existsSync(settingsPath)) {
}
}

function cmd(hookFile: string): string {
return `bash -c 'bun run "${join(hooksDir, hookFile)}"'`;
function cmd(spec: HookSpec): string {
return `bash -c 'bun run "${join(hooksDir, spec.source)}"'`;
}

// 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") }] },
],
PreToolUse: [
{ matcher: "", hooks: [{ type: "command", command: cmd("pre-tool.ts") }] },
],
PostToolUse: [
{ matcher: "", hooks: [{ type: "command", command: cmd("post-tool.ts") }] },
],
SessionEnd: [
{ hooks: [{ type: "command", command: cmd("session-end.ts") }] },
],
};
const existingHooks = (settings["hooks"] ?? {}) as Record<string, unknown[]>;
const betterdbHooks = buildHookMap(cmd);

for (const [event, entries] of Object.entries(betterdbHooks)) {
const prev = Array.isArray(existingHooks[event]) ? existingHooks[event] : [];
const filtered = prev.filter((entry) => {
const json = JSON.stringify(entry);
return !json.includes("betterdb") && !json.includes(hooksDir);
return !isOwnHookEntry(entry, [hooksDir, "betterdb"]);
});
existingHooks[event] = [...filtered, ...entries];
}
Expand All @@ -99,9 +82,8 @@ settings["hooks"] = existingHooks;
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");

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(" SessionEnd → session-end.ts");
for (const line of formatHookSummary()) {
console.log(line);
}
console.log(`\n Plugin root: ${resolvedRoot}`);
console.log("\n Restart Claude Code for hooks to take effect.");
72 changes: 61 additions & 11 deletions src/client/providers/ollama.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,32 @@ import { SessionSummarySchema, type SessionSummary } from "../../memory/schema.j
import type { ModelClient, ModelPreset } from "../model.js";
import { buildSummarizePrompt, stripCodeFences } from "./_prompt.js";

export interface OllamaTransport {
chat(request: {
model: string;
messages: { role: string; content: string }[];
format: string;
stream: true;
keep_alive: string;
}): Promise<AsyncIterable<{ message: { content: string } }>>;
embed(request: {
model: string;
input: string;
}): Promise<{ embeddings: number[][] }>;
}

export class OllamaModelClient implements ModelClient {
private ollama: Ollama;
private ollama: OllamaTransport;
readonly preset: ModelPreset;
readonly embedDim: number;

constructor(preset: ModelPreset, ollamaUrl?: string) {
this.ollama = new Ollama({ host: ollamaUrl ?? config.ollama.url });
constructor(
preset: ModelPreset,
ollamaUrl?: string,
transport?: OllamaTransport,
) {
this.ollama =
transport ?? new Ollama({ host: ollamaUrl ?? config.ollama.url });
this.preset = preset;
this.embedDim = preset.embedDim;
}
Expand All @@ -27,17 +46,30 @@ export class OllamaModelClient implements ModelClient {
return first;
}

/**
* Streamed rather than awaited whole: Bun's fetch enforces an idle timeout,
* and a non-streaming generate sends no bytes until the entire summary is
* done — long generations died as TimeoutError. Chunks keep the connection
* alive for as long as the model keeps producing.
*
* A cold model load is silent for longer than the idle timeout allows, but
* the load keeps going server-side after the client gives up — so one
* timed-out attempt is retried against the by-then warm model, and
* keep_alive holds the model in memory between calls.
*/
async summarize(transcript: string): Promise<SessionSummary> {
const response = await this.ollama.chat({
model: this.preset.summarizeModel,
messages: [
{ role: "user", content: buildSummarizePrompt(transcript) },
],
format: "json",
});
let content: string;
try {
content = await this.chatSummary(transcript);
} catch (err) {
if (!(err instanceof DOMException && err.name === "TimeoutError")) {
throw err;
}
content = await this.chatSummary(transcript);
}

const parsed = SessionSummarySchema.safeParse(
JSON.parse(stripCodeFences(response.message.content)),
JSON.parse(stripCodeFences(content)),
);

if (!parsed.success) {
Expand All @@ -50,4 +82,22 @@ export class OllamaModelClient implements ModelClient {

return parsed.data;
}

private async chatSummary(transcript: string): Promise<string> {
const stream = await this.ollama.chat({
model: this.preset.summarizeModel,
messages: [
{ role: "user", content: buildSummarizePrompt(transcript) },
],
format: "json",
stream: true,
keep_alive: config.ollama.keepAlive,
});

let content = "";
for await (const chunk of stream) {
content += chunk.message.content;
}
return content;
}
}
Loading