Bookmark
Context snapshots for Claude Code. Restores session state across compactions, terminal closures, and restarts with zero manual steps.
Install
claude plugin marketplace add tyroneross/RossLabs-AI-Toolkit
claude plugin install bookmark@rosslabs-ai-toolkit
The Problem
Claude Code sessions are ephemeral. Context evaporates three ways: compaction compresses the conversation and drops detail, closing the terminal starts a blank session, and switching between projects strands yesterday’s decisions in a summary nobody reads. The recurring tax is re-explaining architecture, decisions, and open items every morning.
What I Built
Bookmark captures Claude Code session context automatically and restores it when you return, so every new terminal starts where the last one stopped.
Architecture
Bookmark splits into two tracks that run on the same four hook events, one deterministic and one LLM-authored, plus a restore path that reconciles them. A CLI (@tyroneross/bookmark, Commander.js) is the single entry point; a hand-rolled MCP server (JSON-RPC 2.0 over stdio, no SDK dependency) exposes the same five operations for MCP clients that aren’t wired through hooks.
flowchart TD
A["Stop / PreCompact hook fires"] --> B["bookmark stop/precompact (child process)"]
B --> C["discoverTranscriptPath()<br/>~/.claude/projects/<encoded-cwd>/*.jsonl"]
C --> D["parseTranscript()<br/>stream JSONL → typed entries"]
D --> E["extractFilesAndTools()<br/>regex: Write/Edit calls, file-creating Bash"]
E --> F[("snapshots/SNAP_*.json<br/>index.json · LATEST.md · trails/files.md")]
F --> G[("~/.bookmark/registry.json<br/>cross-project index")]
B --> H{".bookmark.context.md<br/>fresh? (>200B, has ## header,<br/>newer than block marker)"}
H -- stale --> I["hook returns block + systemMessage<br/>(Stop: blocks once, max 1 loop guard)<br/>(PreCompact: non-blocking nudge)"]
I --> J["active Claude Code session<br/>writes bookmark.context.md<br/>via its own Write tool — no extra API call"]
H -- fresh --> K[skip]
L["SessionStart hook fires"] --> M["bookmark restore"]
M --> N{"bookmark.context.md<br/>present + useful?"}
N -- "home-scope pointer" --> O["follow points_to_canonical<br/>to the real repo bookmark"]
N -- "repo-scope, ≥72h old" --> P["hard-block: return staleness<br/>guidance, not stale content"]
N -- "repo-scope, fresh/24-72h" --> Q["emit as systemMessage<br/>(soft warning if 24-72h,<br/>repo_path mismatch warning if CWD differs)"]
N -- missing --> R["fall back to LATEST.md<br/>(file-trail only)"]
Q --> S["Claude Code injects into<br/>new session's context"]
R --> S
O --> S
Tools & infra — which, why:
| Component | Choice | Why |
|---|---|---|
| CLI framework | Commander.js | Small, dependency-light command parser for the bookmark binary’s ~15 subcommands |
| Runtime | Node.js ≥20, TypeScript, compiled to dist/ via tsc | npm-distributable; matches the Claude Code plugin/hook execution environment |
| Storage | Flat files — JSON snapshots, .json index, .md trail files under project-local .bookmark/ | No database: single-user, single-project data, needs to be .gitignored and human-readable, not queried |
| Cross-project index | ~/.bookmark/registry.json (JSON, capped 500 entries) | Lets bookmark list --all and the home-scope pointer resolve “what was I last working on” across every project without a shared service |
| Session integration | Claude Code hooks (Stop, PreCompact, SessionStart, UserPromptSubmit), command-type, defined in hooks/hooks.json | The only mechanism Claude Code exposes for code to run at those lifecycle points and to feed content back into context via systemMessage |
| MCP server | Hand-rolled JSON-RPC 2.0 over stdio (src/mcp/server.ts), no MCP SDK dependency | Exposes snapshot/restore/status/list/show as callable tools for MCP clients, independent of the hook path; kept dependency-free to match the rest of the package |
| Distribution | npm package + Claude Code plugin marketplace (tyroneross/RossLabs-AI-Toolkit) | Two install paths: npm install @tyroneross/bookmark (hooks auto-configured via postinstall) or claude plugin install bookmark@rosslabs-ai-toolkit |
| Testing | Vitest | Matches the TypeScript/ESM toolchain; used for parser/identity unit tests |
Models — which, where, why: Bookmark itself makes zero LLM/API calls — extraction (step 3 below) is regex and JSON parsing, restore logic (step 6) is deterministic identity/staleness rules. The narrative content in bookmark.context.md (step 5) is written by whichever model is already running the host Claude Code session, at the moment that session is about to stop or compact — not a second inference call Bookmark initiates, and not billed separately. The README documents an optional “Smart Mode” that would call Claude Haiku directly (--smart, ANTHROPIC_API_KEY) for higher-quality extraction; ⚠️ needs confirmation — no haiku, ANTHROPIC_API_KEY, or Anthropic SDK reference exists anywhere in src/, and package.json lists no Anthropic dependency, so this mode does not appear implemented in the current source.
⚠️ The README’s “Adaptive Thresholds” table (snapshot-at-20%/30%/40%/50%-context-remaining, scaling with compaction count) describes logic that exists in src/threshold/adaptive.ts but is not called anywhere outside its own module — the UserPromptSubmit handler (bookmark check) only runs checkTimeInterval(), a pure elapsed-time comparison; the code comment states “token estimation removed (can’t know actual context usage).” The live trigger is time-based only (default every 5 minutes, not the README’s 20 — configurable via bookmark config --interval or BOOKMARK_INTERVAL).
How it works
- Hook fires.
Stop(session end),PreCompact(before transcript compression), orUserPromptSubmit(every user message, async) invokes thebookmarkCLI as a plain command-type hook — a child process, no stdin piping forstop/precompact.SessionStartinvokesbookmark restore. - Transcript discovery.
discoverTranscriptPath()locates the session’s JSONL under~/.claude/projects/<encoded-cwd>/, preferring the file matchingCLAUDE_SESSION_ID, else the most recent substantial (>10KB).jsonl. - Deterministic extraction.
parseTranscript()stream-parses the JSONL into typed entries (user/assistant/tool_use/tool_result), pullingtool_use/tool_resultblocks out of nestedmessage.contentarrays.extractFilesAndTools()then walks those entries forWrite/Editcalls and file-creating Bash patterns (mkdir,touch,mv,cp,cat >,tee) to build a file-activity list (path, operation, lines changed, structural hints like+fn foo/+class Barfrom diffing old/new strings against a small regex table) and a tool-usage histogram. This is the only content the deterministic path extracts — as of v0.3 the extractor’s own comments state decision/intent/status/error extraction was removed because “regex can’t do semantic extraction reliably.” - Deterministic write.
captureSnapshot()writes.bookmark/snapshots/SNAP_<timestamp>.json, updates.bookmark/index.json(capped at 50 active entries), regenerates.bookmark/LATEST.md(files + tools only, viacompressToMarkdown), merges into.bookmark/trails/files.md(a running per-file trail across snapshots), and appends to~/.bookmark/registry.json, a cross-project index capped at 500 entries. Snapshots with zero files changed are skipped (except manual triggers) to avoid empty-snapshot accumulation. - LLM-authored narrative. The same
stop/precompactinvocation checks.bookmark/bookmark.context.mdfor freshness (>200 bytes, contains a markdown##header, newer than any prior block marker). If stale,stopreturns{"decision":"block","systemMessage":"..."}telling the already-running Claude Code session to write task/progress/decisions/branch/files tobookmark.context.mdvia its ownWritetool — capped at one block per session via a.stop-requestedmarker so it can’t loop.precompactdoes the equivalent non-blockingly (systemMessage nudge only, 5-minute staleness window, never blocks compaction). No separate model call happens here — it’s the same inference pass the user is already paying for, redirected to also produce a summary. - Restore.
SessionStartrunsbookmark restore, which readsbookmark.context.md, parses an optionalBOOKMARK_IDENTITYheader (scope,project,repo_path,branch,head). Ascope: homepointer (written at~/.bookmark/bookmark.context.mdwhen a session ends) is followed to the real repo-scoped file so a session started from~still restores the right project. Arepo_pathmismatch against the current CWD prefixes a warning rather than silently injecting the wrong project’s context. Content ≥72h old is hard-blocked — the restore returns staleness guidance instead of the stale content, on the reasoning that a confident wrong start is worse than no restore; 24-72h gets a soft inline warning instead. - Fallback. If
bookmark.context.mdis missing or fails a content-usefulness check, restore falls back toLATEST.md— the deterministic file-trail snapshot from step 4 — so a session that never got a narrative write still recovers something. - Injection. The result is emitted as a hook
systemMessage, which Claude Code injects into the new session’s context automatically — no manual paste, no separate restore command.
Why This Design
Running as an external process keeps the deterministic path (file/tool tracking, restore logic) at zero tokens. The narrative path deliberately does not try to out-regex an LLM for decisions and intent — it hands that job to the model already running the session, at the moment it’s already about to stop or compact, instead of adding a second inference call. The hard 72-hour staleness block exists because a soft warning on old content reads as noise; Claude tends to treat a stale summary as current unless restoration is refused outright. The result: install it, forget about it, pick up tomorrow.
Tech stack
TypeScript on Node.js ≥20, compiled with tsc, distributed as an npm package and a Claude Code plugin. Commander.js drives the CLI. Claude Code’s four command-type hooks (Stop, PreCompact, SessionStart, UserPromptSubmit) are the only integration surface; a hand-rolled MCP server (JSON-RPC 2.0 over stdio, no SDK) exposes the same operations to other MCP clients. Storage is flat files only — JSON snapshots and index, Markdown trail/context files — under project-local .bookmark/ plus a capped cross-project registry at ~/.bookmark/registry.json; there is no database, vector store, or queue anywhere in the system. Vitest covers the parser and identity modules.
Results
Worked example: a long session approaches the compaction threshold mid-task. PreCompact fires first, deterministically snapshots which files changed and what tools ran, and nudges Claude to write a bookmark.context.md narrative if the existing one is more than 5 minutes stale. If the terminal is closed instead, Stop does the same file/tool capture and, if bookmark.context.md hasn’t been touched in the last 2 minutes, blocks exit once to force the write. Either path leaves both a file-trail snapshot and (if the write succeeded) a Claude-authored summary on disk. The next time a terminal opens in that project, SessionStart restores the most recent usable one into context automatically — task, decisions, and open items from the narrative file if present and not stale, file/tool activity from LATEST.md if not — so the new session starts from prior state instead of a blank slate.
Because the narrative write happens inside the same inference pass that’s already running (the session about to stop or compact writes its own summary), and the file-trail extraction is pure regex/JSON parsing, Bookmark itself adds no LLM cost. The only tokens that enter a new session’s context window are whatever the restored systemMessage contains.
Restore latency, snapshot size, and cross-session summary accuracy: ⚠️ no benchmark yet.