@tyroneross/bookmark 0.2.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/.claude-plugin/marketplace.json +29 -12
  2. package/.claude-plugin/plugin.json +12 -8
  3. package/.codex-plugin/plugin.json +31 -0
  4. package/.mcp.json +8 -0
  5. package/CLAUDE.md +82 -38
  6. package/LICENSE +202 -21
  7. package/README.md +150 -57
  8. package/agents/snapshot-analyst.md +1 -1
  9. package/commands/bookmark.md +29 -0
  10. package/commands/feedback.md +37 -0
  11. package/commands/restore.md +14 -4
  12. package/commands/snapshot.md +19 -7
  13. package/commands/status.md +1 -1
  14. package/dist/cli/index.js +600 -66
  15. package/dist/cli/index.js.map +1 -1
  16. package/dist/cli/snapshot-resolution.d.ts +15 -0
  17. package/dist/cli/snapshot-resolution.d.ts.map +1 -0
  18. package/dist/cli/snapshot-resolution.js +21 -0
  19. package/dist/cli/snapshot-resolution.js.map +1 -0
  20. package/dist/config.d.ts +19 -0
  21. package/dist/config.d.ts.map +1 -1
  22. package/dist/config.js +150 -19
  23. package/dist/config.js.map +1 -1
  24. package/dist/context/freshness.d.ts +3 -0
  25. package/dist/context/freshness.d.ts.map +1 -0
  26. package/dist/context/freshness.js +29 -0
  27. package/dist/context/freshness.js.map +1 -0
  28. package/dist/context/handoff-prompt.d.ts +12 -0
  29. package/dist/context/handoff-prompt.d.ts.map +1 -0
  30. package/dist/context/handoff-prompt.js +29 -0
  31. package/dist/context/handoff-prompt.js.map +1 -0
  32. package/dist/index.d.ts +7 -4
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +5 -3
  35. package/dist/index.js.map +1 -1
  36. package/dist/mcp/server.d.ts +9 -0
  37. package/dist/mcp/server.d.ts.map +1 -0
  38. package/dist/mcp/server.js +85 -0
  39. package/dist/mcp/server.js.map +1 -0
  40. package/dist/mcp/tools.d.ts +97 -0
  41. package/dist/mcp/tools.d.ts.map +1 -0
  42. package/dist/mcp/tools.js +304 -0
  43. package/dist/mcp/tools.js.map +1 -0
  44. package/dist/registry.d.ts +40 -0
  45. package/dist/registry.d.ts.map +1 -0
  46. package/dist/registry.js +134 -0
  47. package/dist/registry.js.map +1 -0
  48. package/dist/restore/index.d.ts +13 -1
  49. package/dist/restore/index.d.ts.map +1 -1
  50. package/dist/restore/index.js +154 -256
  51. package/dist/restore/index.js.map +1 -1
  52. package/dist/setup/auto-setup.d.ts +8 -2
  53. package/dist/setup/auto-setup.d.ts.map +1 -1
  54. package/dist/setup/auto-setup.js +281 -66
  55. package/dist/setup/auto-setup.js.map +1 -1
  56. package/dist/setup/configure-hooks.d.ts +14 -3
  57. package/dist/setup/configure-hooks.d.ts.map +1 -1
  58. package/dist/setup/configure-hooks.js +156 -24
  59. package/dist/setup/configure-hooks.js.map +1 -1
  60. package/dist/snapshot/capture.d.ts +12 -10
  61. package/dist/snapshot/capture.d.ts.map +1 -1
  62. package/dist/snapshot/capture.js +36 -48
  63. package/dist/snapshot/capture.js.map +1 -1
  64. package/dist/snapshot/compress.d.ts +3 -3
  65. package/dist/snapshot/compress.d.ts.map +1 -1
  66. package/dist/snapshot/compress.js +16 -51
  67. package/dist/snapshot/compress.js.map +1 -1
  68. package/dist/snapshot/storage.d.ts +1 -0
  69. package/dist/snapshot/storage.d.ts.map +1 -1
  70. package/dist/snapshot/storage.js +7 -6
  71. package/dist/snapshot/storage.js.map +1 -1
  72. package/dist/threshold/state.d.ts +5 -0
  73. package/dist/threshold/state.d.ts.map +1 -1
  74. package/dist/threshold/state.js +28 -2
  75. package/dist/threshold/state.js.map +1 -1
  76. package/dist/threshold/token-usage.d.ts +29 -0
  77. package/dist/threshold/token-usage.d.ts.map +1 -0
  78. package/dist/threshold/token-usage.js +128 -0
  79. package/dist/threshold/token-usage.js.map +1 -0
  80. package/dist/trails/identity.d.ts +76 -0
  81. package/dist/trails/identity.d.ts.map +1 -0
  82. package/dist/trails/identity.js +117 -0
  83. package/dist/trails/identity.js.map +1 -0
  84. package/dist/trails/reader.d.ts +1 -1
  85. package/dist/trails/reader.js +2 -2
  86. package/dist/trails/reader.js.map +1 -1
  87. package/dist/trails/writer.d.ts +2 -8
  88. package/dist/trails/writer.d.ts.map +1 -1
  89. package/dist/trails/writer.js +33 -191
  90. package/dist/trails/writer.js.map +1 -1
  91. package/dist/transcript/extractor.d.ts +8 -9
  92. package/dist/transcript/extractor.d.ts.map +1 -1
  93. package/dist/transcript/extractor.js +97 -360
  94. package/dist/transcript/extractor.js.map +1 -1
  95. package/dist/types.d.ts +34 -41
  96. package/dist/types.d.ts.map +1 -1
  97. package/hooks/hooks.json +12 -14
  98. package/package.json +13 -7
  99. package/scripts/install-plugin.sh +48 -0
  100. package/skills/context-continuity/SKILL.md +43 -24
  101. package/commands/activate.md +0 -20
  102. package/dist/transcript/estimator.d.ts +0 -20
  103. package/dist/transcript/estimator.d.ts.map +0 -1
  104. package/dist/transcript/estimator.js +0 -95
  105. package/dist/transcript/estimator.js.map +0 -1
