@tyroneross/bookmark 0.2.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/.claude-plugin/marketplace.json +29 -12
  2. package/.claude-plugin/plugin.json +12 -8
  3. package/.codex-plugin/plugin.json +31 -0
  4. package/.mcp.json +8 -0
  5. package/CLAUDE.md +82 -38
  6. package/LICENSE +202 -21
  7. package/README.md +150 -57
  8. package/agents/snapshot-analyst.md +1 -1
  9. package/commands/bookmark.md +29 -0
  10. package/commands/feedback.md +37 -0
  11. package/commands/restore.md +14 -4
  12. package/commands/snapshot.md +19 -7
  13. package/commands/status.md +1 -1
  14. package/dist/cli/index.js +600 -66
  15. package/dist/cli/index.js.map +1 -1
  16. package/dist/cli/snapshot-resolution.d.ts +15 -0
  17. package/dist/cli/snapshot-resolution.d.ts.map +1 -0
  18. package/dist/cli/snapshot-resolution.js +21 -0
  19. package/dist/cli/snapshot-resolution.js.map +1 -0
  20. package/dist/config.d.ts +19 -0
  21. package/dist/config.d.ts.map +1 -1
  22. package/dist/config.js +150 -19
  23. package/dist/config.js.map +1 -1
  24. package/dist/context/freshness.d.ts +3 -0
  25. package/dist/context/freshness.d.ts.map +1 -0
  26. package/dist/context/freshness.js +29 -0
  27. package/dist/context/freshness.js.map +1 -0
  28. package/dist/context/handoff-prompt.d.ts +12 -0
  29. package/dist/context/handoff-prompt.d.ts.map +1 -0
  30. package/dist/context/handoff-prompt.js +29 -0
  31. package/dist/context/handoff-prompt.js.map +1 -0
  32. package/dist/index.d.ts +7 -4
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +5 -3
  35. package/dist/index.js.map +1 -1
  36. package/dist/mcp/server.d.ts +9 -0
  37. package/dist/mcp/server.d.ts.map +1 -0
  38. package/dist/mcp/server.js +85 -0
  39. package/dist/mcp/server.js.map +1 -0
  40. package/dist/mcp/tools.d.ts +97 -0
  41. package/dist/mcp/tools.d.ts.map +1 -0
  42. package/dist/mcp/tools.js +304 -0
  43. package/dist/mcp/tools.js.map +1 -0
  44. package/dist/registry.d.ts +40 -0
  45. package/dist/registry.d.ts.map +1 -0
  46. package/dist/registry.js +134 -0
  47. package/dist/registry.js.map +1 -0
  48. package/dist/restore/index.d.ts +13 -1
  49. package/dist/restore/index.d.ts.map +1 -1
  50. package/dist/restore/index.js +154 -256
  51. package/dist/restore/index.js.map +1 -1
  52. package/dist/setup/auto-setup.d.ts +8 -2
  53. package/dist/setup/auto-setup.d.ts.map +1 -1
  54. package/dist/setup/auto-setup.js +281 -66
  55. package/dist/setup/auto-setup.js.map +1 -1
  56. package/dist/setup/configure-hooks.d.ts +14 -3
  57. package/dist/setup/configure-hooks.d.ts.map +1 -1
  58. package/dist/setup/configure-hooks.js +156 -24
  59. package/dist/setup/configure-hooks.js.map +1 -1
  60. package/dist/snapshot/capture.d.ts +12 -10
  61. package/dist/snapshot/capture.d.ts.map +1 -1
  62. package/dist/snapshot/capture.js +36 -48
  63. package/dist/snapshot/capture.js.map +1 -1
  64. package/dist/snapshot/compress.d.ts +3 -3
  65. package/dist/snapshot/compress.d.ts.map +1 -1
  66. package/dist/snapshot/compress.js +16 -51
  67. package/dist/snapshot/compress.js.map +1 -1
  68. package/dist/snapshot/storage.d.ts +1 -0
  69. package/dist/snapshot/storage.d.ts.map +1 -1
  70. package/dist/snapshot/storage.js +7 -6
  71. package/dist/snapshot/storage.js.map +1 -1
  72. package/dist/threshold/state.d.ts +5 -0
  73. package/dist/threshold/state.d.ts.map +1 -1
  74. package/dist/threshold/state.js +28 -2
  75. package/dist/threshold/state.js.map +1 -1
  76. package/dist/threshold/token-usage.d.ts +29 -0
  77. package/dist/threshold/token-usage.d.ts.map +1 -0
  78. package/dist/threshold/token-usage.js +128 -0
  79. package/dist/threshold/token-usage.js.map +1 -0
  80. package/dist/trails/identity.d.ts +76 -0
  81. package/dist/trails/identity.d.ts.map +1 -0
  82. package/dist/trails/identity.js +117 -0
  83. package/dist/trails/identity.js.map +1 -0
  84. package/dist/trails/reader.d.ts +1 -1
  85. package/dist/trails/reader.js +2 -2
  86. package/dist/trails/reader.js.map +1 -1
  87. package/dist/trails/writer.d.ts +2 -8
  88. package/dist/trails/writer.d.ts.map +1 -1
  89. package/dist/trails/writer.js +33 -191
  90. package/dist/trails/writer.js.map +1 -1
  91. package/dist/transcript/extractor.d.ts +8 -9
  92. package/dist/transcript/extractor.d.ts.map +1 -1
  93. package/dist/transcript/extractor.js +97 -360
  94. package/dist/transcript/extractor.js.map +1 -1
  95. package/dist/types.d.ts +34 -41
  96. package/dist/types.d.ts.map +1 -1
  97. package/hooks/hooks.json +12 -14
  98. package/package.json +13 -7
  99. package/scripts/install-plugin.sh +48 -0
  100. package/skills/context-continuity/SKILL.md +43 -24
  101. package/commands/activate.md +0 -20
  102. package/dist/transcript/estimator.d.ts +0 -20
  103. package/dist/transcript/estimator.d.ts.map +0 -1
  104. package/dist/transcript/estimator.js +0 -95
  105. package/dist/transcript/estimator.js.map +0 -1
