Back to projects
Active Started Sep 2025

Bookmark

Context snapshots for Claude Code. Restores session state across compactions, terminal closures, and restarts with zero manual steps.

Node.js TypeScript Claude Code Hooks Model Context Protocol Commander.js

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/&lt;encoded-cwd&gt;/*.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? (&gt;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:

ComponentChoiceWhy
CLI frameworkCommander.jsSmall, dependency-light command parser for the bookmark binary’s ~15 subcommands
RuntimeNode.js ≥20, TypeScript, compiled to dist/ via tscnpm-distributable; matches the Claude Code plugin/hook execution environment
StorageFlat 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 integrationClaude Code hooks (Stop, PreCompact, SessionStart, UserPromptSubmit), command-type, defined in hooks/hooks.jsonThe only mechanism Claude Code exposes for code to run at those lifecycle points and to feed content back into context via systemMessage
MCP serverHand-rolled JSON-RPC 2.0 over stdio (src/mcp/server.ts), no MCP SDK dependencyExposes 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
Distributionnpm 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
TestingVitestMatches 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

  1. Hook fires. Stop (session end), PreCompact (before transcript compression), or UserPromptSubmit (every user message, async) invokes the bookmark CLI as a plain command-type hook — a child process, no stdin piping for stop/precompact. SessionStart invokes bookmark restore.
  2. Transcript discovery. discoverTranscriptPath() locates the session’s JSONL under ~/.claude/projects/<encoded-cwd>/, preferring the file matching CLAUDE_SESSION_ID, else the most recent substantial (>10KB) .jsonl.
  3. Deterministic extraction. parseTranscript() stream-parses the JSONL into typed entries (user/assistant/tool_use/tool_result), pulling tool_use/tool_result blocks out of nested message.content arrays. extractFilesAndTools() then walks those entries for Write/Edit calls 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 Bar from 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.”
  4. 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, via compressToMarkdown), 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.
  5. LLM-authored narrative. The same stop/precompact invocation checks .bookmark/bookmark.context.md for freshness (>200 bytes, contains a markdown ## header, newer than any prior block marker). If stale, stop returns {"decision":"block","systemMessage":"..."} telling the already-running Claude Code session to write task/progress/decisions/branch/files to bookmark.context.md via its own Write tool — capped at one block per session via a .stop-requested marker so it can’t loop. precompact does 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.
  6. Restore. SessionStart runs bookmark restore, which reads bookmark.context.md, parses an optional BOOKMARK_IDENTITY header (scope, project, repo_path, branch, head). A scope: home pointer (written at ~/.bookmark/bookmark.context.md when a session ends) is followed to the real repo-scoped file so a session started from ~ still restores the right project. A repo_path mismatch 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.
  7. Fallback. If bookmark.context.md is missing or fails a content-usefulness check, restore falls back to LATEST.md — the deterministic file-trail snapshot from step 4 — so a session that never got a narrative write still recovers something.
  8. 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.