package/dist/cli/index.js CHANGED
@@ -1,29 +1,33 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
- import { existsSync, readdirSync, statSync } from 'node:fs';
4
- import { join } from 'node:path';
3
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
4
+ import { join, basename } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import { createInterface } from 'node:readline';
7
7
  import { captureSnapshot } from '../snapshot/capture.js';
8
8
  import { loadSnapshot, listSnapshots, readLatestMd, getSnapshotCount } from '../snapshot/storage.js';
9
9
  import { compressToMarkdown } from '../snapshot/compress.js';
10
+ import { readContextMd } from '../trails/reader.js';
10
11
  import { restoreContext } from '../restore/index.js';
11
12
  import { loadState, saveState } from '../threshold/state.js';
12
13
  import { checkTimeInterval } from '../threshold/time-based.js';
13
- import { shouldSnapshotByThreshold } from '../threshold/adaptive.js';
14
- import { quickEstimate } from '../transcript/estimator.js';
14
+ import { handledThresholdsForUsage, newlyCrossedThresholds, readLatestContextUsage, } from '../threshold/token-usage.js';
15
+ import { buildHandoffPrompt } from '../context/handoff-prompt.js';
16
+ import { isContextMdFresh } from '../context/freshness.js';
15
17
  import { loadConfig, getStoragePath, writeConfig } from '../config.js';
16
18
  import { ensureProjectBootstrapped, setupProject } from '../setup/auto-setup.js';
19
+ import { findById, pruneStale, getLastProject, loadRegistry } from '../registry.js';
20
+ import { parseIdentity } from '../trails/identity.js';
17
21
  const program = new Command();
18
22
  program
19
23
  .name('bookmark')
20
24
  .description('Context snapshots for Claude Code — session continuity across compactions and terminals')
21
- .version('0.1.0');
25
+ .version('0.3.2');
22
26
  // ─── Hook Commands (invoked by hooks, not users) ───
23
27
  program
24
28
  .command('snapshot')
25
29
  .description('Capture a context snapshot')
26
- .option('--trigger <type>', 'Trigger type: pre_compact|time_interval|manual|session_end', 'manual')
30
+ .option('--trigger <type>', 'Trigger type: pre_compact|token_threshold|time_interval|manual|session_end', 'manual')
27
31
  .option('--transcript <path>', 'Path to transcript JSONL')
28
32
  .option('--session-id <id>', 'Session ID')
29
33
  .option('--cwd <path>', 'Working directory')
@@ -35,11 +39,15 @@ program
35
39
  let transcriptPath = opts.transcript ?? hookInput?.transcript_path;
36
40
  const sessionId = opts.sessionId ?? hookInput?.session_id;
37
41
  if (!transcriptPath) {
38
- // Try to discover transcript from ~/.claude/projects/
39
42
  transcriptPath = discoverTranscriptPath(cwd);
40
43
  if (!transcriptPath) {
41
- console.error('No transcript found. This command is typically called by hooks.');
42
- process.exit(1);
44
+ if (opts.trigger === 'manual') {
45
+ console.log('No transcript found. Using existing Bookmark trails for this manual checkpoint.');
46
+ }
47
+ else {
48
+ console.error('No transcript found. This command is typically called by hooks.');
49
+ process.exit(1);
50
+ }
43
51
  }
44
52
  }
45
53
  const snapshot = await captureSnapshot({
@@ -50,10 +58,8 @@ program
50
58
  });
51
59
  console.log(`Snapshot captured: ${snapshot.snapshot_id}`);
52
60
  console.log(` Trigger: ${snapshot.trigger}`);
53
- console.log(` Decisions: ${snapshot.decisions.length}`);
54
61
  console.log(` Files changed: ${snapshot.files_changed.length}`);
55
- console.log(` Open items: ${snapshot.open_items.length}`);
56
- console.log(` Context remaining: ${Math.round(snapshot.context_remaining_pct * 100)}%`);
62
+ console.log(` Tools tracked: ${Object.keys(snapshot.tools_summary).length}`);
57
63
  }
58
64
  catch (err) {
59
65
  console.error('Snapshot failed:', err.message);
@@ -89,7 +95,7 @@ program
89
95
  });
90
96
  program
91
97
  .command('check')
92
- .description('Check time/threshold intervals (for UserPromptSubmit hook)')
98
+ .description('Check the time interval (async UserPromptSubmit hook)')
93
99
  .option('--transcript <path>', 'Path to transcript JSONL')
94
100
  .option('--cwd <path>', 'Working directory')