@@ -0,0 +1,134 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { basename, dirname, join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ const REGISTRY_VERSION = '1.0.0';
5
+ const MAX_ENTRIES = 500;
6
+ /**
7
+ * Resolve a human-readable project name from a directory path.
8
+ *
9
+ * Resolution order:
10
+ * 1. package.json "name" field in cwd
11
+ * 2. basename of git repo root (walk up until .git is found)
12
+ * 3. basename of cwd — unless it's the home directory
13
+ * 4. Fallback: basename(cwd) regardless
14
+ */
15
+ export function resolveProjectName(cwd) {
16
+ const home = homedir();
17
+ // 1. package.json name
18
+ const pkgPath = join(cwd, 'package.json');
19
+ if (existsSync(pkgPath)) {
20
+ try {
21
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
22
+ if (pkg.name && pkg.name.trim()) {
23
+ // Strip scoped package prefix (@scope/name → name)
24
+ return pkg.name.replace(/^@[^/]+\//, '').trim();
25
+ }
26
+ }
27
+ catch {
28
+ // Fall through
29
+ }
30
+ }
31
+ // 2. Walk up looking for .git directory
32
+ let dir = cwd;
33
+ const root = dirname(home); // Stop before going above home's parent
34
+ while (dir && dir !== root && dir !== dirname(dir)) {
35
+ if (existsSync(join(dir, '.git'))) {
36
+ return basename(dir);
37
+ }
38
+ dir = dirname(dir);
39
+ }
40
+ // 3. Avoid returning home dir basename as the name
41
+ const cwdBase = basename(cwd);
42
+ if (cwd === home || cwdBase === basename(home)) {
43
+ return 'home';
44
+ }
45
+ return cwdBase;
46
+ }
47
+ export function getRegistryPath() {
48
+ return join(homedir(), '.bookmark', 'registry.json');
49
+ }
50
+ function ensureRegistryDir() {
51
+ const dir = join(homedir(), '.bookmark');
52
+ if (!existsSync(dir))
53
+ mkdirSync(dir, { recursive: true });
54
+ }
55
+ export function loadRegistry() {
56
+ const path = getRegistryPath();
57
+ if (!existsSync(path)) {
58
+ return { version: REGISTRY_VERSION, last_updated: 0, snapshots: [] };
59
+ }
60
+ try {
61
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
62
+ if (!parsed.snapshots)
63
+ parsed.snapshots = [];
64
+ return parsed;
65
+ }
66
+ catch {
67
+ return { version: REGISTRY_VERSION, last_updated: 0, snapshots: [] };
68
+ }
69
+ }
70
+ function saveRegistry(registry) {
71
+ ensureRegistryDir();
72
+ registry.last_updated = Date.now();
73
+ writeFileSync(getRegistryPath(), JSON.stringify(registry, null, 2), 'utf-8');
74
+ }
75
+ export function appendToRegistry(snapshot, canonicalFile) {
76
+ try {
77
+ const registry = loadRegistry();
78
+ const entry = {
79
+ id: snapshot.snapshot_id,
80
+ timestamp: snapshot.timestamp,
81
+ trigger: snapshot.trigger,
82
+ project_path: snapshot.project_path,
83
+ project_name: resolveProjectName(snapshot.project_path),
84
+ canonical_file: canonicalFile,
85
+ files_changed_count: snapshot.files_changed.length,
86
+ };
87
+ // Dedupe by id, newest wins
88
+ registry.snapshots = [entry, ...registry.snapshots.filter(e => e.id !== entry.id)];
89
+ if (registry.snapshots.length > MAX_ENTRIES) {
90
+ registry.snapshots.length = MAX_ENTRIES;
91
+ }
92
+ registry.last_project = {
93
+ path: snapshot.project_path,
94
+ name: entry.project_name,
95
+ last_activity_at: snapshot.timestamp,
96
+ };
97
+ saveRegistry(registry);
98
+ }
99
+ catch {
100
+ // Never break capture for registry bookkeeping
101
+ }
102
+ }
103
+ export function touchLastProject(projectPath) {
104
+ try {
105
+ const registry = loadRegistry();
106
+ registry.last_project = {
107
+ path: projectPath,
108
+ name: resolveProjectName(projectPath),
109
+ last_activity_at: Date.now(),
110
+ };
111
+ saveRegistry(registry);
112
+ }
113
+ catch {
114
+ // Silent — never break restore for bookkeeping
115
+ }
116
+ }
117
+ export function findById(id) {
118
+ const registry = loadRegistry();
119
+ return registry.snapshots.find(e => e.id === id) ?? null;
120
+ }
121
+ export function getLastProject() {
122
+ return loadRegistry().last_project ?? null;
123
+ }
124
+ /** Prune entries whose canonical_file is missing. Lazy cleanup on read paths. */
125
+ export function pruneStale() {
126
+ const registry = loadRegistry();
127
+ const live = registry.snapshots.filter(e => existsSync(e.canonical_file));
128
+ if (live.length !== registry.snapshots.length) {
129
+ registry.snapshots = live;
130
+ saveRegistry(registry);
131
+ }
132
+ return registry;
133
+ }
134
+ //# sourceMappingURL=registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.js","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"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"}
@@ -7,7 +7,19 @@ export interface RestoreOptions {
7
7
  }
8
8
  /**
9
9
  * Generate restoration context for a SessionStart hook.
10
- * Returns a HookOutput with systemMessage containing the LATEST.md content.
10
+ *
11
+ * v0.4 adds:
12
+ * 1. BOOKMARK_IDENTITY parsing + repo_path validation
13
+ * 2. Home-scope pointer delegation to a canonical repo bookmark
14
+ * 3. Hard staleness block at 72h (instead of soft warning only)
15
+ *
16
+ * Cascade:
17
+ * 1. bookmark.context.md — if present and useful
18
+ * 1a. If identity scope=home + points_to_canonical, follow the pointer
19
+ * 1b. If identity scope=repo and path mismatch, prefix a warning
20
+ * 1c. If age >= STALENESS_HARD_BLOCK_HOURS, return staleness block
21
+ * 2. LATEST.md — file tracking fallback
22
+ * 3. Empty
11
23
  */
12
24
  export declare function restoreContext(options: RestoreOptions): HookOutput;
13
25
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/restore/index.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,UAAU,EAA6D,MAAM,aAAa,CAAC;AAEzG,MAAM,WAAW,cAAc;IAC7B,MAAM,CAAC,EAAE,SAAS,GAAG,QAAQ,GAAG,SAAS,GAAG,OAAO,CAAC;IACpD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,gBAAgB,GAAG,MAAM,GAAG,UAAU,CAAC;CACjD;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,cAAc,GAAG,UAAU,CAoElE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/restore/index.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,UAAU,EAAiB,MAAM,aAAa,CAAC;AAE7D,MAAM,WAAW,cAAc;IAC7B,MAAM,CAAC,EAAE,SAAS,GAAG,QAAQ,GAAG,SAAS,GAAG,OAAO,CAAC;IACpD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,gBAAgB,GAAG,MAAM,GAAG,UAAU,CAAC;CACjD;AAWD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,cAAc,GAAG,UAAU,CAqFlE"}
@@ -1,279 +1,180 @@
1
- import { existsSync } from 'node:fs';
2
- import { readLatestMd, loadLatestSnapshot, getSnapshotCount, loadSnapshotChain } from '../snapshot/storage.js';
1
+ import { existsSync, statSync, unlinkSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { readLatestMd, getSnapshotCount } from '../snapshot/storage.js';
3
4
  import { readContextMd } from '../trails/reader.js';
5
+ import { parseIdentity, validateRepoIdentity, followPointer, } from '../trails/identity.js';
4
6
  import { loadState, saveState, resetForNewSession, incrementCompaction } from '../threshold/state.js';
5
7
  import { loadConfig, getStoragePath } from '../config.js';
8
+ import { touchLastProject } from '../registry.js';
9
+ /**
10
+ * Hours after which a bookmark is considered too stale to auto-restore.
11
+ * Below this threshold: restore with a soft warning.
12
+ * At or above: hard-block — return an empty context with guidance instead
13
+ * of risking a confident wrong start from stale content.
14
+ */
15
+ const STALENESS_HARD_BLOCK_HOURS = 72;
16
+ const STALENESS_SOFT_WARN_HOURS = 24;
6
17
  /**
7
18
  * Generate restoration context for a SessionStart hook.
8
- * Returns a HookOutput with systemMessage containing the LATEST.md content.
19
+ *
20
+ * v0.4 adds:
21
+ * 1. BOOKMARK_IDENTITY parsing + repo_path validation
22
+ * 2. Home-scope pointer delegation to a canonical repo bookmark
23
+ * 3. Hard staleness block at 72h (instead of soft warning only)
24
+ *
25
+ * Cascade:
26
+ * 1. bookmark.context.md — if present and useful
27
+ * 1a. If identity scope=home + points_to_canonical, follow the pointer
28
+ * 1b. If identity scope=repo and path mismatch, prefix a warning
29
+ * 1c. If age >= STALENESS_HARD_BLOCK_HOURS, return staleness block
30
+ * 2. LATEST.md — file tracking fallback
31
+ * 3. Empty
9
32
  */
10
33
  export function restoreContext(options) {
11
34
  const config = loadConfig(options.cwd);
12
35
  const storagePath = getStoragePath(options.cwd, config);
13
36
  const state = loadState(storagePath);
14
- // Handle session state transitions
15
37
  handleSessionTransition(storagePath, state, options, config.thresholds);
16
- // Check if restoration is needed
17
- if (!config.restoreOnSessionStart) {
18
- return {};
38
+ const stopRequestedPath = join(storagePath, '.stop-requested');
39
+ if (existsSync(stopRequestedPath)) {
40
+ try {
41
+ unlinkSync(stopRequestedPath);
42
+ }
43
+ catch { /* ignore */ }
19
44
  }
20
- // On resume, context is likely intact — skip restoration
21
- if (options.source === 'resume') {
45
+ if (!config.restoreOnSessionStart)
22
46
  return {};
23
- }
24
- // Check for existing snapshots
25
- const snapshotCount = getSnapshotCount(storagePath);
26
- if (snapshotCount === 0) {
27
- // First run — tell Claude that bookmark is active
28
- return {
29
- systemMessage: '[Bookmark: Active — no snapshots yet. Snapshots will be captured automatically ' +
30
- 'before compaction, on 20-minute intervals, and at session end.]\n\n' +
31
- 'Briefly let the user know that Bookmark is active and will start capturing context snapshots automatically.',
32
- };
33
- }
34
- // Read LATEST.md
35
- const latestMd = readLatestMd(storagePath);
36
- if (!latestMd) {
47
+ if (options.source === 'resume')
37
48
  return {};
38
- }
39
- if (options.format === 'json') {
40
- const snapshot = loadLatestSnapshot(storagePath);
41
- return {
42
- systemMessage: JSON.stringify(snapshot, null, 2),
43
- };
44
- }
45
- if (options.format === 'markdown') {
46
- return {
47
- systemMessage: latestMd,
48
- };
49
- }
50
- // Default: system_message format — structured for fast LLM pickup
51
- // Prefer trail-routed CONTEXT.md (compact trailhead with routing pointers)
52
- const contextMd = readContextMd(storagePath);
53
- if (contextMd) {
54
- return { systemMessage: contextMd };
55
- }
56
- // Fallback: chain-aware restoration from raw snapshots
57
- const chain = loadSnapshotChain(storagePath);
58
- if (chain.length > 1) {
59
- const merged = mergeSnapshotChain(chain);
60
- return { systemMessage: buildChainRestoration(merged, chain.length, snapshotCount) };
61
- }
62
- const snapshot = loadLatestSnapshot(storagePath);
63
- const systemMessage = snapshot
64
- ? buildSmartRestoration(snapshot, snapshotCount)
65
- : buildFallbackRestoration(latestMd, snapshotCount);
66
- return { systemMessage };
67
- }
68
- /**
69
- * Merge a chain of snapshots into one unified context.
70
- * Chain is in chronological order (oldest first).
71
- *
72
- * Strategy:
73
- * - Intent/progress: Latest non-Unknown wins (most current)
74
- * - Files: Union by path, merge operations, sum line counts
75
- * - Decisions: Accumulate unique across chain, cap at 6
76
- * - Open items: Latest snapshot only (prior items are done or superseded)
77
- * - Sentiment: Latest non-neutral wins
78
- */
79
- function mergeSnapshotChain(chain) {
80
- const latest = chain[chain.length - 1];
81
- const oldest = chain[0];
82
- // Intent/progress: walk backward, take first non-Unknown
83
- let intent = 'Unknown';
84
- let progress = 'Unknown';
85
- let sentiment = 'neutral';
86
- for (let i = chain.length - 1; i >= 0; i--) {
87
- if (intent === 'Unknown' && chain[i].intent && chain[i].intent !== 'Unknown') {
88
- intent = chain[i].intent;
89
- }
90
- if (progress === 'Unknown' && chain[i].progress && chain[i].progress !== 'Unknown') {
91
- progress = chain[i].progress;
92
- }
93
- if (sentiment === 'neutral' && chain[i].user_sentiment && chain[i].user_sentiment !== 'neutral') {
94
- sentiment = chain[i].user_sentiment;
95
- }
96
- }
97
- // Files: merge by path across all snapshots
98
- const fileMap = new Map();
99
- for (const snap of chain) {
100
- for (const f of snap.files_changed) {
101
- const existing = fileMap.get(f.path);
102
- if (existing) {
103
- // Merge operations (union)
104
- const ops = new Set([...existing.operations, ...f.operations]);
105
- existing.operations = [...ops];
106
- // Sum line counts
107
- existing.lines_changed = (existing.lines_changed ?? 0) + (f.lines_changed ?? 0);
108
- }
109
- else {
110
- fileMap.set(f.path, { ...f });
49
+ // Primary: bookmark.context.md with identity-aware handling
50
+ const rawContextMd = readContextMd(storagePath);
51
+ if (rawContextMd && isContextMdUseful(rawContextMd)) {
52
+ const contextPath = join(storagePath, 'bookmark.context.md');
53
+ const { identity, bodyWithoutIdentity } = parseIdentity(rawContextMd);
54
+ // 1a. Home-scope pointer? Follow it to the canonical repo bookmark.
55
+ if (identity?.scope === 'home') {
56
+ const target = followPointer(identity);
57
+ if (target) {
58
+ const targetAge = target.staleness_hours ?? 0;
59
+ if (targetAge >= STALENESS_HARD_BLOCK_HOURS) {
60
+ trackRestore(storagePath, 0);
61
+ return { systemMessage: buildHardStalenessMessage(target.canonical_path, targetAge) };
62
+ }
63
+ // Strip identity block from target content for cleaner display
64
+ const { bodyWithoutIdentity: targetBody } = parseIdentity(target.content);
65
+ const header = buildPointerFollowHeader(identity, target.canonical_path, targetAge);
66
+ const message = `${header}\n\n${targetBody}`;
67
+ trackRestore(storagePath, message.length);
68
+ if (identity.points_to_canonical) {
69
+ touchLastProject(derivProjectFromCanonical(identity.points_to_canonical));
70
+ }
71
+ return { systemMessage: message };
111
72
  }
73
+ // Pointer target missing — fall through to present the pointer body itself
112
74
  }
113
- }
114
- // Decisions: accumulate unique across chain
115
- const decisionSet = new Set();
116
- const allDecisions = [];
117
- for (const snap of chain) {
118
- for (const d of snap.decisions) {
119
- // Deduplicate by normalized prefix (first 60 chars, lowered)
120
- const key = d.description.slice(0, 60).toLowerCase().trim();
121
- if (!decisionSet.has(key)) {
122
- decisionSet.add(key);
123
- allDecisions.push(d);
124
- }
75
+ // 1c. Hard staleness block
76
+ const ageHours = getAgeHours(contextPath);
77
+ if (ageHours !== null && ageHours >= STALENESS_HARD_BLOCK_HOURS) {
78
+ trackRestore(storagePath, 0);
79
+ return { systemMessage: buildHardStalenessMessage(contextPath, ageHours) };
125
80
  }
126
- }
127
- // Open items: latest snapshot only
128
- const openItems = latest.open_items;
129
- const files = [...fileMap.values()];
130
- const totalLinesChanged = files.reduce((sum, f) => sum + (f.lines_changed ?? 0), 0);
131
- return {
132
- intent,
133
- progress,
134
- sentiment,
135
- files,
136
- decisions: allDecisions.slice(0, 6),
137
- openItems,
138
- projectPath: latest.project_path,
139
- compactionCycle: latest.compaction_cycle,
140
- totalLinesChanged,
141
- chainSpan: { oldest: oldest.timestamp, newest: latest.timestamp },
142
- };
81
+ // 1b. Path-mismatch warning for repo-scoped identities
82
+ const mismatchWarning = identity ? validateRepoIdentity(identity, options.cwd) : null;
83
+ // Soft staleness warning
84
+ const softWarning = ageHours !== null && ageHours >= STALENESS_SOFT_WARN_HOURS
85
+ ? `[Note: This bookmark context is ${ageHours}h old and may be outdated.]`
86
+ : null;
87
+ const prefixes = [mismatchWarning, softWarning].filter(Boolean).join('\n\n');
88
+ const message = prefixes ? `${prefixes}\n\n${bodyWithoutIdentity}` : bodyWithoutIdentity;
89
+ trackRestore(storagePath, message.length);
90
+ touchLastProject(options.cwd);
91
+ return { systemMessage: message };
92
+ }
93
+ if (rawContextMd) {
94
+ trackBoilerplateCaught(storagePath);
95
+ }
96
+ // Fallback: LATEST.md
97
+ const snapshotCount = getSnapshotCount(storagePath);
98
+ const latestMd = readLatestMd(storagePath);
99
+ if (latestMd) {
100
+ const message = buildFallbackRestoration(latestMd, snapshotCount);
101
+ trackRestore(storagePath, message.length);
102
+ touchLastProject(options.cwd);
103
+ return { systemMessage: message };
104
+ }
105
+ // No local bookmark and no LATEST.md — stay silent rather than inject
106
+ // another repo's context. The home-scope pointer at ~/.bookmark/bookmark.context.md
107
+ // is the supported way to redirect from an empty CWD to a canonical project.
108
+ return {};
143
109
  }
144
- /**
145
- * Build restoration from merged chain context.
146
- * Target: <600 tokens — covers multiple compaction cycles efficiently.
147
- */
148
- function buildChainRestoration(merged, chainLength, snapshotCount) {
149
- const lines = [];
150
- const spanMinutes = Math.round((merged.chainSpan.newest - merged.chainSpan.oldest) / 60000);
151
- const spanStr = spanMinutes > 60
152
- ? `${Math.round(spanMinutes / 60)}h ${spanMinutes % 60}m`
153
- : `${spanMinutes}m`;
154
- lines.push(`[Bookmark: Context recovered — ${chainLength} snapshots merged across ${spanStr}]`);
155
- lines.push('');
156
- // Intent
157
- if (merged.intent !== 'Unknown') {
158
- lines.push(`**User intent:** ${merged.intent}`);
159
- }
160
- // Progress
161
- if (merged.progress !== 'Unknown') {
162
- lines.push(`**Progress:** ${merged.progress}`);
163
- }
164
- // Sentiment
165
- if (merged.sentiment !== 'neutral') {
166
- lines.push(`**User feedback:** ${merged.sentiment}`);
167
- }
168
- lines.push('');
169
- // Files — show cumulative work
170
- if (merged.files.length > 0) {
171
- lines.push(`**Files modified:** ${merged.files.length} files (~${merged.totalLinesChanged} lines total)`);
172
- // Sort by lines changed descending — most important files first
173
- const sorted = [...merged.files].sort((a, b) => (b.lines_changed ?? 0) - (a.lines_changed ?? 0));
174
- for (const f of sorted.slice(0, 10)) {
175
- const shortPath = f.path.includes(merged.projectPath)
176
- ? f.path.slice(merged.projectPath.length).replace(/^\//, '')
177
- : f.path;
178
- const lc = f.lines_changed ? ` ~${f.lines_changed}L` : '';
179
- lines.push(`- \`${shortPath}\` (${f.operations.join('/')}${lc})`);
180
- }
181
- if (merged.files.length > 10) {
182
- lines.push(`- ...+${merged.files.length - 10} more`);
183
- }
184
- lines.push('');
185
- }
186
- // Decisions — accumulated across chain
187
- if (merged.decisions.length > 0) {
188
- lines.push('**Key decisions (across session):**');
189
- for (const d of merged.decisions) {
190
- lines.push(`- ${d.description.slice(0, 120)}`);
191
- }
192
- lines.push('');
193
- }
194
- // Open items — from latest snapshot only
195
- if (merged.openItems.length > 0) {
196
- lines.push('**Remaining:**');
197
- for (const item of merged.openItems.slice(0, 4)) {
198
- lines.push(`- ${item.description.slice(0, 120)}`);
199
- }
200
- lines.push('');
201
- }
202
- // Chain metadata
203
- if (merged.compactionCycle > 0) {
204
- lines.push(`> Compaction cycle ${merged.compactionCycle} — context compressed ${merged.compactionCycle} time(s), this merges all recovered state.`);
205
- }
206
- if (snapshotCount > chainLength) {
207
- lines.push(`> ${snapshotCount} total snapshots. \`/bookmark:list\` for full history.`);
208
- }
209
- lines.push('');
210
- lines.push('Resume working on the task above. Do not re-read files you already modified unless the user asks.');
211
- return lines.join('\n');
110
+ /** Derive the project path from a canonical bookmark.context.md path. */
111
+ function derivProjectFromCanonical(canonicalPath) {
112
+ // .../<project>/.bookmark/bookmark.context.md <project>
113
+ const marker = `/.bookmark/`;
114
+ const idx = canonicalPath.lastIndexOf(marker);
115
+ return idx > 0 ? canonicalPath.slice(0, idx) : canonicalPath;
212
116
  }
213
- // ─── Single Snapshot Restoration ───
214
- /**
215
- * Build a restoration message optimized for LLM context recovery.
216
- * Structured as a briefing: intent → progress → what changed → what's next.
217
- * Target: <500 tokens — every token here saves 10+ tokens of re-exploration.
218
- */
219
- function buildSmartRestoration(snapshot, snapshotCount) {
220
- const lines = [];
221
- lines.push('[Bookmark: Context recovered pick up where you left off]');
222
- lines.push('');
223
- // Intent — what the user is trying to do
224
- if (snapshot.intent && snapshot.intent !== 'Unknown') {
225
- lines.push(`**User intent:** ${snapshot.intent}`);
226
- }
227
- // Progress — how far along
228
- if (snapshot.progress && snapshot.progress !== 'Unknown') {
229
- lines.push(`**Progress:** ${snapshot.progress}`);
230
- }
231
- // Sentiment — user's reaction to work so far
232
- if (snapshot.user_sentiment && snapshot.user_sentiment !== 'neutral') {
233
- lines.push(`**User feedback:** ${snapshot.user_sentiment}`);
234
- }
235
- lines.push('');
236
- // Files changed — concrete work done
237
- if (snapshot.files_changed.length > 0) {
238
- const totalLines = snapshot.files_changed.reduce((sum, f) => sum + (f.lines_changed ?? 0), 0);
239
- lines.push(`**Files modified:** ${snapshot.files_changed.length} files (~${totalLines} lines)`);
240
- for (const f of snapshot.files_changed.slice(0, 8)) {
241
- const shortPath = f.path.includes(snapshot.project_path)
242
- ? f.path.slice(snapshot.project_path.length).replace(/^\//, '')
243
- : f.path;
244
- const lc = f.lines_changed ? ` ~${f.lines_changed}L` : '';
245
- lines.push(`- \`${shortPath}\` (${f.operations.join('/')}${lc})`);
246
- }
247
- if (snapshot.files_changed.length > 8) {
248
- lines.push(`- ...+${snapshot.files_changed.length - 8} more`);
249
- }
250
- lines.push('');
251
- }
252
- // Key decisions — only if LLM-extracted (high quality)
253
- if (snapshot.decisions.length > 0) {
254
- lines.push('**Key decisions:**');
255
- for (const d of snapshot.decisions.slice(0, 4)) {
256
- lines.push(`- ${d.description.slice(0, 120)}`);
257
- }
258
- lines.push('');
117
+ /** Record a successful restore — chars injected / 4 ≈ tokens */
118
+ function trackRestore(storagePath, charCount) {
119
+ try {
120
+ const state = loadState(storagePath);
121
+ state.restores_performed = (state.restores_performed ?? 0) + 1;
122
+ state.tokens_injected = (state.tokens_injected ?? 0) + Math.round(charCount / 4);
123
+ saveState(storagePath, state);
124
+ }
125
+ catch { /* never break restore for tracking */ }
126
+ }
127
+ function trackBoilerplateCaught(storagePath) {
128
+ try {
129
+ const state = loadState(storagePath);
130
+ state.boilerplate_caught = (state.boilerplate_caught ?? 0) + 1;
131
+ saveState(storagePath, state);
259
132
  }
260
- // Open items what's left
261
- if (snapshot.open_items.length > 0) {
262
- lines.push('**Remaining:**');
263
- for (const item of snapshot.open_items.slice(0, 4)) {
264
- lines.push(`- ${item.description.slice(0, 120)}`);
265
- }
266
- lines.push('');
133
+ catch { /* never break restore for tracking */ }
134
+ }
135
+ function isContextMdUseful(content) {
136
+ if (content.length < 200)
137
+ return false;
138
+ if (content.startsWith('[Bookmark Context') && !content.includes('## '))
139
+ return false;
140
+ const markers = ['## ', '**Task', '**Status', '**Progress', 'done', 'remaining', '- '];
141
+ return markers.some(m => content.includes(m));
142
+ }
143
+ function getAgeHours(path) {
144
+ try {
145
+ const mtime = statSync(path).mtimeMs;
146
+ return Math.round((Date.now() - mtime) / (1000 * 60 * 60));
267
147
  }
268
- // Compaction chain info
269
- if (snapshot.compaction_cycle > 0) {
270
- lines.push(`> Compaction cycle ${snapshot.compaction_cycle} — context was compressed, this is the recovered state.`);
148
+ catch {
149
+ return null;
271
150
  }
272
- if (snapshotCount > 1) {
273
- lines.push(`> ${snapshotCount} snapshots in history. \`/bookmark:list\` for full chain.`);
151
+ }
152
+ function buildHardStalenessMessage(path, ageHours) {
153
+ return [
154
+ `[Bookmark: auto-restore BLOCKED — source is ${ageHours}h stale (threshold ${STALENESS_HARD_BLOCK_HOURS}h).]`,
155
+ '',
156
+ `The bookmark file at ${path} is too old to treat as current context.`,
157
+ 'Stale auto-restore creates confident wrong starts — worse than no restore at all.',
158
+ '',
159
+ 'To proceed, either:',
160
+ '- Run `/bookmark:list` and pick a specific snapshot explicitly',
161
+ '- Read the stale file manually if you still want its content: ' +
162
+ `\`cat "${path}"\``,
163
+ '- Ask the user what they were working on most recently',
164
+ ].join('\n');
165
+ }
166
+ function buildPointerFollowHeader(pointer, canonicalPath, ageHours) {
167
+ const lines = [
168
+ `[Bookmark: followed home-scope pointer → repo-scope bookmark]`,
169
+ '',
170
+ `Canonical file: ${canonicalPath}`,
171
+ ];
172
+ if (pointer.points_to_project) {
173
+ lines.push(`Project: ${pointer.points_to_project}`);
174
+ }
175
+ if (ageHours >= STALENESS_SOFT_WARN_HOURS) {
176
+ lines.push(`Age: ${ageHours}h (approaching staleness threshold of ${STALENESS_HARD_BLOCK_HOURS}h)`);
274
177
  }
275
- lines.push('');
276
- lines.push('Resume working on the task above. Do not re-read files you already modified unless the user asks.');
277
178
  return lines.join('\n');
278
179
  }
279
180
  function buildFallbackRestoration(latestMd, snapshotCount) {
@@ -294,16 +195,13 @@ function handleSessionTransition(storagePath, state, options, thresholds) {
294
195
  switch (source) {
295
196
  case 'startup':
296
197
  case 'clear':
297
- // New session — reset compaction count
298
198
  updatedState = resetForNewSession(state, sessionId, thresholds);
299
199
  break;
300
200
  case 'compact':
301
- // After compaction — increment compaction count
302
201
  updatedState = incrementCompaction(state, thresholds);
303
202
  updatedState.session_id = sessionId;
304
203
  break;
305
204
  case 'resume':
306
- // Resuming — keep state, just update session ID if different
307
205
  updatedState = { ...state, session_id: sessionId, last_event_time: Date.now() };
308
206
  break;
309
207
  default: