@tyroneross/bookmark 0.1.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 +83 -49
- 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 +627 -87
- 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 +149 -23
- 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 +163 -43
- 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 +287 -67
- 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 +13 -9
- package/dist/snapshot/capture.d.ts.map +1 -1
- package/dist/snapshot/capture.js +42 -105
- 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 +35 -67
- package/dist/snapshot/compress.js.map +1 -1
- package/dist/snapshot/storage.d.ts +7 -0
- package/dist/snapshot/storage.d.ts.map +1 -1
- package/dist/snapshot/storage.js +31 -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 +7 -0
- package/dist/trails/reader.d.ts.map +1 -0
- package/dist/trails/reader.js +22 -0
- package/dist/trails/reader.js.map +1 -0
- package/dist/trails/writer.d.ts +9 -0
- package/dist/trails/writer.d.ts.map +1 -0
- package/dist/trails/writer.js +110 -0
- package/dist/trails/writer.js.map +1 -0
- package/dist/transcript/extractor.d.ts +17 -4
- package/dist/transcript/extractor.d.ts.map +1 -1
- package/dist/transcript/extractor.js +141 -174
- package/dist/transcript/extractor.js.map +1 -1
- package/dist/transcript/parser.d.ts.map +1 -1
- package/dist/transcript/parser.js +69 -7
- package/dist/transcript/parser.js.map +1 -1
- package/dist/types.d.ts +37 -38
- package/dist/types.d.ts.map +1 -1
- package/hooks/hooks.json +12 -14
- package/package.json +13 -10
- 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,30 +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')
|
|
27
|
-
.option('--smart', 'Use Claude Haiku for enhanced extraction')
|
|
30
|
+
.option('--trigger <type>', 'Trigger type: pre_compact|token_threshold|time_interval|manual|session_end', 'manual')
|
|
28
31
|
.option('--transcript <path>', 'Path to transcript JSONL')
|
|
29
32
|
.option('--session-id <id>', 'Session ID')
|
|
30
33
|
.option('--cwd <path>', 'Working directory')
|
|
@@ -36,11 +39,15 @@ program
|
|
|
36
39
|
let transcriptPath = opts.transcript ?? hookInput?.transcript_path;
|
|
37
40
|
const sessionId = opts.sessionId ?? hookInput?.session_id;
|
|
38
41
|
if (!transcriptPath) {
|
|
39
|
-
// Try to discover transcript from ~/.claude/projects/
|
|
40
42
|
transcriptPath = discoverTranscriptPath(cwd);
|
|
41
43
|
if (!transcriptPath) {
|
|
42
|
-
|
|
43
|
-
|
|
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
|
+
}
|
|
44
51
|
}
|
|
45
52
|
}
|
|
46
53
|
const snapshot = await captureSnapshot({
|
|
@@ -48,14 +55,11 @@ program
|
|
|
48
55
|
transcriptPath,
|
|
49
56
|
cwd,
|
|
50
57
|
sessionId,
|
|
51
|
-
smart: opts.smart,
|
|
52
58
|
});
|
|
53
59
|
console.log(`Snapshot captured: ${snapshot.snapshot_id}`);
|
|
54
60
|
console.log(` Trigger: ${snapshot.trigger}`);
|
|
55
|
-
console.log(` Decisions: ${snapshot.decisions.length}`);
|
|
56
61
|
console.log(` Files changed: ${snapshot.files_changed.length}`);
|
|
57
|
-
console.log(`
|
|
58
|
-
console.log(` Context remaining: ${Math.round(snapshot.context_remaining_pct * 100)}%`);
|
|
62
|
+
console.log(` Tools tracked: ${Object.keys(snapshot.tools_summary).length}`);
|
|
59
63
|
}
|
|
60
64
|
catch (err) {
|
|
61
65
|
console.error('Snapshot failed:', err.message);
|
|
@@ -91,7 +95,7 @@ program
|
|
|
91
95
|
});
|
|
92
96
|
program
|
|
93
97
|
.command('check')
|
|
94
|
-
.description('Check time
|
|
98
|
+
.description('Check the time interval (async UserPromptSubmit hook)')
|
|
95
99
|
.option('--transcript <path>', 'Path to transcript JSONL')
|
|
96
100
|
.option('--cwd <path>', 'Working directory')
|
|
97
101
|
.action(async (opts) => {
|
|
@@ -105,20 +109,13 @@ program
|
|
|
105
109
|
const state = loadState(storagePath);
|
|
106
110
|
let shouldCapture = false;
|
|
107
111
|
let reason = '';
|
|
108
|
-
//
|
|
112
|
+
// Legacy interval-only command. Installed hooks use context-check so
|
|
113
|
+
// interval capture and measured token usage share one state writer.
|
|
109
114
|
const timeCheck = checkTimeInterval(state);
|
|
110
115
|
if (timeCheck.shouldSnapshot) {
|
|
111
116
|
shouldCapture = true;
|
|
112
117
|
reason = timeCheck.reason ?? 'time interval';
|
|
113
118
|
}
|
|
114
|
-
// Check adaptive threshold
|
|
115
|
-
if (!shouldCapture && transcriptPath) {
|
|
116
|
-
const { remainingPct } = quickEstimate(transcriptPath, config.contextLimitTokens, config.charsPerToken);
|
|
117
|
-
if (shouldSnapshotByThreshold(remainingPct, state.compaction_count, config)) {
|
|
118
|
-
shouldCapture = true;
|
|
119
|
-
reason = `context at ${Math.round(remainingPct * 100)}% remaining (threshold: ${Math.round(state.current_threshold * 100)}%)`;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
119
|
if (shouldCapture && transcriptPath) {
|
|
123
120
|
const snapshot = await captureSnapshot({
|
|
124
121
|
trigger: 'time_interval',
|
|
@@ -137,6 +134,269 @@ program
|
|
|
137
134
|
// Silent — never break user prompt flow
|
|
138
135
|
}
|
|
139
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
|
+
});
|
|
140
400
|
// ─── User-Facing Commands ───
|
|
141
401
|
program
|
|
142
402
|
.command('status')
|
|
@@ -154,8 +414,21 @@ program
|
|
|
154
414
|
console.log('═══════════════');
|
|
155
415
|
console.log(` Snapshots: ${count}`);
|
|
156
416
|
console.log(` Compaction cycles: ${state.compaction_count}`);
|
|
157
|
-
console.log(` Current threshold: ${Math.round(state.current_threshold * 100)}% remaining`);
|
|
158
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
|
+
}
|
|
159
432
|
if (state.last_snapshot_time > 0) {
|
|
160
433
|
const ago = Math.round((Date.now() - state.last_snapshot_time) / 60_000);
|
|
161
434
|
console.log(` Last snapshot: ${ago} minutes ago`);
|
|
@@ -163,13 +436,31 @@ program
|
|
|
163
436
|
else {
|
|
164
437
|
console.log(` Last snapshot: never`);
|
|
165
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
|
+
}
|
|
166
458
|
if (entries.length > 0) {
|
|
167
459
|
console.log('');
|
|
168
460
|
console.log('Recent Snapshots:');
|
|
169
461
|
for (const entry of entries) {
|
|
170
462
|
const date = new Date(entry.timestamp).toLocaleString();
|
|
171
|
-
|
|
172
|
-
console.log(` ${entry.id} ${entry.trigger.padEnd(14)} ${pct}% ctx ${date}`);
|
|
463
|
+
console.log(` ${entry.id} ${entry.trigger.padEnd(14)} ${date}`);
|
|
173
464
|
}
|
|
174
465
|
}
|
|
175
466
|
console.log('');
|
|
@@ -178,23 +469,50 @@ program
|
|
|
178
469
|
.command('list')
|
|
179
470
|
.description('List available snapshots')
|
|
180
471
|
.option('--limit <n>', 'Max snapshots to show', '10')
|
|
472
|
+
.option('--all', 'List snapshots across all projects (from global registry)')
|
|
181
473
|
.option('--cwd <path>', 'Working directory')
|
|
182
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
|
+
}
|
|
183
494
|
const cwd = opts.cwd ?? process.cwd();
|
|
184
495
|
const config = loadConfig(cwd);
|
|
185
496
|
const storagePath = getStoragePath(cwd, config);
|
|
186
|
-
const entries = listSnapshots(storagePath,
|
|
497
|
+
const entries = listSnapshots(storagePath, limit);
|
|
187
498
|
if (entries.length === 0) {
|
|
188
|
-
|
|
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
|
+
}
|
|
189
508
|
return;
|
|
190
509
|
}
|
|
191
510
|
console.log('');
|
|
192
|
-
console.log('ID Trigger
|
|
193
|
-
console.log('──────────────────── ─────────────── ─────
|
|
511
|
+
console.log('ID Trigger Files Time');
|
|
512
|
+
console.log('──────────────────── ─────────────── ───── ────────────────────');
|
|
194
513
|
for (const entry of entries) {
|
|
195
514
|
const date = new Date(entry.timestamp).toLocaleString();
|
|
196
|
-
|
|
197
|
-
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}`);
|
|
198
516
|
}
|
|
199
517
|
console.log('');
|
|
200
518
|
});
|
|
@@ -208,38 +526,192 @@ program
|
|
|
208
526
|
const config = loadConfig(cwd);
|
|
209
527
|
const storagePath = getStoragePath(cwd, config);
|
|
210
528
|
if (opts.latest || !snapshotId) {
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
else {
|
|
529
|
+
const contextMd = readContextMd(storagePath);
|
|
530
|
+
const latestMd = readLatestMd(storagePath);
|
|
531
|
+
if (!contextMd && !latestMd) {
|
|
216
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);
|
|
217
545
|
}
|
|
218
546
|
return;
|
|
219
547
|
}
|
|
220
|
-
|
|
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
|
+
}
|
|
221
561
|
if (!snapshot) {
|
|
222
562
|
console.log(`Snapshot not found: ${snapshotId}`);
|
|
223
563
|
return;
|
|
224
564
|
}
|
|
225
565
|
console.log(compressToMarkdown(snapshot));
|
|
226
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
|
+
});
|
|
227
660
|
program
|
|
228
661
|
.command('config')
|
|
229
662
|
.description('Show or set configuration')
|
|
230
663
|
.option('--interval <minutes>', 'Set time-based interval')
|
|
231
|
-
.option('--
|
|
232
|
-
.option('--
|
|
664
|
+
.option('--token-threshold <fraction>', 'Set context-used threshold (0.75 or 75)')
|
|
665
|
+
.option('--context-limit <tokens>', 'Override model context limit')
|
|
233
666
|
.option('--cwd <path>', 'Working directory')
|
|
234
667
|
.action((opts) => {
|
|
235
668
|
const cwd = opts.cwd ?? process.cwd();
|
|
236
669
|
const config = loadConfig(cwd);
|
|
237
670
|
const storagePath = getStoragePath(cwd, config);
|
|
238
|
-
if (opts.interval) {
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
+
}
|
|
243
715
|
return;
|
|
244
716
|
}
|
|
245
717
|
console.log('');
|
|
@@ -247,15 +719,15 @@ program
|
|
|
247
719
|
console.log('══════════════════════');
|
|
248
720
|
console.log(` Storage path: ${config.storagePath}`);
|
|
249
721
|
console.log(` Interval: ${config.intervalMinutes} minutes`);
|
|
250
|
-
console.log(`
|
|
251
|
-
console.log(` Context limit: ${config.contextLimitTokens.
|
|
252
|
-
console.log(` Smart default: ${config.smartDefault}`);
|
|
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'}`);
|
|
253
724
|
console.log(` Max snapshots: ${config.maxActiveSnapshots}`);
|
|
254
725
|
console.log(` Archive after: ${config.archiveAfterDays} days`);
|
|
255
726
|
console.log('');
|
|
256
727
|
console.log('Environment overrides:');
|
|
257
|
-
console.log(' BOOKMARK_INTERVAL,
|
|
258
|
-
console.log('
|
|
728
|
+
console.log(' BOOKMARK_INTERVAL, BOOKMARK_TOKEN_THRESHOLD, BOOKMARK_CONTEXT_LIMIT');
|
|
729
|
+
console.log(' BOOKMARK_STORAGE_PATH');
|
|
730
|
+
console.log(' BOOKMARK_VERBOSE');
|
|
259
731
|
console.log('');
|
|
260
732
|
});
|
|
261
733
|
program
|
|
@@ -292,6 +764,70 @@ program
|
|
|
292
764
|
}
|
|
293
765
|
});
|
|
294
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
|
+
}
|
|
295
831
|
const GREEN = '\x1b[32m';
|
|
296
832
|
const CYAN = '\x1b[36m';
|
|
297
833
|
const DIM = '\x1b[2m';
|
|
@@ -300,6 +836,9 @@ const RESET = '\x1b[0m';
|
|
|
300
836
|
/**
|
|
301
837
|
* Discover the most recent transcript file for the given working directory.
|
|
302
838
|
* Claude Code stores transcripts in ~/.claude/projects/<encoded-path>/*.jsonl
|
|
839
|
+
*
|
|
840
|
+
* Uses CLAUDE_SESSION_ID env var if available (set by Claude Code hooks),
|
|
841
|
+
* otherwise falls back to finding the most recent JSONL by file size + recency.
|
|
303
842
|
*/
|
|
304
843
|
function discoverTranscriptPath(cwd) {
|
|
305
844
|
const claudeProjectsDir = join(homedir(), '.claude', 'projects');
|
|
@@ -310,21 +849,38 @@ function discoverTranscriptPath(cwd) {
|
|
|
310
849
|
// Claude Code encodes paths by replacing / with -
|
|
311
850
|
// e.g., /Users/tyroneross/myproject → -Users-tyroneross-myproject
|
|
312
851
|
const encodedCwd = cwd.replace(/\//g, '-');
|
|
313
|
-
|
|
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);
|
|
314
855
|
if (!matchingDir)
|
|
315
856
|
return null;
|
|
316
857
|
const transcriptDir = join(claudeProjectsDir, matchingDir);
|
|
317
|
-
const
|
|
318
|
-
if (!
|
|
858
|
+
const dirStat = statSync(transcriptDir);
|
|
859
|
+
if (!dirStat.isDirectory())
|
|
319
860
|
return null;
|
|
320
|
-
|
|
861
|
+
const sessionId = process.env.CLAUDE_SESSION_ID;
|
|
862
|
+
// If we have a session ID, look for that specific file first
|
|
863
|
+
if (sessionId) {
|
|
864
|
+
const sessionFile = join(transcriptDir, `${sessionId}.jsonl`);
|
|
865
|
+
if (existsSync(sessionFile))
|
|
866
|
+
return sessionFile;
|
|
867
|
+
}
|
|
868
|
+
// Fall back: find the most recent .jsonl file by modification time,
|
|
869
|
+
// but skip tiny files (likely just file-history-snapshot entries)
|
|
321
870
|
const files = readdirSync(transcriptDir)
|
|
322
871
|
.filter(f => f.endsWith('.jsonl'))
|
|
323
|
-
.map(f =>
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
})
|
|
327
|
-
|
|
872
|
+
.map(f => {
|
|
873
|
+
const s = statSync(join(transcriptDir, f));
|
|
874
|
+
return { name: f, mtime: s.mtimeMs, size: s.size };
|
|
875
|
+
})
|
|
876
|
+
// Prefer files >10KB (actual conversations, not just metadata)
|
|
877
|
+
.sort((a, b) => {
|
|
878
|
+
const aSubstantial = a.size > 10_000 ? 1 : 0;
|
|
879
|
+
const bSubstantial = b.size > 10_000 ? 1 : 0;
|
|
880
|
+
if (aSubstantial !== bSubstantial)
|
|
881
|
+
return bSubstantial - aSubstantial;
|
|
882
|
+
return b.mtime - a.mtime;
|
|
883
|
+
});
|
|
328
884
|
return files[0] ? join(transcriptDir, files[0].name) : null;
|
|
329
885
|
}
|
|
330
886
|
catch {
|
|
@@ -347,8 +903,7 @@ function askQuestion(question) {
|
|
|
347
903
|
* Interactive (or default) setup for bookmark in a project.
|
|
348
904
|
*/
|
|
349
905
|
async function runSetup(cwd, useDefaults) {
|
|
350
|
-
let intervalMinutes =
|
|
351
|
-
let smartDefault = false;
|
|
906
|
+
let intervalMinutes = 5;
|
|
352
907
|
console.log('');
|
|
353
908
|
console.log(`${BOLD}Bookmark — Context Snapshot Setup${RESET}`);
|
|
354
909
|
console.log('═════════════════════════════════');
|
|
@@ -356,36 +911,22 @@ async function runSetup(cwd, useDefaults) {
|
|
|
356
911
|
if (!useDefaults) {
|
|
357
912
|
// Prompt for interval
|
|
358
913
|
console.log('Snapshot interval?');
|
|
359
|
-
console.log(
|
|
360
|
-
console.log(' 2)
|
|
361
|
-
console.log(
|
|
362
|
-
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)');
|
|
363
919
|
console.log('');
|
|
364
920
|
const intervalAnswer = await askQuestion(`${DIM}> ${RESET}`);
|
|
365
|
-
const intervalMap = { '1':
|
|
366
|
-
intervalMinutes = intervalMap[intervalAnswer] ??
|
|
367
|
-
console.log('');
|
|
368
|
-
// Prompt for smart mode
|
|
369
|
-
const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
|
|
370
|
-
console.log('Smart mode (uses Claude Haiku for better extraction, ~$0.001/snapshot)?');
|
|
371
|
-
if (hasApiKey) {
|
|
372
|
-
console.log(` ${GREEN}ANTHROPIC_API_KEY detected.${RESET}`);
|
|
373
|
-
}
|
|
374
|
-
else {
|
|
375
|
-
console.log(` ${DIM}No ANTHROPIC_API_KEY found — smart mode requires it.${RESET}`);
|
|
376
|
-
}
|
|
377
|
-
console.log(' 1) Enable smart mode');
|
|
378
|
-
console.log(` 2) Pattern-matching only ${DIM}(recommended)${RESET}`);
|
|
379
|
-
console.log('');
|
|
380
|
-
const smartAnswer = await askQuestion(`${DIM}> ${RESET}`);
|
|
381
|
-
smartDefault = smartAnswer === '1';
|
|
921
|
+
const intervalMap = { '1': 5, '2': 10, '3': 15, '4': 20, '5': 30 };
|
|
922
|
+
intervalMinutes = intervalMap[intervalAnswer] ?? 5;
|
|
382
923
|
console.log('');
|
|
383
924
|
}
|
|
384
925
|
// Run core setup (dirs, hooks, gitignore, CLAUDE.md)
|
|
385
926
|
const steps = setupProject(cwd);
|
|
386
927
|
// Write user preferences
|
|
387
|
-
writeConfig(cwd, { intervalMinutes
|
|
388
|
-
steps.push('Saved config to .
|
|
928
|
+
writeConfig(cwd, { intervalMinutes });
|
|
929
|
+
steps.push('Saved config to .bookmark/config.json');
|
|
389
930
|
// Initialize state with user preferences
|
|
390
931
|
const sp = getStoragePath(cwd);
|
|
391
932
|
const state = loadState(sp);
|
|
@@ -399,8 +940,7 @@ async function runSetup(cwd, useDefaults) {
|
|
|
399
940
|
console.log('');
|
|
400
941
|
console.log('Defaults:');
|
|
401
942
|
console.log(` Interval: ${intervalMinutes} minutes`);
|
|
402
|
-
console.log(
|
|
403
|
-
console.log(` Smart mode: ${smartDefault ? 'on' : 'off'}`);
|
|
943
|
+
console.log(' New session: suggest at 75% context used');
|
|
404
944
|
console.log('');
|
|
405
945
|
console.log(`${GREEN}Ready.${RESET} Start a Claude Code session — snapshots will be captured automatically.`);
|
|
406
946
|
console.log('');
|