95
101
  .action(async (opts) => {
@@ -103,20 +109,13 @@ program
103
109
  const state = loadState(storagePath);
104
110
  let shouldCapture = false;
105
111
  let reason = '';
106
- // Check time interval
112
+ // Legacy interval-only command. Installed hooks use context-check so
113
+ // interval capture and measured token usage share one state writer.
107
114
  const timeCheck = checkTimeInterval(state);
108
115
  if (timeCheck.shouldSnapshot) {
109
116
  shouldCapture = true;
110
117
  reason = timeCheck.reason ?? 'time interval';
111
118
  }
112
- // Check adaptive threshold
113
- if (!shouldCapture && transcriptPath) {
114
- const { remainingPct } = quickEstimate(transcriptPath, config.contextLimitTokens, config.charsPerToken);
115
- if (shouldSnapshotByThreshold(remainingPct, state.compaction_count, config)) {
116
- shouldCapture = true;
117
- reason = `context at ${Math.round(remainingPct * 100)}% remaining (threshold: ${Math.round(state.current_threshold * 100)}%)`;
118
- }
119
- }
120
119
  if (shouldCapture && transcriptPath) {
121
120
  const snapshot = await captureSnapshot({
122
121
  trigger: 'time_interval',
@@ -135,6 +134,269 @@ program
135
134
  // Silent — never break user prompt flow
136
135
  }
137
136
  });
137
+ program
138
+ .command('context-check')
139
+ .description('Capture and notify when context usage crosses a token threshold')
140
+ .option('--transcript <path>', 'Path to transcript JSONL')
141
+ .option('--cwd <path>', 'Working directory')
142
+ .action(async (opts) => {
143
+ try {
144
+ const hookInput = await readHookInput();
145
+ const cwd = opts.cwd ?? hookInput?.cwd ?? process.cwd();
146
+ ensureProjectBootstrapped(cwd);
147
+ const transcriptPath = opts.transcript ?? hookInput?.transcript_path;
148
+ if (!transcriptPath) {
149
+ console.log(JSON.stringify({}));
150
+ return;
151
+ }
152
+ const config = loadConfig(cwd);
153
+ const storagePath = getStoragePath(cwd, config);
154
+ const state = loadState(storagePath);
155
+ const timeCheck = checkTimeInterval(state);
156
+ const observation = readLatestContextUsage(transcriptPath, config.contextLimitTokens, hookInput?.prompt);
157
+ if (!observation) {
158
+ if (timeCheck.shouldSnapshot) {
159
+ await captureSnapshot({
160
+ trigger: 'time_interval',
161
+ transcriptPath,
162
+ cwd,
163
+ sessionId: hookInput?.session_id,
164
+ });
165
+ }
166
+ console.log(JSON.stringify({}));
167
+ return;
168
+ }
169
+ if (observation.status === 'unknown_context_limit') {
170
+ const shouldNotify = state.unknown_context_limit_notified_model !== observation.model;
171
+ const observedState = {
172
+ ...state,
173
+ last_event_time: Date.now(),
174
+ latest_model: observation.model,
175
+ latest_context_tokens: observation.usedTokens,
176
+ latest_context_limit_tokens: undefined,
177
+ latest_context_used_pct: undefined,
178
+ latest_context_observed_at: Date.now(),
179
+ unknown_context_limit_notified_model: observation.model,
180
+ };
181
+ if (timeCheck.shouldSnapshot) {
182
+ await captureSnapshot({
183
+ trigger: 'time_interval',
184
+ transcriptPath,
185
+ cwd,
186
+ sessionId: hookInput?.session_id,
187
+ });
188
+ const refreshedState = loadState(storagePath);
189
+ saveState(storagePath, {
190
+ ...refreshedState,
191
+ last_event_time: observedState.last_event_time,
192
+ latest_model: observedState.latest_model,
193
+ latest_context_tokens: observedState.latest_context_tokens,
194
+ latest_context_limit_tokens: observedState.latest_context_limit_tokens,
195
+ latest_context_used_pct: observedState.latest_context_used_pct,
196
+ latest_context_observed_at: observedState.latest_context_observed_at,
197
+ unknown_context_limit_notified_model: observedState.unknown_context_limit_notified_model,
198
+ });
199
+ }
200
+ else {
201
+ saveState(storagePath, observedState);
202
+ }
203
+ if (shouldNotify) {
204
+ console.log(JSON.stringify({
205
+ systemMessage: `Bookmark detected ${observation.model}, but its context limit is not verified. ` +
206
+ 'Token-threshold capture is paused for this model. ' +
207
+ 'Set the verified limit with `bookmark config --context-limit <tokens>`. ' +
208
+ `Manual snapshots and ${state.snapshot_interval_minutes}-minute periodic snapshots remain active.`,
209
+ }));
210
+ }
211
+ else {
212
+ console.log(JSON.stringify({}));
213
+ }
214
+ return;
215
+ }
216
+ const usage = observation;
217
+ const observedState = {
218
+ ...state,
219
+ last_event_time: Date.now(),
220
+ latest_model: usage.model,
221
+ latest_context_tokens: usage.usedTokens,
222
+ latest_context_limit_tokens: usage.contextLimitTokens,
223
+ latest_context_used_pct: usage.usedFraction,
224
+ latest_context_observed_at: Date.now(),
225
+ unknown_context_limit_notified_model: undefined,
226
+ };
227
+ const handledThresholds = handledThresholdsForUsage(usage.usedFraction, config.tokenThresholds, state.token_thresholds_triggered);
228
+ observedState.token_thresholds_triggered = handledThresholds;
229
+ const crossed = newlyCrossedThresholds(usage.usedFraction, config.tokenThresholds, handledThresholds);
230
+ if (crossed.length === 0) {
231
+ if (timeCheck.shouldSnapshot) {
232
+ await captureSnapshot({
233
+ trigger: 'time_interval',
234
+ transcriptPath,
235
+ cwd,
236
+ sessionId: hookInput?.session_id,
237
+ });
238
+ const refreshedState = loadState(storagePath);
239
+ saveState(storagePath, {
240
+ ...refreshedState,
241
+ last_event_time: observedState.last_event_time,
242
+ latest_model: observedState.latest_model,
243
+ latest_context_tokens: observedState.latest_context_tokens,
244
+ latest_context_limit_tokens: observedState.latest_context_limit_tokens,
245
+ latest_context_used_pct: observedState.latest_context_used_pct,
246
+ latest_context_observed_at: observedState.latest_context_observed_at,
247
+ unknown_context_limit_notified_model: observedState.unknown_context_limit_notified_model,
248
+ token_thresholds_triggered: observedState.token_thresholds_triggered,
249
+ });
250
+ }
251
+ else {
252
+ saveState(storagePath, observedState);
253
+ }
254
+ console.log(JSON.stringify({}));
255
+ return;
256
+ }
257
+ const snapshot = await captureSnapshot({
258
+ trigger: 'token_threshold',
259
+ transcriptPath,
260
+ cwd,
261
+ sessionId: hookInput?.session_id,
262
+ contextUsage: usage,
263
+ });
264
+ const refreshedState = loadState(storagePath);
265
+ saveState(storagePath, {
266
+ ...refreshedState,
267
+ latest_model: usage.model,
268
+ latest_context_tokens: usage.usedTokens,
269
+ latest_context_limit_tokens: usage.contextLimitTokens,
270
+ latest_context_used_pct: usage.usedFraction,
271
+ latest_context_observed_at: Date.now(),
272
+ unknown_context_limit_notified_model: undefined,
273
+ token_thresholds_triggered: [
274
+ ...new Set([...(handledThresholds ?? []), ...crossed]),
275
+ ],
276
+ });
277
+ const threshold = crossed[crossed.length - 1];
278
+ const thresholdPct = Math.round(threshold * 100);
279
+ const usedPct = Math.round(usage.usedFraction * 100);
280
+ const reason = `Context usage crossed ${thresholdPct}% (${usedPct}% observed; snapshot ${snapshot.snapshot_id}).`;
281
+ const handoffPrompt = buildHandoffPrompt({ cwd, reason });
282
+ console.log(JSON.stringify({
283
+ systemMessage: `Bookmark captured ${snapshot.snapshot_id} at ${usedPct}% of the ${usage.model} context window. ` +
284
+ 'Bookmark asked Claude to update the handoff. Start a new session after this response to restore it.',
285
+ hookSpecificOutput: {
286
+ hookEventName: 'UserPromptSubmit',
287
+ additionalContext: `${handoffPrompt}\nAfter successfully writing it, tell the user to start a new Claude session with /clear or by exiting and reopening Claude Code.`,
288
+ },
289
+ }));
290
+ }
291
+ catch {
292
+ // Context protection must never block the user's prompt.
293
+ console.log(JSON.stringify({}));
294
+ }
295
+ });
296
+ program
297
+ .command('stop')
298
+ .description('Stop hook — capture files, conditionally block for bookmark.context.md (for Stop hook)')
299
+ .option('--cwd <path>', 'Working directory')
300
+ .action(async (opts) => {
301
+ try {
302
+ const hookInput = await readHookInput();
303
+ const cwd = opts.cwd ?? hookInput?.cwd ?? process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
304
+ ensureProjectBootstrapped(cwd);
305
+ // Always capture file tracking snapshot
306
+ const transcriptPath = hookInput?.transcript_path ?? discoverTranscriptPath(cwd);
307
+ if (transcriptPath) {
308
+ try {
309
+ await captureSnapshot({
310
+ trigger: 'session_end',
311
+ transcriptPath,
312
+ cwd,
313
+ sessionId: hookInput?.session_id ?? process.env.CLAUDE_SESSION_ID,
314
+ });
315
+ }
316
+ catch { /* file tracking is best-effort */ }
317
+ }
318
+ // Update home-scope pointer to track the most recently active project
319
+ updateHomePointer(cwd);
320
+ const config = loadConfig(cwd);
321
+ const storagePath = getStoragePath(cwd, config);
322
+ const contextPath = join(storagePath, 'bookmark.context.md');
323
+ const markerPath = join(storagePath, '.stop-requested');
324
+ // Quality gate: check if bookmark.context.md has real content
325
+ if (isContextMdFresh(contextPath, markerPath)) {
326
+ console.log(JSON.stringify({ decision: 'approve' }));
327
+ return;
328
+ }
329
+ // Check .stop-requested marker — max 1 block to prevent loops
330
+ if (existsSync(markerPath)) {
331
+ // Already blocked once — approve to prevent infinite loop
332
+ console.log(JSON.stringify({ decision: 'approve' }));
333
+ return;
334
+ }
335
+ // First time — write JSON marker, track the block, and block
336
+ const marker = JSON.stringify({
337
+ timestamp: Date.now(),
338
+ session_id: hookInput?.session_id ?? process.env.CLAUDE_SESSION_ID ?? 'unknown',
339
+ });
340
+ writeFileSync(markerPath, marker, 'utf-8');
341
+ try {
342
+ const st = loadState(storagePath);
343
+ st.quality_blocks = (st.quality_blocks ?? 0) + 1;
344
+ saveState(storagePath, st);
345
+ }
346
+ catch { /* tracking is best-effort */ }
347
+ const handoffPrompt = buildHandoffPrompt({
348
+ cwd,
349
+ reason: 'The session is stopping and needs a durable handoff.',
350
+ });
351
+ console.log(JSON.stringify({
352
+ decision: 'block',
353
+ reason: handoffPrompt,
354
+ systemMessage: 'Bookmark needs Claude to refresh the session handoff before stopping.',
355
+ }));
356
+ }
357
+ catch (err) {
358
+ // Log error for debugging, then approve to never block stop
359
+ try {
360
+ const config = loadConfig(opts.cwd ?? process.cwd());
361
+ const storagePath = getStoragePath(opts.cwd ?? process.cwd(), config);
362
+ const errLog = join(storagePath, '.errors.log');
363
+ appendFileSync(errLog, `[${new Date().toISOString()}] stop: ${err.message}\n`);
364
+ }
365
+ catch { /* truly silent */ }
366
+ console.log(JSON.stringify({ decision: 'approve' }));
367
+ }
368
+ });
369
+ program
370
+ .command('precompact')
371
+ .description('PreCompact hook — capture a mechanical checkpoint')
372
+ .option('--cwd <path>', 'Working directory')
373
+ .action(async (opts) => {
374
+ try {
375
+ const hookInput = await readHookInput();
376
+ const cwd = opts.cwd ?? hookInput?.cwd ?? process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
377
+ ensureProjectBootstrapped(cwd);
378
+ // Always capture file tracking snapshot
379
+ const transcriptPath = hookInput?.transcript_path ?? discoverTranscriptPath(cwd);
380
+ if (transcriptPath) {
381
+ try {
382
+ await captureSnapshot({
383
+ trigger: 'pre_compact',
384
+ transcriptPath,
385
+ cwd,
386
+ sessionId: hookInput?.session_id ?? process.env.CLAUDE_SESSION_ID,
387
+ });
388
+ }
389
+ catch { /* file tracking is best-effort */ }
390
+ }
391
+ // Claude Code discards PreCompact systemMessage output. The synchronous
392
+ // 75% UserPromptSubmit hook is the semantic-handoff activation path.
393
+ console.log(JSON.stringify({}));
394
+ }
395
+ catch {
396
+ // Never block compaction
397
+ console.log(JSON.stringify({}));
398
+ }
399
+ });
138
400
  // ─── User-Facing Commands ───
139
401
  program
140
402
  .command('status')
@@ -152,8 +414,21 @@ program
152
414
  console.log('═══════════════');
153
415
  console.log(` Snapshots: ${count}`);
154
416
  console.log(` Compaction cycles: ${state.compaction_count}`);
155
- console.log(` Current threshold: ${Math.round(state.current_threshold * 100)}% remaining`);
156
417
  console.log(` Snapshot interval: ${state.snapshot_interval_minutes} minutes`);
418
+ console.log(` New-session alert: ${config.tokenThresholds.map(t => `${Math.round(t * 100)}%`).join(', ')} used when limit is known`);
419
+ if (state.latest_context_used_pct !== undefined) {
420
+ const usedPct = Math.round(state.latest_context_used_pct * 100);
421
+ const usedTokens = state.latest_context_tokens ?? 0;
422
+ const limitTokens = state.latest_context_limit_tokens ?? 0;
423
+ console.log(` Context usage: ${usedPct}% (${formatTokenCount(usedTokens)} / ${formatTokenCount(limitTokens)})`);
424
+ if (state.latest_model)
425
+ console.log(` Active model: ${state.latest_model}`);
426
+ }
427
+ else if (state.latest_model && state.latest_context_tokens !== undefined) {
428
+ console.log(` Context usage: ${formatTokenCount(state.latest_context_tokens)} tokens; limit unknown`);
429
+ console.log(` Active model: ${state.latest_model}`);
430
+ console.log(' Required action: bookmark config --context-limit <tokens>');
431
+ }
157
432
  if (state.last_snapshot_time > 0) {
158
433
  const ago = Math.round((Date.now() - state.last_snapshot_time) / 60_000);
159
434
  console.log(` Last snapshot: ${ago} minutes ago`);
@@ -161,13 +436,31 @@ program
161
436
  else {
162
437
  console.log(` Last snapshot: never`);
163
438
  }
439
+ // Usage counters — only show when there's data
440
+ const restores = state.restores_performed ?? 0;
441
+ const tokensInjected = state.tokens_injected ?? 0;
442
+ const blocks = state.quality_blocks ?? 0;
443
+ const caught = state.boilerplate_caught ?? 0;
444
+ if (restores > 0 || blocks > 0) {
445
+ console.log('');
446
+ console.log('Usage:');
447
+ if (restores > 0) {
448
+ console.log(` Sessions restored: ${restores}`);
449
+ console.log(` Tokens injected: ~${tokensInjected}`);
450
+ }
451
+ if (blocks > 0) {
452
+ console.log(` Quality blocks: ${blocks} (asked Claude to write bookmark.context.md)`);
453
+ }
454
+ if (caught > 0) {
455
+ console.log(` Boilerplate caught: ${caught} (skipped stale restore)`);
456
+ }
457
+ }
164
458
  if (entries.length > 0) {
165
459
  console.log('');
166
460
  console.log('Recent Snapshots:');
167
461
  for (const entry of entries) {
168
462
  const date = new Date(entry.timestamp).toLocaleString();
169
- const pct = Math.round(entry.context_remaining_pct * 100);
170
- console.log(` ${entry.id} ${entry.trigger.padEnd(14)} ${pct}% ctx ${date}`);
463
+ console.log(` ${entry.id} ${entry.trigger.padEnd(14)} ${date}`);
171
464
  }
172
465
  }
173
466
  console.log('');
@@ -176,23 +469,50 @@ program
176
469
  .command('list')
177
470
  .description('List available snapshots')
178
471
  .option('--limit <n>', 'Max snapshots to show', '10')
472
+ .option('--all', 'List snapshots across all projects (from global registry)')
179
473
  .option('--cwd <path>', 'Working directory')
180
474
  .action((opts) => {
475
+ const limit = parseInt(opts.limit, 10);
476
+ if (opts.all) {
477
+ const registry = pruneStale();
478
+ const entries = registry.snapshots.slice(0, limit);
479
+ if (entries.length === 0) {
480
+ console.log('No snapshots in global registry yet.');
481
+ return;
482
+ }
483
+ console.log('');
484
+ console.log('ID Project Trigger Files Time');
485
+ console.log('──────────────────── ──────────────────────── ─────────────── ───── ────────────────────');
486
+ for (const entry of entries) {
487
+ const date = new Date(entry.timestamp).toLocaleString();
488
+ const proj = entry.project_name.length > 24 ? entry.project_name.slice(0, 23) + '…' : entry.project_name;
489
+ console.log(`${entry.id} ${proj.padEnd(24)} ${entry.trigger.padEnd(15)} ${String(entry.files_changed_count).padStart(5)} ${date}`);
490
+ }
491
+ console.log('');
492
+ return;
493
+ }
181
494
  const cwd = opts.cwd ?? process.cwd();
182
495
  const config = loadConfig(cwd);
183
496
  const storagePath = getStoragePath(cwd, config);
184
- const entries = listSnapshots(storagePath, parseInt(opts.limit, 10));
497
+ const entries = listSnapshots(storagePath, limit);
185
498
  if (entries.length === 0) {
186
- console.log('No snapshots found. Run `/bookmark:snapshot` to create one.');
499
+ const last = getLastProject();
500
+ if (last && last.path !== cwd) {
501
+ console.log('No snapshots in this project.');
502
+ console.log(`Last active project: ${last.name} (${last.path})`);
503
+ console.log('Run `bookmark list --all` for the cross-project view.');
504
+ }
505
+ else {
506
+ console.log('No snapshots found. Run `/bookmark:snapshot` to create one.');
507
+ }
187
508
  return;
188
509
  }
189
510
  console.log('');
190
- console.log('ID Trigger Ctx% Decisions Files Open Items Time');
191
- console.log('──────────────────── ─────────────── ───── ───────── ───── ────────── ────────────────────');
511
+ console.log('ID Trigger Files Time');
512
+ console.log('──────────────────── ─────────────── ───── ────────────────────');
192
513
  for (const entry of entries) {
193
514
  const date = new Date(entry.timestamp).toLocaleString();
194
- const pct = `${Math.round(entry.context_remaining_pct * 100)}%`.padStart(4);
195
- console.log(`${entry.id} ${entry.trigger.padEnd(15)} ${pct} ${String(entry.decisions_count).padStart(9)} ${String(entry.files_changed_count).padStart(5)} ${String(entry.open_items_count).padStart(10)} ${date}`);
515
+ console.log(`${entry.id} ${entry.trigger.padEnd(15)} ${String(entry.files_changed_count).padStart(5)} ${date}`);
196
516
  }
197
517
  console.log('');
198
518
  });
@@ -206,36 +526,192 @@ program
206
526
  const config = loadConfig(cwd);
207
527
  const storagePath = getStoragePath(cwd, config);
208
528
  if (opts.latest || !snapshotId) {
209
- const md = readLatestMd(storagePath);
210
- if (md) {
211
- console.log(md);
212
- }
213
- else {
529
+ const contextMd = readContextMd(storagePath);
530
+ const latestMd = readLatestMd(storagePath);
531
+ if (!contextMd && !latestMd) {
214
532
  console.log('No snapshots found.');
533
+ return;
534
+ }
535
+ if (contextMd) {
536
+ console.log(contextMd);
537
+ }
538
+ if (contextMd && latestMd) {
539
+ console.log('');
540
+ console.log('─── File Trail ───');
541
+ console.log('');
542
+ }
543
+ if (latestMd) {
544
+ console.log(latestMd);
215
545
  }
216
546
  return;
217
547
  }
218
- const snapshot = loadSnapshot(storagePath, snapshotId);
548
+ let snapshot = loadSnapshot(storagePath, snapshotId);
549
+ // Registry fallback: not in current CWD, maybe it belongs to another project
550
+ if (!snapshot) {
551
+ const entry = findById(snapshotId);
552
+ if (entry) {
553
+ const otherStorage = join(entry.project_path, config.storagePath);
554
+ snapshot = loadSnapshot(otherStorage, snapshotId);
555
+ if (snapshot) {
556
+ console.log(`[bookmark: resolved via registry → ${entry.project_name} (${entry.project_path})]`);
557
+ console.log('');
558
+ }
559
+ }
560
+ }
219
561
  if (!snapshot) {
220
562
  console.log(`Snapshot not found: ${snapshotId}`);
221
563
  return;
222
564
  }
223
565
  console.log(compressToMarkdown(snapshot));
224
566
  });
567
+ program
568
+ .command('clear')
569
+ .description('Write a home-scope pointer to the most-recent project and print its context')
570
+ .option('--project <path>', 'Point at a specific project root instead of the last-active one')
571
+ .option('--no-print', 'Skip printing the canonical context to stdout')
572
+ .option('--no-pointer', 'Skip writing ~/.bookmark/bookmark.context.md pointer')
573
+ .action((opts) => {
574
+ // Resolve target project:
575
+ // 1. explicit --project wins
576
+ // 2. else registry.last_project, but skip if it points at $HOME (stale/bad state)
577
+ // 3. else most-recent snapshot whose project_path !== $HOME and has a bookmark.context.md
578
+ const home = homedir();
579
+ let targetPath = null;
580
+ let targetName = null;
581
+ if (opts.project) {
582
+ targetPath = opts.project;
583
+ targetName = basename(opts.project);
584
+ }
585
+ else {
586
+ const last = getLastProject();
587
+ if (last && last.path !== home) {
588
+ targetPath = last.path;
589
+ targetName = last.name;
590
+ }
591
+ else {
592
+ // Fall back: scan registry snapshots for the most recent real project.
593
+ const registry = loadRegistry();
594
+ for (const entry of registry.snapshots) {
595
+ if (entry.project_path === home)
596
+ continue;
597
+ const candidate = join(entry.project_path, '.bookmark', 'bookmark.context.md');
598
+ if (existsSync(candidate)) {
599
+ targetPath = entry.project_path;
600
+ targetName = entry.project_name;
601
+ break;
602
+ }
603
+ }
604
+ }
605
+ }
606
+ if (!targetPath || !targetName) {
607
+ console.error('No last-active project found in the registry. Run `bookmark list --all`.');
608
+ console.error('Or pass --project <path> to point at a specific project.');
609
+ process.exit(1);
610
+ }
611
+ let canonicalPath = join(targetPath, '.bookmark', 'bookmark.context.md');
612
+ if (!existsSync(canonicalPath)) {
613
+ console.error(`No bookmark.context.md at ${canonicalPath}.`);
614
+ console.error('That project has no session summary to restore from.');
615
+ process.exit(1);
616
+ }
617
+ let canonicalContent = readFileSync(canonicalPath, 'utf-8');
618
+ // If target is itself a home-scope pointer, follow it to the real canonical file.
619
+ const { identity } = parseIdentity(canonicalContent);
620
+ if (identity?.scope === 'home' && identity.points_to_canonical && existsSync(identity.points_to_canonical)) {
621
+ canonicalPath = identity.points_to_canonical;
622
+ canonicalContent = readFileSync(canonicalPath, 'utf-8');
623
+ if (identity.points_to_project)
624
+ targetName = identity.points_to_project;
625
+ const marker = '/.bookmark/';
626
+ const idx = canonicalPath.lastIndexOf(marker);
627
+ if (idx > 0)
628
+ targetPath = canonicalPath.slice(0, idx);
629
+ }
630
+ if (opts.pointer !== false) {
631
+ const homeDir = join(homedir(), '.bookmark');
632
+ if (!existsSync(homeDir))
633
+ mkdirSync(homeDir, { recursive: true });
634
+ const pointerBody = [
635
+ `# Bookmark Pointer — ${targetName}`,
636
+ '',
637
+ '<!-- BOOKMARK_IDENTITY',
638
+ 'scope: home',
639
+ 'project: POINTER_ONLY',
640
+ `points_to_project: ${targetName}`,
641
+ `points_to_canonical: ${canonicalPath}`,
642
+ `written: ${new Date().toISOString().slice(0, 10)}`,
643
+ 'written_by: bookmark-clear',
644
+ '-->',
645
+ '',
646
+ `Redirects SessionStart to: ${targetName}`,
647
+ `Canonical file: ${canonicalPath}`,
648
+ '',
649
+ '*This file was written by `bookmark clear`. The next Claude Code session',
650
+ 'started from this directory (or any directory without its own .bookmark/)',
651
+ 'will follow the pointer above and restore the canonical context.*',
652
+ ].join('\n');
653
+ writeFileSync(join(homeDir, 'bookmark.context.md'), pointerBody, 'utf-8');
654
+ console.error(`[bookmark] Home pointer → ${targetName} (${canonicalPath})`);
655
+ }
656
+ if (opts.print !== false) {
657
+ console.log(canonicalContent);
658
+ }
659
+ });
225
660
  program
226
661
  .command('config')
227
662
  .description('Show or set configuration')
228
663
  .option('--interval <minutes>', 'Set time-based interval')
664
+ .option('--token-threshold <fraction>', 'Set context-used threshold (0.75 or 75)')
665
+ .option('--context-limit <tokens>', 'Override model context limit')
229
666
  .option('--cwd <path>', 'Working directory')
230
667
  .action((opts) => {
231
668
  const cwd = opts.cwd ?? process.cwd();
232
669
  const config = loadConfig(cwd);
233
670
  const storagePath = getStoragePath(cwd, config);
234
- if (opts.interval) {
235
- const state = loadState(storagePath);
236
- state.snapshot_interval_minutes = parseInt(opts.interval, 10);
237
- saveState(storagePath, state);
238
- console.log(`Snapshot interval set to ${opts.interval} minutes`);
671
+ if (opts.interval || opts.tokenThreshold || opts.contextLimit) {
672
+ const preferences = {};
673
+ if (opts.interval) {
674
+ const interval = Number.parseInt(opts.interval, 10);
675
+ if (!Number.isFinite(interval) || interval <= 0) {
676
+ console.error('Interval must be a positive number of minutes.');
677
+ process.exitCode = 1;
678
+ return;
679
+ }
680
+ preferences.intervalMinutes = interval;
681
+ }
682
+ if (opts.tokenThreshold) {
683
+ const threshold = normalizeThreshold(Number(opts.tokenThreshold));
684
+ if (threshold === null) {
685
+ console.error('Token threshold must be between 0 and 1, or between 1 and 100 percent.');
686
+ process.exitCode = 1;
687
+ return;
688
+ }
689
+ preferences.tokenThresholds = [threshold];
690
+ }
691
+ if (opts.contextLimit) {
692
+ const limit = Number.parseInt(opts.contextLimit, 10);
693
+ if (!Number.isFinite(limit) || limit <= 0) {
694
+ console.error('Context limit must be a positive token count.');
695
+ process.exitCode = 1;
696
+ return;
697
+ }
698
+ preferences.contextLimitTokens = limit;
699
+ }
700
+ writeConfig(cwd, preferences);
701
+ if (preferences.intervalMinutes !== undefined) {
702
+ const state = loadState(storagePath);
703
+ state.snapshot_interval_minutes = preferences.intervalMinutes;
704
+ saveState(storagePath, state);
705
+ }
706
+ if (preferences.intervalMinutes !== undefined) {
707
+ console.log(`Snapshot interval set to ${preferences.intervalMinutes} minutes`);
708
+ }
709
+ if (preferences.tokenThresholds) {
710
+ console.log(`Token threshold set to ${Math.round(preferences.tokenThresholds[0] * 100)}% used`);
711
+ }
712
+ if (preferences.contextLimitTokens) {
713
+ console.log(`Context limit set to ${preferences.contextLimitTokens} tokens`);
714
+ }
239
715
  return;
240
716
  }
241
717
  console.log('');
@@ -243,14 +719,15 @@ program
243
719
  console.log('══════════════════════');
244
720
  console.log(` Storage path: ${config.storagePath}`);
245
721
  console.log(` Interval: ${config.intervalMinutes} minutes`);
246
- console.log(` Thresholds: ${config.thresholds.map(t => `${Math.round(t * 100)}%`).join(', ')}`);
247
- console.log(` Context limit: ${config.contextLimitTokens.toLocaleString()} tokens`);
722
+ console.log(` Token thresholds: ${config.tokenThresholds.map(t => `${Math.round(t * 100)}% used`).join(', ')}`);
723
+ console.log(` Context limit: ${config.contextLimitTokens ? formatTokenCount(config.contextLimitTokens) : 'model-aware'}`);
248
724
  console.log(` Max snapshots: ${config.maxActiveSnapshots}`);
249
725
  console.log(` Archive after: ${config.archiveAfterDays} days`);
250
726
  console.log('');
251
727
  console.log('Environment overrides:');
252
- console.log(' BOOKMARK_INTERVAL, BOOKMARK_THRESHOLD, BOOKMARK_STORAGE_PATH');
253
- console.log(' BOOKMARK_CONTEXT_LIMIT, BOOKMARK_VERBOSE');
728
+ console.log(' BOOKMARK_INTERVAL, BOOKMARK_TOKEN_THRESHOLD, BOOKMARK_CONTEXT_LIMIT');
729
+ console.log(' BOOKMARK_STORAGE_PATH');
730
+ console.log(' BOOKMARK_VERBOSE');
254
731
  console.log('');
255
732
  });
256
733
  program
@@ -287,6 +764,70 @@ program
287
764
  }
288
765
  });
