@misterhuydo/cairn-mcp 1.6.21 → 1.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/cairn-mcp",
3
- "version": "1.6.21",
3
+ "version": "1.7.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",
@@ -24,8 +24,8 @@ function outlineCStyle(content, { annotations = false } = {}) {
24
24
  const out = [];
25
25
  let depth = 0;
26
26
 
27
- for (const raw of lines) {
28
- const line = raw.trim();
27
+ for (let i = 0; i < lines.length; i++) {
28
+ const line = lines[i].trim();
29
29
  if (!line) continue;
30
30
 
31
31
  const { opens, closes } = countBraces(line);
@@ -33,11 +33,11 @@ function outlineCStyle(content, { annotations = false } = {}) {
33
33
 
34
34
  if (depth === 0) {
35
35
  const sig = topLevelSig(line);
36
- if (sig !== null) out.push(sig);
36
+ if (sig !== null) out.push(`${i + 1}: ${sig}`);
37
37
  else if (line === '}') out.push('}'); // close class/block
38
38
  } else if (depth === 1) {
39
39
  const sig = memberSig(line, annotations);
40
- if (sig !== null) out.push(INDENT + sig);
40
+ if (sig !== null) out.push(`${INDENT}${i + 1}: ${sig}`);
41
41
  }
42
42
  // depth > 1: skip (inside method bodies)
43
43
 
@@ -138,14 +138,16 @@ function countBraces(line) {
138
138
 
139
139
  function outlinePython(content) {
140
140
  const out = [];
141
- for (const line of content.split('\n')) {
141
+ const lines = content.split('\n');
142
+ for (let i = 0; i < lines.length; i++) {
143
+ const line = lines[i];
142
144
  if (!line.trim()) continue;
143
145
  const trimmed = line.trimStart();
144
146
  if (trimmed.startsWith('import ') || trimmed.startsWith('from ')) {
145
147
  out.push(line);
146
148
  } else if (trimmed.startsWith('class ') || trimmed.startsWith('def ') ||
147
149
  trimmed.startsWith('async def ')) {
148
- out.push(line.replace(/:(\s*)$/, ':')); // keep signature, no body
150
+ out.push(`${i + 1}: ${line.trim().replace(/:(\s*)$/, ':')}`);
149
151
  }
150
152
  }
151
153
  return out.join('\n');
@@ -175,10 +177,11 @@ function outlineVue(content) {
175
177
 
176
178
  function outlineSQL(content) {
177
179
  const out = [];
178
- for (const line of content.split('\n')) {
179
- const t = line.trim();
180
+ const lines = content.split('\n');
181
+ for (let i = 0; i < lines.length; i++) {
182
+ const t = lines[i].trim();
180
183
  if (/^(CREATE|ALTER|DROP|INSERT|UPDATE|DELETE|SELECT|WITH|GRANT|REVOKE|BEGIN|COMMIT)\b/i.test(t)) {
181
- out.push(t.slice(0, 120) + (t.length > 120 ? '...' : ''));
184
+ out.push(`${i + 1}: ${t.slice(0, 120)}${t.length > 120 ? '...' : ''}`);
182
185
  }
183
186
  }
184
187
  return out.join('\n');
package/src/graph/db.js CHANGED
@@ -21,7 +21,8 @@ const SCHEMA = `
21
21
  kind TEXT,
22
22
  language TEXT,
23
23
  exported INTEGER DEFAULT 0,
24
- description TEXT
24
+ description TEXT,
25
+ line INTEGER
25
26
  );
26
27
 
27
28
  CREATE TABLE IF NOT EXISTS dependencies (
@@ -147,7 +148,7 @@ function tableSelect(schema, table, offset) {
147
148
  case 'files':
148
149
  return `SELECT id+${offset} AS id, repo, path, language, file_type, last_indexed FROM ${q}.files`;
149
150
  case 'symbols':
150
- return `SELECT id+${offset} AS id, file_id+${offset} AS file_id, fqn, name, kind, language, exported, description FROM ${q}.symbols`;
151
+ return `SELECT id+${offset} AS id, file_id+${offset} AS file_id, fqn, name, kind, language, exported, description, line FROM ${q}.symbols`;
151
152
  case 'dependencies':
152
153
  return `SELECT from_file_id+${offset} AS from_file_id, to_fqn, dep_type FROM ${q}.dependencies`;
153
154
  case 'build_deps':
@@ -187,6 +188,8 @@ export function openDB() {
187
188
  db.exec('PRAGMA journal_mode = WAL');
188
189
  db.exec('PRAGMA synchronous = NORMAL');
189
190
  db.exec(SCHEMA);
191
+ // Migration: add line column to existing DBs that predate v1.7.0
192
+ try { db.exec('ALTER TABLE main.symbols ADD COLUMN line INTEGER'); } catch {}
190
193
  mountParentSubIndexes(db);
191
194
  refreshFederatedViews(db);
192
195
  return db;
@@ -14,16 +14,16 @@ export function upsertFile(db, { repo, path, language, file_type }) {
14
14
  return result.lastInsertRowid;
15
15
  }
16
16
 
17
- export function upsertSymbol(db, { file_id, fqn, name, kind, language, exported, description }) {
17
+ export function upsertSymbol(db, { file_id, fqn, name, kind, language, exported, description, line }) {
18
18
  const existing = db.prepare('SELECT id FROM main.symbols WHERE fqn = ?').get(fqn);
19
19
  if (existing) {
20
20
  db.prepare(
21
- 'UPDATE main.symbols SET file_id=?, name=?, kind=?, language=?, exported=?, description=? WHERE id=?'
22
- ).run(file_id, name, kind, language, exported ? 1 : 0, description || '', existing.id);
21
+ 'UPDATE main.symbols SET file_id=?, name=?, kind=?, language=?, exported=?, description=?, line=? WHERE id=?'
22
+ ).run(file_id, name, kind, language, exported ? 1 : 0, description || '', line ?? null, existing.id);
23
23
  } else {
24
24
  db.prepare(
25
- 'INSERT INTO main.symbols (file_id, fqn, name, kind, language, exported, description) VALUES (?, ?, ?, ?, ?, ?, ?)'
26
- ).run(file_id, fqn, name, kind, language, exported ? 1 : 0, description || '');
25
+ 'INSERT INTO main.symbols (file_id, fqn, name, kind, language, exported, description, line) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
26
+ ).run(file_id, fqn, name, kind, language, exported ? 1 : 0, description || '', line ?? null);
27
27
  }
28
28
  }
29
29
 
@@ -1,17 +1,21 @@
1
+ function lineOf(content, index) {
2
+ return content.substring(0, index).split('\n').length;
3
+ }
4
+
1
5
  export function parseJava(filePath, content, repoName) {
2
6
  const pkg = content.match(/^package\s+([\w.]+);/m)?.[1] || '';
3
7
  const imports = [...content.matchAll(/^import\s+([\w.]+);/gm)].map(m => m[1]);
4
8
 
5
- const classMatch = content.match(
6
- /(?:public|protected)?\s+(?:abstract\s+)?(?:class|interface|enum|record)\s+(\w+)/
7
- );
9
+ const classRe = /(?:public|protected)?\s+(?:abstract\s+)?(?:class|interface|enum|record)\s+(\w+)/;
10
+ const classMatch = classRe.exec(content);
8
11
  const name = classMatch?.[1] || '';
9
12
  const kind = classMatch?.[0]?.match(/class|interface|enum|record/)?.[0] || 'class';
10
13
  const fqn = pkg ? `${pkg}.${name}` : name;
14
+ const classLine = classMatch ? lineOf(content, classMatch.index) : null;
11
15
 
12
- const methodNames = [...content.matchAll(
16
+ const methodMatches = [...content.matchAll(
13
17
  /(?:public|protected|private)[^{;]*\s+(\w+)\s*\([^)]*\)\s*(?:throws[^{]*)?\{/gm
14
- )].map(m => m[1]);
18
+ )];
15
19
 
16
20
  const extendsMatch = content.match(/extends\s+([\w.]+)/)?.[1];
17
21
  const implementsMatch = content.match(/implements\s+([\w.,\s]+)/)?.[1]
@@ -21,12 +25,12 @@ export function parseJava(filePath, content, repoName) {
21
25
  ?.replace(/\s*\*\s?/g, ' ').trim();
22
26
 
23
27
  const methodSymbols = fqn
24
- ? methodNames.map(m => ({ name: m, fqn: `${fqn}.${m}`, kind: 'method', exported: false, description: '' }))
28
+ ? methodMatches.map(m => ({ name: m[1], fqn: `${fqn}.${m[1]}`, kind: 'method', exported: false, description: '', line: lineOf(content, m.index) }))
25
29
  : [];
26
30
 
27
31
  return {
28
32
  language: 'java',
29
- symbols: [{ name, fqn, kind, exported: true, description: javadoc || '' }, ...methodSymbols],
33
+ symbols: [{ name, fqn, kind, exported: true, description: javadoc || '', line: classLine }, ...methodSymbols],
30
34
  imports,
31
35
  extends: extendsMatch ? [extendsMatch] : [],
32
36
  implements: implementsMatch || [],
@@ -1,3 +1,7 @@
1
+ function lineOf(content, index) {
2
+ return content.substring(0, index).split('\n').length;
3
+ }
4
+
1
5
  export function parsePython(filePath, content, repoName) {
2
6
  const symbols = [];
3
7
  const imports = [];
@@ -9,17 +13,17 @@ export function parsePython(filePath, content, repoName) {
9
13
 
10
14
  for (const m of content.matchAll(/^class\s+(\w+)(?:\(([^)]*)\))?:/gm)) {
11
15
  const docstring = content.slice(m.index).match(/:\s*\n\s+"""([\s\S]*?)"""/)?.[1]?.trim();
12
- symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'class', exported: true, description: docstring || '' });
16
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'class', exported: true, description: docstring || '', line: lineOf(content, m.index) });
13
17
  }
14
18
 
15
19
  for (const m of content.matchAll(/^(?:async\s+)?def\s+(\w+)\s*\(/gm)) {
16
20
  if (m[1].startsWith('_')) continue;
17
21
  const docstring = content.slice(m.index).match(/\).*?:\s*\n\s+"""([\s\S]*?)"""/)?.[1]?.trim();
18
- symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'function', exported: true, description: docstring || '' });
22
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'function', exported: true, description: docstring || '', line: lineOf(content, m.index) });
19
23
  }
20
24
 
21
25
  for (const m of content.matchAll(/^@([\w.]+)(?:\(([^)]*)\))?/gm))
22
- symbols.push({ name: m[1], fqn: `${filePath}::@${m[1]}`, kind: 'decorator', exported: false, description: '' });
26
+ symbols.push({ name: m[1], fqn: `${filePath}::@${m[1]}`, kind: 'decorator', exported: false, description: '', line: lineOf(content, m.index) });
23
27
 
24
28
  return { language: 'python', symbols, imports };
25
29
  }
@@ -1,3 +1,7 @@
1
+ function lineOf(content, index) {
2
+ return content.substring(0, index).split('\n').length;
3
+ }
4
+
1
5
  export function parseTS(filePath, content, repoName, language = 'typescript') {
2
6
  const symbols = [];
3
7
  const imports = [];
@@ -14,26 +18,26 @@ export function parseTS(filePath, content, repoName, language = 'typescript') {
14
18
  for (const m of content.matchAll(/(?:export\s+)?(?:abstract\s+)?class\s+(\w+)/g)) {
15
19
  const exported = m[0].startsWith('export');
16
20
  const jsdoc = extractJSDoc(content, m.index);
17
- symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'class', exported, description: jsdoc });
21
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'class', exported, description: jsdoc, line: lineOf(content, m.index) });
18
22
  }
19
23
 
20
24
  // Interfaces & Types
21
25
  for (const m of content.matchAll(/export\s+(?:interface|type)\s+(\w+)/g))
22
- symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'interface', exported: true, description: '' });
26
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'interface', exported: true, description: '', line: lineOf(content, m.index) });
23
27
 
24
28
  // Exported named functions
25
29
  for (const m of content.matchAll(/export\s+(?:async\s+)?function\s+(\w+)/g)) {
26
30
  const jsdoc = extractJSDoc(content, m.index);
27
- symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'function', exported: true, description: jsdoc });
31
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'function', exported: true, description: jsdoc, line: lineOf(content, m.index) });
28
32
  }
29
33
 
30
34
  // Exported arrow functions: export const foo = (...) =>
31
35
  for (const m of content.matchAll(/export\s+const\s+(\w+)\s*=\s*(?:async\s+)?\(/g))
32
- symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'function', exported: true, description: '' });
36
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'function', exported: true, description: '', line: lineOf(content, m.index) });
33
37
 
34
38
  // Enums
35
39
  for (const m of content.matchAll(/export\s+(?:const\s+)?enum\s+(\w+)/g))
36
- symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'enum', exported: true, description: '' });
40
+ symbols.push({ name: m[1], fqn: `${filePath}::${m[1]}`, kind: 'enum', exported: true, description: '', line: lineOf(content, m.index) });
37
41
 
38
42
  return { language, symbols, imports };
39
43
  }
@@ -132,8 +132,22 @@ export async function maintain(db, { languages } = {}) {
132
132
  refreshFederatedViews(db);
133
133
  }
134
134
 
135
+ // Check if .cairn is gitignored; suggest adding it if not
136
+ let gitignoreHint = null;
137
+ try {
138
+ const gitignorePath = path.join(root, '.gitignore');
139
+ const gitignoreContent = await fs.readFile(gitignorePath, 'utf-8').catch(() => '');
140
+ const lines = gitignoreContent.split('\n').map(l => l.trim());
141
+ const covered = lines.some(l => l === '.cairn' || l === '.cairn/' || l === '/.cairn' || l === '/.cairn/');
142
+ if (!covered) {
143
+ gitignoreHint = 'Add .cairn/ to your .gitignore to avoid committing the index and session cache.';
144
+ }
145
+ } catch {}
146
+
135
147
  stats.duration_ms = Date.now() - startTime;
136
- return { content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }] };
148
+ const result = { ...stats };
149
+ if (gitignoreHint) result.gitignore_hint = gitignoreHint;
150
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
137
151
  }
138
152
 
139
153
  // Incremental re-index: only process a specific list of changed file paths
package/src/tools/memo.js CHANGED
@@ -55,7 +55,7 @@ export function loadPreferenceMemories(memoryDir) {
55
55
  .map(e => {
56
56
  const filePath = path.join(memoryDir, e.file);
57
57
  if (!fs.existsSync(filePath)) return null;
58
- return { name: e.name, description: e.description, content: fs.readFileSync(filePath, 'utf8') };
58
+ return { name: e.name, description: e.description, file: e.file, content: fs.readFileSync(filePath, 'utf8') };
59
59
  })
60
60
  .filter(Boolean);
61
61
  }
@@ -105,7 +105,7 @@ export function outlineProject(db, { paths: scopePaths, repo, checks } = {}) {
105
105
  }
106
106
 
107
107
  const allSymbols = db.prepare(
108
- `SELECT file_id, fqn, name, kind, exported FROM symbols ORDER BY file_id, kind, name`
108
+ `SELECT file_id, fqn, name, kind, exported, line FROM symbols ORDER BY file_id, kind, name`
109
109
  ).all();
110
110
 
111
111
  for (const sym of allSymbols) {
@@ -141,16 +141,19 @@ export function outlineProject(db, { paths: scopePaths, repo, checks } = {}) {
141
141
 
142
142
  for (const [className, { meta, members }] of classes) {
143
143
  const exported = meta.exported ? '+' : '-';
144
- outlineLines.push(` ${exported} ${meta.kind} ${className}`);
144
+ const lineTag = meta.line ? `:${meta.line}` : '';
145
+ outlineLines.push(` ${exported} ${meta.kind} ${className}${lineTag}`);
145
146
  for (const m of members) {
146
147
  const vis = m.exported ? '+' : '-';
147
- outlineLines.push(` ${vis} ${m.name} (${m.kind})`);
148
+ const mLineTag = m.line ? `:${m.line}` : '';
149
+ outlineLines.push(` ${vis} ${m.name}${mLineTag} (${m.kind})`);
148
150
  }
149
151
  }
150
152
  for (const sym of topLevel) {
151
153
  if (sym.kind === 'class') continue; // already handled above
152
154
  const vis = sym.exported ? '+' : '-';
153
- outlineLines.push(` ${vis} ${sym.name} (${sym.kind})`);
155
+ const lineTag = sym.line ? `:${sym.line}` : '';
156
+ outlineLines.push(` ${vis} ${sym.name}${lineTag} (${sym.kind})`);
154
157
  }
155
158
  }
156
159
 
@@ -170,7 +170,9 @@ export async function resume(db) {
170
170
  } catch { }
171
171
 
172
172
  if (relocation) result.relocated = relocation;
173
- if (preferences.length > 0) result.preferences = preferences;
173
+ if (preferences.length > 0) {
174
+ result.preferences = preferences.map(p => ({ name: p.name, description: p.description, file: p.file }));
175
+ }
174
176
  if (memoryIndex) result.memory_index = memoryIndex;
175
177
 
176
178
  return {
@@ -44,5 +44,20 @@ export function search(db, { query, limit = 10, language, kind }) {
44
44
  return true;
45
45
  });
46
46
 
47
- return { content: [{ type: 'text', text: JSON.stringify(deduped.slice(0, limit), null, 2) }] };
47
+ const top = deduped.slice(0, limit);
48
+
49
+ // Look up line numbers from symbols table (not stored in FTS index)
50
+ const fqnToLine = new Map();
51
+ for (const schema of schemas) {
52
+ const missing = top.filter(r => !fqnToLine.has(r.fqn)).map(r => r.fqn);
53
+ if (missing.length === 0) break;
54
+ try {
55
+ const phs = missing.map(() => '?').join(',');
56
+ const rows = db.prepare(`SELECT fqn, line FROM ${schema}.symbols WHERE fqn IN (${phs})`).all(...missing);
57
+ for (const r of rows) if (r.line != null) fqnToLine.set(r.fqn, r.line);
58
+ } catch {}
59
+ }
60
+
61
+ const results = top.map(r => ({ ...r, line: fqnToLine.get(r.fqn) ?? null }));
62
+ return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
48
63
  }
package/test/memo.test.js CHANGED
@@ -302,3 +302,28 @@ test('cairn_employ_memory returns error when source has no memory dir', () => {
302
302
  fs.rmSync(emptyDir, { recursive: true, force: true });
303
303
  }
304
304
  });
305
+
306
+ // ── loadPreferenceMemories file field (resume fix) ────────────────────────────
307
+
308
+ test('loadPreferenceMemories includes file field and full content is preserved', () => {
309
+ const longContent = 'x'.repeat(1000);
310
+ memo(null, {
311
+ action: 'write',
312
+ name: 'verbose pref',
313
+ type: 'preference',
314
+ content: longContent,
315
+ description: 'A long preference',
316
+ });
317
+
318
+ const memoryDir = path.join(tmpDir, '.cairn', 'memory');
319
+ const prefs = loadPreferenceMemories(memoryDir);
320
+
321
+ assert.equal(prefs.length, 1);
322
+ assert.ok('file' in prefs[0], 'file field must be present');
323
+ assert.ok(prefs[0].file.endsWith('.md'), 'file must be a markdown path');
324
+ // Full content must not be truncated
325
+ assert.ok(prefs[0].content.includes(longContent), 'full content must be preserved in memory object');
326
+ // File on disk must also have full content
327
+ const onDisk = fs.readFileSync(path.join(memoryDir, prefs[0].file), 'utf8');
328
+ assert.ok(onDisk.includes(longContent), 'full content must be on disk');
329
+ });