Skip to content

Add checkpoint capture: whole-session segmented memories#9

Open
jamby77 wants to merge 15 commits into
fix/summarizer-hot-pathfrom
feat/checkpoint-capture
Open

Add checkpoint capture: whole-session segmented memories#9
jamby77 wants to merge 15 commits into
fix/summarizer-hot-pathfrom
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: each install path kept its 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 four 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). index.ts and register-hooks.ts derive their settings map, hook count, summary listing, and — in index.ts — the hook entries of BINARIES from HOOK_SPECS. isOwnHookEntry replaces substring matching on "betterdb"/BIN_DIR when identifying our own entries to replace.

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).


Note

Medium Risk
Changes the memory capture path and mutates ~/.claude/settings.json hook merges, but behavior is heavily unit/integration tested and hook replacement is intentionally conservative to avoid deleting third-party hooks.

Overview
Long sessions no longer collapse into a single 8K-char memory. A new Stop hook (stop-checkpoint) incrementally queues ~8K transcript segments to Valkey (no LLM work) and advances a per-session /tmp checkpoint (byteOffset + segment). SessionEnd resumes from that offset, uses planTailSegments to chunk any remainder like Stop would (instead of one selectTranscript cap), tags each queue item with segment, spawns the drain when Stop already queued work, and removes the checkpoint on cleanup.

Shared src/memory/checkpoint.ts adds nextChunk, byte-offset parseTurnsFrom, and checkpoint I/O; transcript parsing moves to parseTranscriptLine in transcript.ts.

Hook registration is centralized in hook-spec.ts (HOOK_SPECS, buildHookMap, isOwnHookEntry, formatHookSummary) and wired through index.ts, register-hooks.ts, and install-hooks.sh. hook-migration.ts and its tests are removed; legacy Stop entries are replaced via isOwnHookEntry (source filenames + install markers) while preserving third-party hooks.

Reviewed by Cursor Bugbot for commit d045272. 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 added 12 commits July 17, 2026 15:54
- 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.
@jamby77
jamby77 force-pushed the feat/checkpoint-capture branch from 49dac04 to d0310ba Compare July 17, 2026 12:56

@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.

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 d0310ba. Configure here.

await writeCheckpoint(sessionId, {
byteOffset: result.endByte,
segment: checkpoint.segment + 1,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Duplicate queue on checkpoint failure

Medium Severity

The Stop hook pushes a segment to Valkey before advancing the on-disk checkpoint. If writeCheckpoint fails after a successful pushIngestQueue, the checkpoint byte offset and segment counter stay unchanged, so the next Stop run re-enqueues the same transcript chunk under the same segment index.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d0310ba. Configure here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Acknowledged, but working as intended — not fixing.

The push-before-checkpoint ordering is deliberate. It yields at-least-once: if writeCheckpoint fails after a successful pushIngestQueue, the next Stop re-enqueues the same chunk, and the worst case is one duplicate near-identical memory. Reversing it to checkpoint-then-push would yield at-most-once: a failed push after an advanced checkpoint would lose that segment permanently, with nothing to detect it.

This plugin exists precisely to stop silently losing session content — the bug it was written to fix stored empty husks on the success path for eight days. Given that, duplicating beats dropping, so the current order is the safe one.

Scope of the window is also narrow: it requires the /tmp temp-file rename to fail after a successful RPUSH. The checkpoint write is already atomic (temp + rename), runHook exits 0 either way, and the drain's isEmptySummary guard independently drops anything that summarizes to nothing.

Noting for the record that this is the third independent review to raise it, which says the intent isn't legible enough in the code — that's a fair signal even though the behavior stands.

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.
@jamby77
jamby77 requested a review from KIvanow July 17, 2026 14:03
jamby77 added 2 commits July 17, 2026 17:08
- 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.
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.

1 participant