Skip to content

Add checkpoint capture: whole-session segmented memories - #9

Merged
jamby77 merged 30 commits into
masterfrom
feat/checkpoint-capture
Jul 27, 2026
Merged

Add checkpoint capture: whole-session segmented memories#9
jamby77 merged 30 commits into
masterfrom
feat/checkpoint-capture

Conversation

@jamby77

@jamby77 jamby77 commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Problem

After #8 moved capture to SessionEnd, a latent cap surfaced: selectTranscript keeps only 8,000 chars, so a long session now captures ~5% of itself into a single memory. Distinct topics (PR review, debugging, a design discussion) collapse into one truncated summary, which also hurts recall — near-duplicate/bunched content always scores "low" at the confidence gate.

Change

A new Stop hook checkpoints the transcript incrementally: every ~8,000 chars it queues one segment to the existing ingest queue and advances a /tmp checkpoint file — doing zero model work. SessionEnd flushes only the post-checkpoint tail and spawns the existing detached drain. A long session becomes many distinct, sequential segments instead of one husk. The drain is unchanged (Track 1's empty-summary guard still applies); session consolidation and provider-aware thresholds are deliberately deferred.

Implementation (7 TDD tasks)

  • nextChunk — pure decision (in turns, resuming in bytes), table-tested with no fs/Valkey/model.
  • Checkpoint file I/O — atomic temp+rename; missing/corrupt → zero checkpoint.
  • Shared parseTranscriptLine + byte-offset parseTurnsFrom (replaces the private parser in session-end, de-duplicated).
  • The Stop hook — stat-gate → parse new bytes → nextChunk → queue + advance to endByte (never EOF).
  • session-end flushes only the tail from the checkpoint offset, tags segment, cleans up.
  • Registration across index.ts, register-hooks.ts, install-hooks.sh.
  • Integration test — a seeded transcript yields sequential segments [0,1,2,…] + a tail against real Valkey.

Install-path refactor

Registering a fifth hook surfaced drift the branch inherited: all three install paths kept their own copy of the hook map, next to a literal Registered 5 hooks count and a hand-aligned summary listing — so adding a hook meant remembering several places.

src/hook-spec.ts is now the single typed source of truth (HookEvent/HookSpec/HookEntry/HookCommand — no more Record<string, unknown[]> and no casts). All three paths derive from HOOK_SPECS: index.ts and register-hooks.ts their settings map, hook count and summary listing (plus, in index.ts, the hook entries of BINARIES), and install-hooks.sh via requireing the module from its embedded Bun snippets. isOwnHookEntry replaces substring matching on "betterdb"/BIN_DIR when identifying our own entries to replace.

install-hooks.sh also gains two fixes. It wrote { ...existing, hooks }, replacing the entire hooks block — installing silently deleted every hook the plugin did not write, third-party entries on unregistered events included. It now merges per event and drops only its own entries, like the other two paths, and reinstalling stays idempotent. Its verification step counted Object.keys(settings.hooks), which reported success even with a hook missing; it now checks each spec's binary is present and exits non-zero naming what is absent.

The hook-migration strip is deleted (module + 89-line test file). It only ever covered Stop, and Stop is registered again, so both install paths' merges already replace the legacy session-end entry before the strip could. Verified rather than assumed: replaying a v0.4.x settings.json (legacy session-end on Stop, a third-party Stop hook, an unrelated PreCompact hook) through both paths with and without the strip produces byte-identical output — stale entry gone, third-party hooks and unrelated keys preserved.

Review & verification

Each task passed an independent spec+quality review (three needed a fix loop: a plan off-by-one, a missing byte-offset test, a dead tail-flush path — all fixed). A whole-branch review verified all four load-bearing invariants end-to-end — no model work on hooks, checkpoint advances to endByte not EOF, segment continuity across Stop firings + SessionEnd, tail parses from the offset — verdict ready to merge.

bun run typecheck clean · bun test tests/unit 164/164 · bun test tests/integration 0 fail (checkpoint test runs unskipped).

Build script

bun run build:hooks was referenced by CLAUDE.md and install-hooks.sh but not defined in package.json — the documented installer was broken independent of this branch. It is now scripts/build-hooks.ts, which compiles each hook entry point to dist/hooks/ and derives its build list from HOOK_SPECS, so a new hook needs no separate build wiring. The optional provider openai (an undeclared dynamic import) is marked --external so --compile does not fail on it. Verified: all five binaries compile and exit 0 on the empty-stdin hook contract, and the working tree stays clean (*.bun-build temp files are swept and ignored).

Stack

Top of a 2-PR stack, based on #8. Its diff is scoped to the checkpoint work only; merge #8 first.


Note

Medium Risk
Changes the core session-capture and Claude Code hook registration paths (including ~/.claude/settings.json), but behavior is heavily unit/integration tested and failures are designed to fail fast or re-queue rather than drop data silently.

Overview
Fixes long-session capture where a single ~8k selectTranscript cap dropped most of the conversation. A new Stop hook (stop-checkpoint) queues ~8k transcript segments to Valkey with no LLM work, tracking byte offset and segment index in /tmp checkpoint files. SessionEnd resumes from that offset, chunks the remainder like Stop (planTailSegments), flushes numbered segments, and still spawns the detached drainer when Stop already queued work.

src/hook-spec.ts becomes the single source for hook events (including Stop), per-hook timeouts, buildHookMap, and conservative isOwnHookEntry replacement across CLI install, register-hooks.ts, and install-hooks.sh (which now merges per event instead of overwriting the whole hooks block). Adds build:hooks / companion drain binary compile, locateDrainCommand, and flushSegments for partial-failure safety.

Ingest reliability: drainIngestQueue loops until empty; failed pops re-queue the full unprocessed batch. Valkey gets deadline-bounded connectValkey and shorter hook retries. Ollama summarization streams with keep_alive and one retry on cold-load timeout.

Docs add macOS dev guidance (docker-compose.mac.yml, native Ollama); extensive unit/integration tests cover checkpoint I/O, chunking, hook identity, and drain behavior.

Reviewed by Cursor Bugbot for commit 707fd81. Bugbot is set up for automated code reviews on this repo. Configure here.

@jamby77 jamby77 changed the title feat/checkpoint capture Add checkpoint capture: whole-session segmented memories Jul 17, 2026
Comment thread src/hooks/session-end.ts
Comment thread src/hooks/session-end.ts Outdated
@jamby77
jamby77 marked this pull request as ready for review July 17, 2026 08:42
@jamby77
jamby77 force-pushed the feat/checkpoint-capture branch from 0f57666 to 49dac04 Compare July 17, 2026 09:53
Comment thread src/hooks/session-end.ts
Comment thread src/hooks/session-end.ts
@jamby77
jamby77 force-pushed the feat/checkpoint-capture branch from 49dac04 to d0310ba Compare July 17, 2026 12:56
Comment thread src/hooks/stop-checkpoint.ts
@jamby77
jamby77 requested a review from KIvanow July 17, 2026 14:03
Comment thread src/hooks/session-end.ts
Comment thread src/hook-spec.ts

@KIvanow KIvanow left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approve. Builds cleanly on #8 (merge #8 first). The checkpoint design is careful — byte-offset checkpoints with atomic temp+rename, multi-byte-safe slicing at newline boundaries, segment continuity across StopSessionEnd, and dedup gating on the event-file fallback. Good test coverage, and the install-path refactor to a single HOOK_SPECS source of truth is a nice cleanup.

Verified locally on this branch (contains #8): bun run typecheck clean · bun test tests/unit 164/164 · build:hooks compiles the hook binaries and sweeps its temp files.

Two low-severity hook-matching edge cases, neither blocking:

  • src/hook-spec.ts isOwnHookEntry (source-name match) — the doc comment calls this "deliberately conservative — a false positive deletes a third party's hook," but matching on generic source filenames like pre-tool.ts / post-tool.ts is arguably the opposite: a third-party hook command that happens to reference such a path would be silently deleted on install. Consider requiring a path marker and a source-name match, or namespacing the filenames (e.g. betterdb-post-tool.ts).

  • src/hook-spec.ts isOwnHookEntry (cross-install-path gap) — installed-binary entries are recognized only via the caller's markers (BIN_DIR/distDir/"betterdb"), because the binary command is .../session-end and does not contain session-end.ts. So an entry written by install-hooks.sh (binaries under a project path with no "betterdb" in it) won't be recognized by a later index.ts CLI install (marker ~/.betterdb/bin) — it survives the merge and you get two hooks on the same event. Edge case (mixing install mechanisms), but eliminating exactly this drift was the goal of the refactor. Worth a test covering the install-path → CLI-install upgrade.

Also see the note left on #8 about processIngestQueue dropping already-popped items on a mid-batch failure — pre-existing, but segmented capture here makes it materially more likely to lose a session's later segments.

Comment thread src/memory/aging.ts
Comment thread docker-compose.mac.yml Outdated
Comment thread src/hooks/session-end.ts
Base automatically changed from fix/summarizer-hot-path to master July 27, 2026 08:49
jamby77 added 16 commits July 27, 2026 11:49
- Add src/hook-spec.ts as the sole source of truth for the five
  lifecycle hooks (event, source file, binary, matcher)
- Derive hook counts, the settings map, the summary listing, and the
  hook entries of BINARIES from HOOK_SPECS
- Replace Record<string, unknown[]> hook maps with typed HookEntry
- Drop stripLegacyBetterdbHooks: Stop is registered again, so both
  install paths already replace the legacy session-end entry on merge
A session whose Stop hook never checkpointed (fresh install before a
restart, Valkey unreachable) arrives at session end whole. A single 8K
selectTranscript cap silently discarded everything past it, reinstating
the truncation checkpoint capture exists to remove. planTailSegments now
chunks the tail exactly as Stop does and selects down only the final
sub-threshold remainder.

Also gate the event-file fallback on segment 0: after a checkpoint an
empty tail means the transcript is already queued, so re-queueing the
whole event file duplicated earlier segments.
A fully-checkpointed session leaves an empty tail, so session-end took
the nothing-to-store path and returned before spawning the drainer. Stop
enqueues segments but never drains, so everything it queued sat
unsummarized until a later session happened to drain it.
install matched existing entries on BIN_DIR or the literal "betterdb",
so a registration written by register-hooks.ts from a checkout not named
betterdb went unrecognized: it survived the merge and kept firing next to
the new entry. Derive the match from HOOK_SPECS' source filenames, which
hold wherever the checkout lives, and keep install paths as extra
markers. Both install paths now share one definition of ours.
- Build the settings map with buildHookMap instead of a third
  hardcoded copy of the five lifecycle hooks
- Print the summary via formatHookSummary, now parameterized so the
  shell path shows dist binary paths and the dev path source files
- Verify each spec's binary is actually registered instead of counting
  keys, which reported success even with a hook missing
Writing `{ ...existing, hooks }` replaced the whole hooks block, so
installing deleted every hook this plugin did not write — including
third-party entries on events we never register.

Merge per event and drop only our own entries, matching the behaviour
of the other two install paths. Reinstalling stays idempotent.
- Define the missing build:hooks script referenced by install-hooks.sh
  and CLAUDE.md, compiling each hook entry point to dist/hooks/
- Derive the build list from HOOK_SPECS so a new hook needs no
  separate build wiring
- Mark optional provider openai as external so --compile does not
  fail on the undeclared dynamic import
- Sweep bun build temp files and ignore *.bun-build
jamby77 added 8 commits July 27, 2026 11:49
- Compiled hooks resolved drain.ts via import.meta.dir, a bunfs virtual
  path where the source never exists, so the dev-shape install never
  drained the ingest queue
- Extract locateDrainCommand: sibling binary first, then
  ~/.betterdb/bin, then bun-running the source
- Ship drain as a companion binary from build:hooks via
  COMPANION_BINARIES, shared with the CLI install
popIngestQueue is destructive; on a summarize failure only the failed
item went back and the rest of the popped batch was lost when the
drain process exited
A non-streaming generate sends no bytes until the whole summary is
done; long generations died on Bun's fetch idle timeout as
TimeoutError. Chunks keep the connection alive while the model is
producing. Transport is injectable for tests
A cold Ollama model load is silent for longer than the client idle
timeout (measured 388s TTFB vs 3.6s warm), but the load continues
server-side after the client gives up — one retry lands on a warm
model. keep_alive=60m holds it in memory between calls
connectTimeout only bounds the TCP connect; a proxy that accepts for a
missing backend passed it and hung the ready check until the 60s hook
timeout, costing minutes per tool call. Every attempt now races a 1s
deadline; worst case is ~3.4s across retries
Backstop over the fail-fast connect: pre/post-tool get 10s so no
future hook bug can make every tool call hang for the 60s default
Native Ollama reloads in seconds, so pinning the model for an hour
trades gigabytes of RAM for nothing on memory-tight machines.
BETTERDB_OLLAMA_KEEP_ALIVE overrides
Docker on macOS has no GPU passthrough — measured 388s to first token
vs 10s native — so macOS runs only Valkey in Docker (with AOF
persistence) and uses the native Ollama.app
@jamby77
jamby77 force-pushed the feat/checkpoint-capture branch from 7bbe861 to b839055 Compare July 27, 2026 08:49
Comment thread src/client/valkey.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit ae6045e. Configure here.

Comment thread src/index.ts
@jamby77
jamby77 merged commit 6e7804b into master Jul 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants