@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.
- package/.claude-plugin/marketplace.json +29 -12
- package/.claude-plugin/plugin.json +12 -8
- package/.codex-plugin/plugin.json +31 -0
- package/.mcp.json +8 -0
- package/CLAUDE.md +82 -38
- package/LICENSE +202 -21
- package/README.md +150 -57
- package/agents/snapshot-analyst.md +1 -1
- package/commands/bookmark.md +29 -0
- package/commands/feedback.md +37 -0
- package/commands/restore.md +14 -4
- package/commands/snapshot.md +19 -7
- package/commands/status.md +1 -1
- package/dist/cli/index.js +600 -66
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/snapshot-resolution.d.ts +15 -0
- package/dist/cli/snapshot-resolution.d.ts.map +1 -0
- package/dist/cli/snapshot-resolution.js +21 -0
- package/dist/cli/snapshot-resolution.js.map +1 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +150 -19
- package/dist/config.js.map +1 -1
- package/dist/context/freshness.d.ts +3 -0
- package/dist/context/freshness.d.ts.map +1 -0
- package/dist/context/freshness.js +29 -0
- package/dist/context/freshness.js.map +1 -0
- package/dist/context/handoff-prompt.d.ts +12 -0
- package/dist/context/handoff-prompt.d.ts.map +1 -0
- package/dist/context/handoff-prompt.js +29 -0
- package/dist/context/handoff-prompt.js.map +1 -0
- package/dist/index.d.ts +7 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +85 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools.d.ts +97 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +304 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/registry.d.ts +40 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +134 -0
- package/dist/registry.js.map +1 -0
- package/dist/restore/index.d.ts +13 -1
- package/dist/restore/index.d.ts.map +1 -1
- package/dist/restore/index.js +154 -256
- package/dist/restore/index.js.map +1 -1
- package/dist/setup/auto-setup.d.ts +8 -2
- package/dist/setup/auto-setup.d.ts.map +1 -1
- package/dist/setup/auto-setup.js +281 -66
- package/dist/setup/auto-setup.js.map +1 -1
- package/dist/setup/configure-hooks.d.ts +14 -3
- package/dist/setup/configure-hooks.d.ts.map +1 -1
- package/dist/setup/configure-hooks.js +156 -24
- package/dist/setup/configure-hooks.js.map +1 -1
- package/dist/snapshot/capture.d.ts +12 -10
- package/dist/snapshot/capture.d.ts.map +1 -1
- package/dist/snapshot/capture.js +36 -48
- package/dist/snapshot/capture.js.map +1 -1
- package/dist/snapshot/compress.d.ts +3 -3
- package/dist/snapshot/compress.d.ts.map +1 -1
- package/dist/snapshot/compress.js +16 -51
- package/dist/snapshot/compress.js.map +1 -1
- package/dist/snapshot/storage.d.ts +1 -0
- package/dist/snapshot/storage.d.ts.map +1 -1
- package/dist/snapshot/storage.js +7 -6
- package/dist/snapshot/storage.js.map +1 -1
- package/dist/threshold/state.d.ts +5 -0
- package/dist/threshold/state.d.ts.map +1 -1
- package/dist/threshold/state.js +28 -2
- package/dist/threshold/state.js.map +1 -1
- package/dist/threshold/token-usage.d.ts +29 -0
- package/dist/threshold/token-usage.d.ts.map +1 -0
- package/dist/threshold/token-usage.js +128 -0
- package/dist/threshold/token-usage.js.map +1 -0
- package/dist/trails/identity.d.ts +76 -0
- package/dist/trails/identity.d.ts.map +1 -0
- package/dist/trails/identity.js +117 -0
- package/dist/trails/identity.js.map +1 -0
- package/dist/trails/reader.d.ts +1 -1
- package/dist/trails/reader.js +2 -2
- package/dist/trails/reader.js.map +1 -1
- package/dist/trails/writer.d.ts +2 -8
- package/dist/trails/writer.d.ts.map +1 -1
- package/dist/trails/writer.js +33 -191
- package/dist/trails/writer.js.map +1 -1
- package/dist/transcript/extractor.d.ts +8 -9
- package/dist/transcript/extractor.d.ts.map +1 -1
- package/dist/transcript/extractor.js +97 -360
- package/dist/transcript/extractor.js.map +1 -1
- package/dist/types.d.ts +34 -41
- package/dist/types.d.ts.map +1 -1
- package/hooks/hooks.json +12 -14
- package/package.json +13 -7
- package/scripts/install-plugin.sh +48 -0
- package/skills/context-continuity/SKILL.md +43 -24
- package/commands/activate.md +0 -20
- package/dist/transcript/estimator.d.ts +0 -20
- package/dist/transcript/estimator.d.ts.map +0 -1
- package/dist/transcript/estimator.js +0 -95
- 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 {
|
|
14
|
-
import {
|
|
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.
|
|
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
|
-
|
|
42
|
-
|
|
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(`
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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,
|
|
497
|
+
const entries = listSnapshots(storagePath, limit);
|
|
185
498
|
if (entries.length === 0) {
|
|
186
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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(`
|
|
247
|
-
console.log(` Context limit: ${config.contextLimitTokens.
|
|
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,
|
|
253
|
-
console.log('
|
|
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
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
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 =
|
|
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(
|
|
382
|
-
console.log(' 2)
|
|
383
|
-
console.log(
|
|
384
|
-
console.log(' 4)
|
|
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':
|
|
388
|
-
intervalMinutes = intervalMap[intervalAnswer] ??
|
|
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 .
|
|
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(
|
|
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('');
|