@misterhuydo/cairn-mcp 1.7.1 → 1.8.1

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/bin/cairn-cli.js CHANGED
@@ -14,91 +14,148 @@ const MINIFY_LANGS = new Set(['java', 'typescript', 'javascript', 'vue', 'python
14
14
 
15
15
  // Memory sync scripts deployed to ~/.claude/scripts/ during global install.
16
16
  // Using forward slashes in hook commands — bash on Windows handles them fine.
17
- const SYNC_FORWARD_SCRIPT = `\
18
- """
19
- Forward sync: Claude Code auto-memory -> .cairn/memory/auto-memory/
20
- Triggered as a PostToolUse hook on Write.
21
- Only fires when the written file is inside Claude's projects memory dir
22
- and the current project has a .cairn/ folder.
23
- """
24
- import sys, json, os, shutil
25
-
26
- data = json.load(sys.stdin)
27
- fp = data.get('tool_input', {}).get('file_path', '')
28
-
29
- user_home = os.path.expanduser('~')
30
- claude_projects = os.path.normpath(os.path.join(user_home, '.claude', 'projects'))
31
-
32
- fp_norm = os.path.normpath(fp)
33
- if not fp_norm.startswith(claude_projects) or 'memory' not in fp_norm:
34
- sys.exit(0)
35
-
36
- cwd = os.getcwd()
37
- if not os.path.exists(os.path.join(cwd, '.cairn')):
38
- sys.exit(0)
39
-
40
- memory_dir = os.path.dirname(fp_norm)
41
- dest = os.path.join(cwd, '.cairn', 'memory', 'auto-memory')
42
- os.makedirs(dest, exist_ok=True)
43
-
44
- for f in os.listdir(memory_dir):
45
- src = os.path.join(memory_dir, f)
46
- if os.path.isfile(src):
47
- shutil.copy2(src, dest)
17
+ const SYNC_FORWARD_SCRIPT = `#!/usr/bin/env node
18
+ // Forward sync: Claude Code auto-memory -> .cairn/memory/auto-memory/
19
+ // Triggered as a PostToolUse hook on Write.
20
+ // Only fires when the written file is inside Claude's projects memory dir
21
+ // and the current project has a .cairn/ folder.
22
+ import fs from 'node:fs';
23
+ import os from 'node:os';
24
+ import path from 'node:path';
25
+
26
+ let stdinData = '';
27
+ process.stdin.setEncoding('utf8');
28
+ process.stdin.on('data', c => { stdinData += c; });
29
+ process.stdin.on('end', () => {
30
+ let fp;
31
+ try { fp = JSON.parse(stdinData)?.tool_input?.file_path || ''; } catch { process.exit(0); }
32
+ if (!fp) process.exit(0);
33
+
34
+ const claudeProjects = path.normalize(path.join(os.homedir(), '.claude', 'projects'));
35
+ const fpNorm = path.normalize(fp);
36
+ if (!fpNorm.startsWith(claudeProjects) || !fpNorm.includes('memory')) process.exit(0);
37
+
38
+ const cwd = process.cwd();
39
+ if (!fs.existsSync(path.join(cwd, '.cairn'))) process.exit(0);
40
+
41
+ const memoryDir = path.dirname(fpNorm);
42
+ const cairnMemory = path.join(cwd, '.cairn', 'memory');
43
+ const dest = path.join(cairnMemory, 'auto-memory');
44
+ fs.mkdirSync(dest, { recursive: true });
45
+
46
+ for (const f of fs.readdirSync(memoryDir)) {
47
+ // Skip files that exist in canonical top-level — those are mirrored from cairn_memo,
48
+ // not out-of-band auto-memory writes, so backing them up would duplicate canonical content.
49
+ if (fs.existsSync(path.join(cairnMemory, f))) continue;
50
+ const src = path.join(memoryDir, f);
51
+ let st;
52
+ try { st = fs.statSync(src); } catch { continue; }
53
+ if (!st.isFile()) continue;
54
+ const dst = path.join(dest, f);
55
+ fs.copyFileSync(src, dst);
56
+ try { fs.utimesSync(dst, st.atime, st.mtime); } catch {}
57
+ }
58
+ });
48
59
  `;
49
60
 
50
- const SYNC_RESTORE_SCRIPT = `\
51
- """
52
- Restore sync: .cairn/memory/ -> Claude Code memory dir for current location.
53
- Triggered as a UserPromptSubmit hook on each new session prompt.
54
- Two passes:
55
- 1. New-machine restore: copy .cairn/memory/auto-memory/ -> Claude memory dir
56
- (only when target is empty/missing, preserves Claude-native memory on new drive)
57
- 2. Always: sync .cairn/memory/ (top-level files only) -> Claude memory dir
58
- using mtime comparison so cairn_memo writes are always reflected.
59
- Path encoding: H:\\\\Projects\\\\Cairn -> H--Projects-Cairn
60
- (both ':' and '\\\\' map to '-', so ':' + '\\\\' = '--')
61
- """
62
- import sys, os, shutil
63
-
64
- cwd = os.getcwd()
65
- cairn_memory = os.path.join(cwd, '.cairn', 'memory')
66
-
67
- if not os.path.exists(cairn_memory):
68
- sys.exit(0)
69
-
70
- encoded = cwd.replace(':', '-').replace('\\\\', '-').replace('/', '-').lstrip('-')
71
- user_home = os.path.expanduser('~')
72
- memory_dir = os.path.join(user_home, '.claude', 'projects', encoded, 'memory')
73
- os.makedirs(memory_dir, exist_ok=True)
74
-
75
- # Pass 1: new-machine restore from auto-memory backup
76
- cairn_auto_memory = os.path.join(cairn_memory, 'auto-memory')
77
- if os.path.exists(cairn_auto_memory):
78
- target_empty = not any(f.endswith('.md') for f in os.listdir(memory_dir))
79
- if target_empty:
80
- restored = 0
81
- for f in os.listdir(cairn_auto_memory):
82
- src = os.path.join(cairn_auto_memory, f)
83
- if os.path.isfile(src):
84
- shutil.copy2(src, memory_dir)
85
- restored += 1
86
- if restored > 0:
87
- print(f"[cairn] Restored {restored} memory files from .cairn/memory/auto-memory/ (new project location detected)")
88
-
89
- # Pass 2: always sync top-level .cairn/memory/ files (cairn_memo writes) -> Claude memory dir
90
- synced = 0
91
- for f in os.listdir(cairn_memory):
92
- src = os.path.join(cairn_memory, f)
93
- if not os.path.isfile(src):
94
- continue # skip subdirs like auto-memory/
95
- dst = os.path.join(memory_dir, f)
96
- if not os.path.exists(dst) or os.path.getmtime(src) > os.path.getmtime(dst):
97
- shutil.copy2(src, dst)
98
- synced += 1
99
-
100
- if synced > 0:
101
- print(f"[cairn] Synced {synced} memory file(s) from .cairn/memory/ to Claude memory dir")
61
+ const SYNC_RESTORE_SCRIPT = `#!/usr/bin/env node
62
+ // Restore sync: .cairn/memory/ -> Claude Code memory dir for current location.
63
+ // Triggered as a UserPromptSubmit hook on each new session prompt.
64
+ // Three passes:
65
+ // 0. Bootstrap: capture any auto-memory files newer/missing in .cairn/memory/auto-memory/
66
+ // (catches files pre-dating the forward-sync hook install).
67
+ // 1. New-machine restore: copy .cairn/memory/auto-memory/ -> Claude memory dir
68
+ // (only when target is empty, preserves Claude-native memory on new drive)
69
+ // 2. Always: mtime-mirror .cairn/memory/*.md -> Claude memory dir
70
+ // (so cairn_memo writes are reflected)
71
+ // Path encoding: H:\\Projects\\Cairn -> H--Projects-Cairn
72
+ // (':', '\\', '/' all map to '-')
73
+ import fs from 'node:fs';
74
+ import os from 'node:os';
75
+ import path from 'node:path';
76
+
77
+ const cwd = process.cwd();
78
+ const cairnMemory = path.join(cwd, '.cairn', 'memory');
79
+ if (!fs.existsSync(cairnMemory)) process.exit(0);
80
+
81
+ const encoded = cwd.replace(/[:/\\\\]/g, '-').replace(/^-+/, '');
82
+ const memoryDir = path.join(os.homedir(), '.claude', 'projects', encoded, 'memory');
83
+ fs.mkdirSync(memoryDir, { recursive: true });
84
+
85
+ const cairnAutoMemory = path.join(cairnMemory, 'auto-memory');
86
+
87
+ function copyWithMtime(src, dst, srcStat) {
88
+ fs.copyFileSync(src, dst);
89
+ try { fs.utimesSync(dst, srcStat.atime, srcStat.mtime); } catch {}
90
+ }
91
+
92
+ // Pass 0: bootstrap — capture auto-memory files missing/newer in .cairn/memory/auto-memory/
93
+ // (catches files that pre-date the forward-sync hook install)
94
+ // Skip files present in canonical top-level: they are mirrored from cairn_memo, not auto-memory.
95
+ const claudeFiles = fs.readdirSync(memoryDir);
96
+ let bootstrapped = 0;
97
+ if (claudeFiles.length > 0) {
98
+ fs.mkdirSync(cairnAutoMemory, { recursive: true });
99
+ for (const f of claudeFiles) {
100
+ if (fs.existsSync(path.join(cairnMemory, f))) continue;
101
+ const src = path.join(memoryDir, f);
102
+ let srcStat;
103
+ try { srcStat = fs.statSync(src); } catch { continue; }
104
+ if (!srcStat.isFile()) continue;
105
+ const dst = path.join(cairnAutoMemory, f);
106
+ let needCopy = !fs.existsSync(dst);
107
+ if (!needCopy) {
108
+ try { needCopy = srcStat.mtimeMs > fs.statSync(dst).mtimeMs; } catch { needCopy = true; }
109
+ }
110
+ if (needCopy) {
111
+ copyWithMtime(src, dst, srcStat);
112
+ bootstrapped++;
113
+ }
114
+ }
115
+ }
116
+ if (bootstrapped > 0) {
117
+ process.stdout.write(\`[cairn] Bootstrapped \${bootstrapped} auto-memory file(s) into .cairn/memory/auto-memory/\\n\`);
118
+ }
119
+
120
+ // Pass 1: new-machine restore from auto-memory backup (only when target empty)
121
+ if (fs.existsSync(cairnAutoMemory)) {
122
+ const targetEmpty = !fs.readdirSync(memoryDir).some(f => f.endsWith('.md'));
123
+ if (targetEmpty) {
124
+ let restored = 0;
125
+ for (const f of fs.readdirSync(cairnAutoMemory)) {
126
+ const src = path.join(cairnAutoMemory, f);
127
+ let srcStat;
128
+ try { srcStat = fs.statSync(src); } catch { continue; }
129
+ if (!srcStat.isFile()) continue;
130
+ copyWithMtime(src, path.join(memoryDir, f), srcStat);
131
+ restored++;
132
+ }
133
+ if (restored > 0) {
134
+ process.stdout.write(\`[cairn] Restored \${restored} memory files from .cairn/memory/auto-memory/ (new project location detected)\\n\`);
135
+ }
136
+ }
137
+ }
138
+
139
+ // Pass 2: mtime-mirror top-level .cairn/memory/*.md -> Claude memory dir
140
+ let synced = 0;
141
+ for (const f of fs.readdirSync(cairnMemory)) {
142
+ const src = path.join(cairnMemory, f);
143
+ let srcStat;
144
+ try { srcStat = fs.statSync(src); } catch { continue; }
145
+ if (!srcStat.isFile()) continue; // skip subdirs like auto-memory/
146
+ const dst = path.join(memoryDir, f);
147
+ let needCopy = !fs.existsSync(dst);
148
+ if (!needCopy) {
149
+ try { needCopy = srcStat.mtimeMs > fs.statSync(dst).mtimeMs; } catch { needCopy = true; }
150
+ }
151
+ if (needCopy) {
152
+ copyWithMtime(src, dst, srcStat);
153
+ synced++;
154
+ }
155
+ }
156
+ if (synced > 0) {
157
+ process.stdout.write(\`[cairn] Synced \${synced} memory file(s) from .cairn/memory/ to Claude memory dir\\n\`);
158
+ }
102
159
  `;
103
160
 
104
161
  process.on('uncaughtException', (e) => {
@@ -343,21 +400,9 @@ if (subcommand === '--version' || subcommand === '-v') {
343
400
  .filter(([, e]) => (e.readCount ?? 0) > 0)
344
401
  .map(([p]) => p);
345
402
  } catch { }
346
- // Snapshot git state as a note so next resume knows where the session left off
347
- // even if no explicit cairn_checkpoint was called.
348
- const gitNotes = [...existingNotes.filter(n => !n.startsWith('git-snapshot:'))];
349
- try {
350
- const stat = execSync('git diff --stat HEAD', {
351
- cwd: process.cwd(), encoding: 'utf8', timeout: 5000,
352
- }).trim();
353
- const status = execSync('git status --short', {
354
- cwd: process.cwd(), encoding: 'utf8', timeout: 5000,
355
- }).trim();
356
- if (stat || status) {
357
- gitNotes.push(`git-snapshot: ${stat || '(clean)'} | status: ${status || '(clean)'}`);
358
- }
359
- } catch { }
360
- checkpoint(null, { message, active_files: activeFiles, notes: gitNotes });
403
+ // git state is already returned live by cairn_resume (git_status + uncommitted_changes)
404
+ // so there is no value in baking a stale snapshot into the notes array.
405
+ checkpoint(null, { message, active_files: activeFiles, notes: existingNotes });
361
406
  process.exit(0);
362
407
 
363
408
  } else if (subcommand === 'resume-hint') {
@@ -521,10 +566,10 @@ if (subcommand === '--version' || subcommand === '-v') {
521
566
  console.log(' PreToolUse[Read] -> cairn minify (compress source files for reading)');
522
567
  console.log(' PreToolUse[Edit] -> cairn edit-guard (block Edit until re-read with full content)')
523
568
  console.log(' PreToolUse[Write] -> cairn edit-guard (clear minify state before full file overwrite)');
524
- console.log(' PostToolUse[Write] -> sync-memory-forward.py (sync ~/.claude/memory -> .cairn/memory/auto-memory/)');
569
+ console.log(' PostToolUse[Write] -> sync-memory-forward.mjs (sync ~/.claude/memory -> .cairn/memory/auto-memory/)');
525
570
  console.log(' Stop -> cairn checkpoint --auto (auto-save session)');
526
571
  console.log(' UserPromptSubmit -> cairn resume-hint (remind Claude of prior session)');
527
- console.log(' UserPromptSubmit -> sync-memory-restore.py (sync .cairn/memory/ -> ~/.claude/memory, mtime-based)');
572
+ console.log(' UserPromptSubmit -> sync-memory-restore.mjs (sync .cairn/memory/ -> ~/.claude/memory, mtime-based)');
528
573
  console.log('');
529
574
  console.log('Restart Claude Code to activate the MCP server, then open any project and start working.');
530
575
  console.log('Upgrade any time with: npm install -g @misterhuydo/cairn-mcp');
@@ -544,12 +589,12 @@ if (subcommand === '--version' || subcommand === '-v') {
544
589
  console.log(' PreToolUse[Edit] -> cairn edit-guard (block Edit until re-read with full content)')
545
590
  console.log(' PreToolUse[Write] -> cairn edit-guard (clear minify state before full file overwrite)');
546
591
  if (isGlobal) {
547
- console.log(' PostToolUse[Write] -> sync-memory-forward.py (sync ~/.claude/memory -> .cairn/memory/auto-memory/)');
592
+ console.log(' PostToolUse[Write] -> sync-memory-forward.mjs (sync ~/.claude/memory -> .cairn/memory/auto-memory/)');
548
593
  }
549
594
  console.log(' Stop -> cairn checkpoint --auto (auto-save session)');
550
595
  console.log(' UserPromptSubmit -> cairn resume-hint (remind Claude of prior session)');
551
596
  if (isGlobal) {
552
- console.log(' UserPromptSubmit -> sync-memory-restore.py (sync .cairn/memory/ -> ~/.claude/memory, mtime-based)');
597
+ console.log(' UserPromptSubmit -> sync-memory-restore.mjs (sync .cairn/memory/ -> ~/.claude/memory, mtime-based)');
553
598
  }
554
599
  process.exit(0);
555
600
 
@@ -591,32 +636,50 @@ function applyHooks(settingsDir, isGlobal) {
591
636
  }
592
637
 
593
638
  // UserPromptSubmit: cairn resume-hint
594
- const submitHooks = settings.hooks.UserPromptSubmit || [];
639
+ let submitHooks = settings.hooks.UserPromptSubmit || [];
595
640
  if (!submitHooks.some(h => h.hooks?.some(hh => hh.command === 'cairn resume-hint'))) {
596
641
  submitHooks.push({ hooks: [{ type: 'command', command: 'cairn resume-hint' }] });
597
642
  }
598
643
 
599
644
  if (isGlobal) {
600
- // Deploy memory sync scripts to ~/.claude/scripts/
645
+ // Deploy memory sync scripts to ~/.claude/scripts/ (Node — cross-platform, no python3 required)
601
646
  const scriptsDir = path.join(settingsDir, 'scripts');
602
647
  fs.mkdirSync(scriptsDir, { recursive: true });
603
- const forwardScript = path.join(scriptsDir, 'sync-memory-forward.py');
604
- const restoreScript = path.join(scriptsDir, 'sync-memory-restore.py');
648
+ const forwardScript = path.join(scriptsDir, 'sync-memory-forward.mjs');
649
+ const restoreScript = path.join(scriptsDir, 'sync-memory-restore.mjs');
605
650
  fs.writeFileSync(forwardScript, SYNC_FORWARD_SCRIPT, 'utf8');
606
651
  fs.writeFileSync(restoreScript, SYNC_RESTORE_SCRIPT, 'utf8');
607
652
 
653
+ // Clean up legacy Python scripts from prior installs
654
+ for (const legacy of ['sync-memory-forward.py', 'sync-memory-restore.py']) {
655
+ const p = path.join(scriptsDir, legacy);
656
+ if (fs.existsSync(p)) { try { fs.unlinkSync(p); } catch {} }
657
+ }
658
+
608
659
  // Use forward slashes in hook commands — works across bash on Windows and Unix
609
- const fwdCmd = `python3 "${scriptsDir.replace(/\\/g, '/')}/sync-memory-forward.py"`;
610
- const rstCmd = `python3 "${scriptsDir.replace(/\\/g, '/')}/sync-memory-restore.py"`;
660
+ const scriptsDirFwd = scriptsDir.replace(/\\/g, '/');
661
+ const fwdCmd = `node "${scriptsDirFwd}/sync-memory-forward.mjs"`;
662
+ const rstCmd = `node "${scriptsDirFwd}/sync-memory-restore.mjs"`;
663
+
664
+ // Strip stale python3 .py references from prior installs (avoids duplicate hooks firing)
665
+ const isLegacyPyCmd = (cmd) => typeof cmd === 'string' &&
666
+ /python3?\s+.*sync-memory-(forward|restore)\.py/.test(cmd);
667
+ const stripLegacy = (entries) => entries
668
+ .map(entry => ({
669
+ ...entry,
670
+ hooks: (entry.hooks || []).filter(h => !isLegacyPyCmd(h.command)),
671
+ }))
672
+ .filter(entry => entry.hooks.length > 0);
611
673
 
612
674
  // PostToolUse[Write]: forward sync Claude memory -> .cairn/memory/auto-memory/
613
- const postHooks = settings.hooks.PostToolUse || [];
675
+ let postHooks = stripLegacy(settings.hooks.PostToolUse || []);
614
676
  if (!postHooks.some(h => h.matcher === 'Write' && h.hooks?.some(hh => hh.command === fwdCmd))) {
615
677
  postHooks.push({ matcher: 'Write', hooks: [{ type: 'command', command: fwdCmd }] });
616
678
  }
617
679
  settings.hooks.PostToolUse = postHooks;
618
680
 
619
681
  // UserPromptSubmit: sync .cairn/memory/ -> Claude memory dir (always, mtime-based)
682
+ submitHooks = stripLegacy(submitHooks);
620
683
  if (!submitHooks.some(h => h.hooks?.some(hh => hh.command === rstCmd))) {
621
684
  submitHooks.push({ hooks: [{ type: 'command', command: rstCmd }] });
622
685
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/cairn-mcp",
3
- "version": "1.7.1",
3
+ "version": "1.8.1",
4
4
  "description": "MCP server that gives Claude Code persistent memory across sessions. Index your codebase once, search symbols, bundle source, scan for vulnerabilities, and checkpoint/resume work — across Java, TypeScript, Vue, Python, SQL and more.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -1,45 +1,101 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
- import { getCairnDir } from '../graph/cwd.js';
4
-
5
- export function checkpoint(_db, { message, active_files = [], notes = [] }) {
6
- // Coerce notes to array in case the caller passed a pre-serialized JSON string
7
- if (typeof notes === 'string') {
8
- try { notes = JSON.parse(notes); } catch { notes = notes ? [notes] : []; }
9
- }
10
- if (!Array.isArray(notes)) notes = [];
11
- const cairnDir = getCairnDir();
12
- const sessionPath = path.join(cairnDir, 'session.json');
13
-
14
- // Snapshot mtimes of active files
15
- const mtimeSnapshot = {};
16
- for (const filePath of active_files) {
17
- try {
18
- mtimeSnapshot[filePath] = fs.statSync(filePath).mtimeMs;
19
- } catch {
20
- // File may not exist yet
21
- }
22
- }
23
-
24
- const session = {
25
- message,
26
- checkpoint_at: new Date().toISOString(),
27
- active_files,
28
- notes: notes.map(n => (/^\[\d{4}-\d{2}-\d{2}\]/.test(n) ? n : '[' + new Date().toISOString().slice(0, 10) + '] ' + n)),
29
- mtime_snapshot: mtimeSnapshot,
30
- };
31
-
32
- fs.writeFileSync(sessionPath, JSON.stringify(session, null, 2), 'utf8');
33
-
34
- return {
35
- content: [{
36
- type: 'text',
37
- text: JSON.stringify({
38
- saved: true,
39
- checkpoint_at: session.checkpoint_at,
40
- active_files_tracked: active_files.length,
41
- notes_saved: notes.length,
42
- }, null, 2),
43
- }],
44
- };
45
- }
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { getCairnDir } from '../graph/cwd.js';
4
+
5
+ const NOTE_CHAR_LIMIT = 1000;
6
+ const SESSION_ROTATION_LIMIT = 100 * 1024; // 100KB
7
+
8
+ // Process a single note for storage:
9
+ // - ref objects pass through unchanged (already filed)
10
+ // - strings within limit get a date prefix and stay inline
11
+ // - strings over limit get filed to .cairn/notes/ and replaced with a ref object
12
+ function processNote(note, cairnDir, dateStr, index) {
13
+ // Already a ref object — pass through
14
+ if (note && typeof note === 'object' && note.ref) return note;
15
+
16
+ const text = typeof note === 'string' ? note : String(note);
17
+
18
+ // Add date prefix if missing
19
+ const dated = /^\[\d{4}-\d{2}-\d{2}\]/.test(text)
20
+ ? text
21
+ : `[${dateStr}] ${text}`;
22
+
23
+ // Short enough to stay inline
24
+ if (dated.length <= NOTE_CHAR_LIMIT) return dated;
25
+
26
+ // Too long — save to .cairn/notes/ and return a ref
27
+ const notesDir = path.join(cairnDir, 'notes');
28
+ fs.mkdirSync(notesDir, { recursive: true });
29
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
30
+ const fileName = `note-${ts}-${index}.md`;
31
+ const filePath = path.join(notesDir, fileName);
32
+ fs.writeFileSync(filePath, dated, 'utf8');
33
+
34
+ // Description: first line of the note, capped at 120 chars
35
+ const description = dated.split('\n')[0].slice(0, 120);
36
+ return { description, ref: path.join('.cairn', 'notes', fileName) };
37
+ }
38
+
39
+ export function checkpoint(_db, { message, active_files = [], notes = [] }) {
40
+ // Coerce notes to array in case the caller passed a pre-serialized JSON string
41
+ if (typeof notes === 'string') {
42
+ try { notes = JSON.parse(notes); } catch { notes = notes ? [notes] : []; }
43
+ }
44
+ if (!Array.isArray(notes)) notes = [];
45
+
46
+ const cairnDir = getCairnDir();
47
+ const sessionPath = path.join(cairnDir, 'session.json');
48
+ const dateStr = new Date().toISOString().slice(0, 10);
49
+
50
+ // Snapshot mtimes of all indexed files so resume can detect changes accurately.
51
+ // Without a full snapshot, resume labels every non-active file as "new" and
52
+ // triggers a full reindex on every call.
53
+ const mtimeSnapshot = {};
54
+ try {
55
+ const rows = _db.prepare('SELECT path FROM main.files').all();
56
+ for (const { path: filePath } of rows) {
57
+ try { mtimeSnapshot[filePath] = fs.statSync(filePath).mtimeMs; } catch { }
58
+ }
59
+ } catch { /* DB may not exist on first run */ }
60
+ // Also include active_files in case they are not yet indexed
61
+ for (const filePath of active_files) {
62
+ try { mtimeSnapshot[filePath] = fs.statSync(filePath).mtimeMs; } catch { }
63
+ }
64
+
65
+ // Process all notes: long strings get filed, short ones stay inline, refs pass through
66
+ const processedNotes = notes.map((n, i) => processNote(n, cairnDir, dateStr, i));
67
+
68
+ // Rotate session.json when it has grown beyond the size limit
69
+ if (fs.existsSync(sessionPath)) {
70
+ try {
71
+ const size = fs.statSync(sessionPath).size;
72
+ if (size > SESSION_ROTATION_LIMIT) {
73
+ let n = 1;
74
+ while (fs.existsSync(path.join(cairnDir, `session.${n}.json`))) n++;
75
+ fs.renameSync(sessionPath, path.join(cairnDir, `session.${n}.json`));
76
+ }
77
+ } catch { /* non-fatal */ }
78
+ }
79
+
80
+ const session = {
81
+ message,
82
+ checkpoint_at: new Date().toISOString(),
83
+ active_files,
84
+ notes: processedNotes,
85
+ mtime_snapshot: mtimeSnapshot,
86
+ };
87
+
88
+ fs.writeFileSync(sessionPath, JSON.stringify(session, null, 2), 'utf8');
89
+
90
+ return {
91
+ content: [{
92
+ type: 'text',
93
+ text: JSON.stringify({
94
+ saved: true,
95
+ checkpoint_at: session.checkpoint_at,
96
+ active_files_tracked: active_files.length,
97
+ notes_saved: processedNotes.length,
98
+ }, null, 2),
99
+ }],
100
+ };
101
+ }
@@ -91,14 +91,23 @@ export function repairRelocation(db, cairnDir, session, map, oldRoot, newRoot) {
91
91
  'utf8'
92
92
  );
93
93
 
94
- // 2. Repair index.db: rewrite absolute paths in the files table
94
+ // 2. Repair index.db: rewrite absolute paths in all tables that store them
95
+ const oldBack = oldRoot.replace(/\//g, '\\');
96
+ function replaceInCol(table, col) {
97
+ try {
98
+ db.prepare(`UPDATE ${table} SET ${col} = replace(${col}, ?, ?)`).run(oldRoot, newRoot);
99
+ db.prepare(`UPDATE ${table} SET ${col} = replace(${col}, ?, ?)`).run(oldBack, newRoot);
100
+ } catch { /* column or table missing in older schema — safe to ignore */ }
101
+ }
102
+ replaceInCol('files', 'path');
103
+ replaceInCol('symbols', 'fqn');
104
+ replaceInCol('todos', 'file');
105
+ // FTS virtual table — delete stale content rows; the next maintain/reindex will repopulate
95
106
  try {
96
- db.prepare('UPDATE files SET path = replace(path, ?, ?)').run(oldRoot, newRoot);
97
- // Also handle the backslash variant stored on Windows
98
- db.prepare('UPDATE files SET path = replace(path, ?, ?)').run(
99
- oldRoot.replace(/\//g, '\\'), newRoot
100
- );
101
- } catch { /* older schema without files table — safe to ignore */ }
107
+ db.prepare(`DELETE FROM fts_index_content WHERE c0 LIKE ? OR c0 LIKE ? OR c3 LIKE ? OR c3 LIKE ?`)
108
+ .run(oldRoot + '%', oldBack + '%', oldRoot + '%', oldBack + '%');
109
+ db.prepare("INSERT INTO fts_index(fts_index) VALUES('rebuild')").run();
110
+ } catch { /* FTS not present in older schema */ }
102
111
 
103
112
  // 3. Repair minify map
104
113
  const newMap = {};
@@ -1,6 +1,8 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { execSync } from 'child_process';
3
+ import { exec } from 'child_process';
4
+ import { promisify } from 'util';
5
+ const execAsync = promisify(exec);
4
6
  import { getCairnDir, getProjectRoot, getMemoryDir } from '../graph/cwd.js';
5
7
  import { walkRepo } from '../indexer/fileWalker.js';
6
8
  import { getAttachedSchemas } from '../graph/db.js';
@@ -38,6 +40,19 @@ export async function resume(db) {
38
40
  }
39
41
  if (!Array.isArray(session.notes)) session.notes = [];
40
42
 
43
+ // Migration: strip legacy git-snapshot notes (accumulated by old auto-checkpoint logic).
44
+ // These are pure noise — resume already returns live git_status + uncommitted_changes.
45
+ // Run once: back up the original to session.bak.json before modifying.
46
+ const gitSnapshotNotes = session.notes.filter(n => typeof n === 'string' && n.includes('git-snapshot:'));
47
+ if (gitSnapshotNotes.length > 0) {
48
+ const bakPath = path.join(cairnDir, 'session.bak.json');
49
+ if (!fs.existsSync(bakPath)) {
50
+ fs.copyFileSync(sessionPath, bakPath);
51
+ }
52
+ session.notes = session.notes.filter(n => !(typeof n === 'string' && n.includes('git-snapshot:')));
53
+ fs.writeFileSync(sessionPath, JSON.stringify(session, null, 2), 'utf8');
54
+ }
55
+
41
56
  // Detect and auto-repair project relocation before any path comparisons
42
57
  let relocation = null;
43
58
  const snapshot = session.mtime_snapshot || {};
@@ -128,9 +143,18 @@ export async function resume(db) {
128
143
  changed.length > 0 ? `Files changed since checkpoint: ${changedSummary}` : 'No files changed since checkpoint.',
129
144
  subSchemas.length > 0 ? `Federated sub-indexes: ${subSchemas.length}` : '',
130
145
  openTodos > 0 ? `Open todos: ${openTodos}` : '',
131
- session.notes?.length > 0 ? `Notes: ${session.notes.join(' | ')}` : '',
146
+ session.notes?.length > 0 ? `Notes: ${session.notes.map(n => (n && typeof n === 'object' && n.ref) ? `${n.description} (see: ${n.ref})` : String(n)).join(' | ')}` : '',
132
147
  ].filter(Boolean).join(' ');
133
148
 
149
+ let gitignoreHint = null;
150
+ try {
151
+ const giContent = fs.readFileSync(path.join(getProjectRoot(), '.gitignore'), 'utf8');
152
+ const lines = giContent.split('\n').map(l => l.trim());
153
+ if (!lines.some(l => l === '.cairn' || l === '.cairn/' || l === '/.cairn' || l === '/.cairn/')) {
154
+ gitignoreHint = 'Add .cairn/ to your .gitignore to avoid committing the index and session cache.';
155
+ }
156
+ } catch { /* no .gitignore yet */ }
157
+
134
158
  const result = {
135
159
  last_checkpoint: session.checkpoint_at,
136
160
  message: session.message,
@@ -145,29 +169,31 @@ export async function resume(db) {
145
169
  notes: session.notes || [],
146
170
  resume_summary: resumeSummary,
147
171
  };
172
+ if (gitignoreHint) result.gitignore_hint = gitignoreHint;
173
+ if (gitSnapshotNotes.length > 0) {
174
+ result.migrated = `Removed ${gitSnapshotNotes.length} legacy git-snapshot note(s). Original session backed up to session.bak.json.`;
175
+ }
148
176
 
149
- // Git state — lets Claude determine what's resolved vs still in-flight,
150
- // regardless of checkpoint staleness.
177
+ // Git state — run all three queries in parallel to avoid sequential git startup overhead.
151
178
  const gitRoot = getProjectRoot();
152
- try {
153
- const since = new Date(session.checkpoint_at).toISOString();
154
- const raw = execSync(`git log --oneline --after="${since}"`, {
155
- cwd: gitRoot, encoding: 'utf8', timeout: 5000,
156
- }).trim();
179
+ const since = new Date(session.checkpoint_at).toISOString();
180
+ const [logResult, diffResult, statusResult] = await Promise.allSettled([
181
+ execAsync(`git log --oneline --after="${since}"`, { cwd: gitRoot, timeout: 5000 }),
182
+ execAsync('git diff --name-only HEAD', { cwd: gitRoot, timeout: 5000 }),
183
+ execAsync('git status --short', { cwd: gitRoot, timeout: 5000 }),
184
+ ]);
185
+ if (logResult.status === 'fulfilled') {
186
+ const raw = logResult.value.stdout.trim();
157
187
  if (raw) result.commits_since_checkpoint = raw.split('\n');
158
- } catch { }
159
- try {
160
- const raw = execSync('git diff --name-only HEAD', {
161
- cwd: gitRoot, encoding: 'utf8', timeout: 5000,
162
- }).trim();
188
+ }
189
+ if (diffResult.status === 'fulfilled') {
190
+ const raw = diffResult.value.stdout.trim();
163
191
  if (raw) result.uncommitted_changes = raw.split('\n');
164
- } catch { }
165
- try {
166
- const raw = execSync('git status --short', {
167
- cwd: gitRoot, encoding: 'utf8', timeout: 5000,
168
- }).trim();
192
+ }
193
+ if (statusResult.status === 'fulfilled') {
194
+ const raw = statusResult.value.stdout.trim();
169
195
  if (raw) result.git_status = raw;
170
- } catch { }
196
+ }
171
197
 
172
198
  if (relocation) result.relocated = relocation;
173
199
  if (preferences.length > 0) {
@@ -0,0 +1,99 @@
1
+ import { strict as assert } from 'node:assert';
2
+ import { test, beforeEach, afterEach } from 'node:test';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import os from 'os';
6
+ import { setActiveProjectRoot } from '../src/graph/cwd.js';
7
+
8
+ let tmpDir;
9
+
10
+ beforeEach(() => {
11
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cairn-checkpoint-'));
12
+ const cairnDir = path.join(tmpDir, '.cairn');
13
+ fs.mkdirSync(cairnDir, { recursive: true });
14
+ fs.writeFileSync(path.join(cairnDir, '.cairn-project'), '', 'utf8');
15
+ setActiveProjectRoot(tmpDir);
16
+ });
17
+
18
+ afterEach(() => {
19
+ fs.rmSync(tmpDir, { recursive: true, force: true });
20
+ });
21
+
22
+ // Dynamically import so setActiveProjectRoot takes effect before module runs
23
+ async function getCheckpoint() {
24
+ const { checkpoint } = await import(`../src/tools/checkpoint.js?t=${Date.now()}`);
25
+ return checkpoint;
26
+ }
27
+
28
+ test('short notes stay inline with date prefix', async () => {
29
+ const checkpoint = await getCheckpoint();
30
+ checkpoint(null, { message: 'test', notes: ['short note'] });
31
+
32
+ const session = JSON.parse(fs.readFileSync(path.join(tmpDir, '.cairn', 'session.json'), 'utf8'));
33
+ assert.equal(session.notes.length, 1);
34
+ assert.ok(typeof session.notes[0] === 'string', 'short note should remain a string');
35
+ assert.match(session.notes[0], /^\[\d{4}-\d{2}-\d{2}\]/);
36
+ });
37
+
38
+ test('long notes are filed to .cairn/notes/ and replaced with a ref object', async () => {
39
+ const checkpoint = await getCheckpoint();
40
+ const longNote = 'x'.repeat(1001);
41
+ checkpoint(null, { message: 'test', notes: [longNote] });
42
+
43
+ const session = JSON.parse(fs.readFileSync(path.join(tmpDir, '.cairn', 'session.json'), 'utf8'));
44
+ assert.equal(session.notes.length, 1);
45
+ const note = session.notes[0];
46
+ assert.ok(note && typeof note === 'object', 'long note should be a ref object');
47
+ assert.ok(typeof note.ref === 'string', 'ref should be a string path');
48
+ assert.ok(typeof note.description === 'string', 'description should be present');
49
+
50
+ // Ref file should exist and contain the original content
51
+ const refPath = path.join(tmpDir, note.ref);
52
+ assert.ok(fs.existsSync(refPath), `ref file should exist at ${refPath}`);
53
+ const content = fs.readFileSync(refPath, 'utf8');
54
+ assert.ok(content.includes(longNote), 'ref file should contain the original note text');
55
+ });
56
+
57
+ test('existing ref objects pass through unchanged', async () => {
58
+ const checkpoint = await getCheckpoint();
59
+ const ref = { description: '[2026-01-01] some decision', ref: '.cairn/notes/note-old.md' };
60
+ checkpoint(null, { message: 'test', notes: [ref] });
61
+
62
+ const session = JSON.parse(fs.readFileSync(path.join(tmpDir, '.cairn', 'session.json'), 'utf8'));
63
+ assert.deepEqual(session.notes[0], ref);
64
+ });
65
+
66
+ test('session.json is rotated when it exceeds 100KB', async () => {
67
+ const checkpoint = await getCheckpoint();
68
+ const cairnDir = path.join(tmpDir, '.cairn');
69
+ const sessionPath = path.join(cairnDir, 'session.json');
70
+
71
+ // Write a fake oversized session.json (> 100KB)
72
+ fs.writeFileSync(sessionPath, JSON.stringify({ message: 'old', notes: [], active_files: [], mtime_snapshot: {}, checkpoint_at: '' }) + ' '.repeat(102 * 1024), 'utf8');
73
+
74
+ checkpoint(null, { message: 'new', notes: [] });
75
+
76
+ assert.ok(fs.existsSync(path.join(cairnDir, 'session.1.json')), 'old session should be rotated to session.1.json');
77
+ const current = JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
78
+ assert.equal(current.message, 'new');
79
+ });
80
+
81
+ test('old-format plain string notes longer than limit are migrated to refs on next checkpoint', async () => {
82
+ const checkpoint = await getCheckpoint();
83
+ const cairnDir = path.join(tmpDir, '.cairn');
84
+
85
+ // Simulate an old session.json with a long plain-string note (e.g. git-snapshot)
86
+ const oldNote = '[2026-04-11] git-snapshot: ' + 'M src/foo.js\n'.repeat(80);
87
+ fs.writeFileSync(
88
+ path.join(cairnDir, 'session.json'),
89
+ JSON.stringify({ message: 'old', notes: [oldNote], active_files: [], mtime_snapshot: {}, checkpoint_at: '' }),
90
+ 'utf8'
91
+ );
92
+
93
+ // Pass the old notes through checkpoint (as would happen on next explicit checkpoint)
94
+ checkpoint(null, { message: 'migrated', notes: [oldNote] });
95
+
96
+ const session = JSON.parse(fs.readFileSync(path.join(cairnDir, 'session.json'), 'utf8'));
97
+ const note = session.notes[0];
98
+ assert.ok(note && typeof note === 'object' && note.ref, 'old long note should be migrated to a ref');
99
+ });
@@ -1,4 +1,5 @@
1
1
  import { strict as assert } from 'node:assert';
2
+ import { DatabaseSync } from 'node:sqlite';
2
3
  import { test, beforeEach, afterEach } from 'node:test';
3
4
  import fs from 'fs';
4
5
  import path from 'path';
@@ -138,6 +139,38 @@ test('repairRelocation rewrites session.json paths', () => {
138
139
  assert.ok(Object.keys(saved.mtime_snapshot).every(k => k.startsWith(newRoot)));
139
140
  });
140
141
 
142
+ test('repairRelocation rewrites symbols.fqn and todos.file in DB', () => {
143
+ const cairnDir = path.join(dstDir, '.cairn');
144
+ fs.mkdirSync(cairnDir, { recursive: true });
145
+
146
+ const db = new DatabaseSync(':memory:');
147
+ db.prepare(`CREATE TABLE files (id INTEGER PRIMARY KEY, path TEXT UNIQUE)`).run();
148
+ db.prepare(`CREATE TABLE symbols (id INTEGER PRIMARY KEY, file_id INTEGER, fqn TEXT)`).run();
149
+ db.prepare(`CREATE TABLE todos (id INTEGER PRIMARY KEY, file TEXT)`).run();
150
+
151
+ const oldRoot = srcDir.replace(/\\/g, '/');
152
+ const newRoot = dstDir.replace(/\\/g, '/');
153
+
154
+ db.prepare('INSERT INTO files (path) VALUES (?)').run(`${oldRoot}/src/a.js`);
155
+ const fileId = db.prepare('SELECT id FROM files WHERE path = ?').get(`${oldRoot}/src/a.js`).id;
156
+ db.prepare('INSERT INTO symbols (file_id, fqn) VALUES (?, ?)').run(fileId, `${oldRoot}/src/a.js:MyClass`);
157
+ db.prepare('INSERT INTO todos (file) VALUES (?)').run(`${oldRoot}/README.md`);
158
+
159
+ const session = { message: 'x', checkpoint_at: '', active_files: [], mtime_snapshot: {} };
160
+ fs.writeFileSync(path.join(cairnDir, 'session.json'), JSON.stringify(session), 'utf8');
161
+
162
+ repairRelocation(db, cairnDir, session, {}, oldRoot, newRoot);
163
+
164
+ const file = db.prepare('SELECT path FROM files').get();
165
+ assert.ok(file.path.startsWith(newRoot), `files.path not updated: ${file.path}`);
166
+
167
+ const sym = db.prepare('SELECT fqn FROM symbols').get();
168
+ assert.ok(sym.fqn.startsWith(newRoot), `symbols.fqn not updated: ${sym.fqn}`);
169
+
170
+ const todo = db.prepare('SELECT file FROM todos').get();
171
+ assert.ok(todo.file.startsWith(newRoot), `todos.file not updated: ${todo.file}`);
172
+ });
173
+
141
174
  test('repairRelocation rewrites minify map keys and tempPaths', () => {
142
175
  const cairnDir = path.join(dstDir, '.cairn');
143
176
  fs.mkdirSync(cairnDir, { recursive: true });
@@ -0,0 +1,83 @@
1
+ import { strict as assert } from 'node:assert';
2
+ import { test, beforeEach, afterEach } from 'node:test';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import os from 'os';
6
+
7
+ // Test the git-snapshot migration logic in isolation by replicating the exact
8
+ // block from resume.js against a real session.json on disk.
9
+ function runMigration(cairnDir) {
10
+ const sessionPath = path.join(cairnDir, 'session.json');
11
+ const session = JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
12
+ if (!Array.isArray(session.notes)) session.notes = [];
13
+
14
+ const gitSnapshotNotes = session.notes.filter(n => typeof n === 'string' && n.includes('git-snapshot:'));
15
+ if (gitSnapshotNotes.length > 0) {
16
+ const bakPath = path.join(cairnDir, 'session.bak.json');
17
+ if (!fs.existsSync(bakPath)) fs.copyFileSync(sessionPath, bakPath);
18
+ session.notes = session.notes.filter(n => !(typeof n === 'string' && n.includes('git-snapshot:')));
19
+ fs.writeFileSync(sessionPath, JSON.stringify(session, null, 2), 'utf8');
20
+ }
21
+ return gitSnapshotNotes.length;
22
+ }
23
+
24
+ let tmpDir;
25
+ beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cairn-resume-mig-')); });
26
+ afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
27
+
28
+ test('migration strips git-snapshot notes and backs up session.json', () => {
29
+ const cairnDir = path.join(tmpDir, '.cairn');
30
+ fs.mkdirSync(cairnDir, { recursive: true });
31
+
32
+ const realNote = '[2026-04-11] billing: 1000 credits = $1';
33
+ const gitNote = '[2026-04-11] git-snapshot: M src/foo.js | status: M src/foo.js';
34
+ fs.writeFileSync(path.join(cairnDir, 'session.json'), JSON.stringify({
35
+ message: 'old', checkpoint_at: '', active_files: [], mtime_snapshot: {},
36
+ notes: [realNote, gitNote, gitNote],
37
+ }), 'utf8');
38
+
39
+ const removed = runMigration(cairnDir);
40
+
41
+ assert.equal(removed, 2, 'should detect 2 git-snapshot notes');
42
+
43
+ const session = JSON.parse(fs.readFileSync(path.join(cairnDir, 'session.json'), 'utf8'));
44
+ assert.deepEqual(session.notes, [realNote], 'only the real note should remain');
45
+
46
+ assert.ok(fs.existsSync(path.join(cairnDir, 'session.bak.json')), 'session.bak.json should be created');
47
+ const bak = JSON.parse(fs.readFileSync(path.join(cairnDir, 'session.bak.json'), 'utf8'));
48
+ assert.equal(bak.notes.length, 3, 'backup should contain all original notes');
49
+ });
50
+
51
+ test('migration does not overwrite an existing session.bak.json', () => {
52
+ const cairnDir = path.join(tmpDir, '.cairn');
53
+ fs.mkdirSync(cairnDir, { recursive: true });
54
+
55
+ const original = { message: 'original-backup', notes: [], active_files: [], mtime_snapshot: {}, checkpoint_at: '' };
56
+ fs.writeFileSync(path.join(cairnDir, 'session.bak.json'), JSON.stringify(original), 'utf8');
57
+ fs.writeFileSync(path.join(cairnDir, 'session.json'), JSON.stringify({
58
+ message: 'second-run', checkpoint_at: '', active_files: [], mtime_snapshot: {},
59
+ notes: ['[2026-04-12] git-snapshot: M x.js'],
60
+ }), 'utf8');
61
+
62
+ runMigration(cairnDir);
63
+
64
+ const bak = JSON.parse(fs.readFileSync(path.join(cairnDir, 'session.bak.json'), 'utf8'));
65
+ assert.equal(bak.message, 'original-backup', 'existing backup should not be overwritten');
66
+ });
67
+
68
+ test('migration is a no-op when no git-snapshot notes are present', () => {
69
+ const cairnDir = path.join(tmpDir, '.cairn');
70
+ fs.mkdirSync(cairnDir, { recursive: true });
71
+
72
+ const notes = ['[2026-04-11] clean note'];
73
+ fs.writeFileSync(path.join(cairnDir, 'session.json'), JSON.stringify({
74
+ message: 'clean', checkpoint_at: '', active_files: [], mtime_snapshot: {}, notes,
75
+ }), 'utf8');
76
+
77
+ const removed = runMigration(cairnDir);
78
+
79
+ assert.equal(removed, 0);
80
+ assert.ok(!fs.existsSync(path.join(cairnDir, 'session.bak.json')), 'no backup should be created');
81
+ const session = JSON.parse(fs.readFileSync(path.join(cairnDir, 'session.json'), 'utf8'));
82
+ assert.deepEqual(session.notes, notes, 'notes should be unchanged');
83
+ });