289
766
  // ─── Helpers ───
767
+ /**
768
+ * Update the home-directory pointer at ~/.bookmark/bookmark.context.md to point
769
+ * at the project that just ended a session.
770
+ *
771
+ * Only writes when:
772
+ * - cwd is NOT the home directory (no self-pointing)
773
+ * - the project's .bookmark/bookmark.context.md actually exists
774
+ *
775
+ * Wrapped in try/catch — never breaks the main stop flow.
776
+ */
777
+ function updateHomePointer(cwd) {
778
+ try {
779
+ const home = homedir();
780
+ if (!home || cwd === home)
781
+ return;
782
+ const projectContextPath = join(cwd, '.bookmark', 'bookmark.context.md');
783
+ if (!existsSync(projectContextPath))
784
+ return;
785
+ // Derive a short project name from the directory basename
786
+ const projectName = basename(cwd)
787
+ .toLowerCase()
788
+ .replace(/\s+/g, '-')
789
+ .replace(/[^a-z0-9-]/g, '');
790
+ const homeBookmarkDir = join(home, '.bookmark');
791
+ if (!existsSync(homeBookmarkDir)) {
792
+ mkdirSync(homeBookmarkDir, { recursive: true });
793
+ }
794
+ const today = new Date().toISOString().slice(0, 10);
795
+ const pointerContent = [
796
+ '# Active Work Pointer (home-scoped bookmark)',
797
+ '',
798
+ '<!-- BOOKMARK_IDENTITY',
799
+ 'scope: home',
800
+ 'project: POINTER_ONLY',
801
+ `points_to_project: ${projectName}`,
802
+ `points_to_repo: ${cwd}`,
803
+ `points_to_canonical: ${projectContextPath}`,
804
+ `written: ${today}`,
805
+ 'written_by: bookmark-plugin',
806
+ '-->',
807
+ '',
808
+ '> **This is the HOME-directory pointer**, not a full session context.',
809
+ `> It delegates to the repo-scope bookmark at \`${projectContextPath}\`.`,
810
+ '',
811
+ ].join('\n');
812
+ writeFileSync(join(homeBookmarkDir, 'bookmark.context.md'), pointerContent, 'utf-8');
813
+ }
814
+ catch {
815
+ // Never propagate — pointer update is best-effort
816
+ }
817
+ }
818
+ function formatTokenCount(value) {
819
+ if (value >= 1_000_000)
820
+ return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1)}M`;
821
+ if (value >= 1_000)
822
+ return `${Math.round(value / 1_000)}K`;
823
+ return String(value);
824
+ }
825
+ function normalizeThreshold(value) {
826
+ if (!Number.isFinite(value) || value <= 0)
827
+ return null;
828
+ const normalized = value > 1 ? value / 100 : value;
829
+ return normalized > 0 && normalized < 1 ? normalized : null;
830
+ }
290
831
  const GREEN = '\x1b[32m';
291
832
  const CYAN = '\x1b[36m';
292
833
  const DIM = '\x1b[2m';
@@ -308,17 +849,9 @@ function discoverTranscriptPath(cwd) {
308
849
  // Claude Code encodes paths by replacing / with -
309
850
  // e.g., /Users/tyroneross/myproject → -Users-tyroneross-myproject
310
851
  const encodedCwd = cwd.replace(/\//g, '-');
311
- // Prefer exact match, then longest match (most specific path)
312
- let matchingDir = projectDirs.find(d => d === encodedCwd);
313
- if (!matchingDir) {
314
- // Find the most specific (longest) matching directory
315
- const candidates = projectDirs.filter(d => d === encodedCwd || encodedCwd.startsWith(d) || d.startsWith(encodedCwd));
316
- if (candidates.length > 0) {
317
- // Sort by length descending — most specific match first
318
- candidates.sort((a, b) => b.length - a.length);
319
- matchingDir = candidates[0];
320
- }
321
- }
852
+ // Exact match only fuzzy matching caused cross-project contamination
853
+ // where e.g. FloDoro would match bookmark's transcript dir via startsWith
854
+ const matchingDir = projectDirs.find(d => d === encodedCwd);
322
855
  if (!matchingDir)
323
856
  return null;
324
857
  const transcriptDir = join(claudeProjectsDir, matchingDir);
@@ -370,7 +903,7 @@ function askQuestion(question) {
370
903
  * Interactive (or default) setup for bookmark in a project.
371
904
  */
372
905
  async function runSetup(cwd, useDefaults) {
373
- let intervalMinutes = 20;
906
+ let intervalMinutes = 5;
374
907
  console.log('');
375
908
  console.log(`${BOLD}Bookmark — Context Snapshot Setup${RESET}`);
376
909
  console.log('═════════════════════════════════');
@@ -378,21 +911,22 @@ async function runSetup(cwd, useDefaults) {
378
911
  if (!useDefaults) {
379
912
  // Prompt for interval
380
913
  console.log('Snapshot interval?');
381
- console.log(' 1) 10 minutes (frequent)');
382
- console.log(' 2) 15 minutes');
383
- console.log(` 3) 20 minutes ${DIM}(recommended)${RESET}`);
384
- console.log(' 4) 30 minutes (conservative)');
914
+ console.log(` 1) 5 minutes ${DIM}(recommended)${RESET}`);
915
+ console.log(' 2) 10 minutes');
916
+ console.log(' 3) 15 minutes');
917
+ console.log(' 4) 20 minutes');
918
+ console.log(' 5) 30 minutes (conservative)');
385
919
  console.log('');
386
920
  const intervalAnswer = await askQuestion(`${DIM}> ${RESET}`);
387
- const intervalMap = { '1': 10, '2': 15, '3': 20, '4': 30 };
388
- intervalMinutes = intervalMap[intervalAnswer] ?? 20;
921
+ const intervalMap = { '1': 5, '2': 10, '3': 15, '4': 20, '5': 30 };
922
+ intervalMinutes = intervalMap[intervalAnswer] ?? 5;
389
923
  console.log('');
390
924
  }
391
925
  // Run core setup (dirs, hooks, gitignore, CLAUDE.md)
392
926
  const steps = setupProject(cwd);
393
927
  // Write user preferences
394
928
  writeConfig(cwd, { intervalMinutes });
395
- steps.push('Saved config to .claude/bookmarks/config.json');
929
+ steps.push('Saved config to .bookmark/config.json');
396
930
  // Initialize state with user preferences
397
931
  const sp = getStoragePath(cwd);
398
932
  const state = loadState(sp);
@@ -406,7 +940,7 @@ async function runSetup(cwd, useDefaults) {
406
940
  console.log('');
407
941
  console.log('Defaults:');
408
942
  console.log(` Interval: ${intervalMinutes} minutes`);
409
- console.log(` Thresholds: 20% 30% 40% → 50% (adaptive)`);
943
+ console.log(' New session: suggest at 75% context used');
410
944
  console.log('');
411
945
  console.log(`${GREEN}Ready.${RESET} Start a Claude Code session — snapshots will be captured automatically.`);
412
946
  console.log('');