@misterhuydo/cairn-mcp 1.5.1 → 1.6.4

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
@@ -27,7 +27,7 @@ if (subcommand === '--version' || subcommand === '-v') {
27
27
  // PreToolUse[Read] hook.
28
28
  // Per-file state machine stored in .cairn/minify-map.json:
29
29
  // no entry → minify, write to .cairn/views/, block Read (exit 2) with inline content or outline
30
- // 'compressed' → re-read after edit-guard blocked; advance to 'edit-ready', pass through (exit 0)
30
+ // 'compressed' → edit-guard blocked Edit + provided content; if re-read happens, advance to 'edit-ready'
31
31
  // 'edit-ready' → Edit is imminent; pass through without touching state (exit 0)
32
32
  let stdinData = '';
33
33
  process.stdin.setEncoding('utf8');
@@ -69,7 +69,8 @@ if (subcommand === '--version' || subcommand === '-v') {
69
69
  }
70
70
 
71
71
  if (entry?.state === 'compressed') {
72
- // Re-read requested by edit-guard: advance to edit-ready so Edit can proceed
72
+ // edit-guard already advanced to 'edit-ready' and showed content inline.
73
+ // If Claude re-reads the original anyway, advance to edit-ready and pass through.
73
74
  entry.state = 'edit-ready';
74
75
  saveMap(cairnDir, map);
75
76
  process.exit(0);
@@ -134,19 +135,22 @@ if (subcommand === '--version' || subcommand === '-v') {
134
135
  });
135
136
 
136
137
  } else if (subcommand === 'edit-guard') {
137
- // PreToolUse[Edit] hook.
138
+ // PreToolUse[Edit|Write] hook.
138
139
  // Per-file state:
139
- // no entry → file was never minified (full content always shown), allow Edit
140
- // 'compressed' → block Edit, instruct Claude to re-read original first
141
- // 'edit-ready' → re-read was done with full content, allow Edit and clear entry
140
+ // no entry → file was never minified (full content always shown), allow Edit/Write
141
+ // 'compressed' → block Edit; advance to 'edit-ready' and provide full content inline
142
+ // Write always allowed (it supplies its own content); clears entry
143
+ // 'edit-ready' → content was provided/re-read, allow Edit and clear entry
142
144
  let stdinData = '';
143
145
  process.stdin.setEncoding('utf8');
144
146
  process.stdin.on('data', chunk => { stdinData += chunk; });
145
147
  process.stdin.on('end', () => {
146
148
  let filePath = '(unknown)';
149
+ let toolName = 'Edit';
147
150
  try {
148
151
  const input = JSON.parse(stdinData);
149
152
  filePath = input?.tool_input?.file_path || '(unknown)';
153
+ toolName = input?.tool_name || 'Edit';
150
154
  } catch { }
151
155
 
152
156
  const resolvedPath = path.resolve(filePath);
@@ -164,18 +168,44 @@ if (subcommand === '--version' || subcommand === '-v') {
164
168
  process.exit(0);
165
169
  }
166
170
 
171
+ // Write replaces the entire file — no need to show old content. Clear and allow.
172
+ if (toolName === 'Write') {
173
+ delete map[resolvedPath];
174
+ saveMap(cairnDir, map);
175
+ process.exit(0);
176
+ }
177
+
167
178
  if (entry.state === 'edit-ready') {
168
179
  delete map[resolvedPath];
169
180
  saveMap(cairnDir, map);
170
181
  process.exit(0);
171
182
  }
172
183
 
173
- // state='compressed': Claude hasn't re-read with full content yet
174
- saveMap(cairnDir, map); // save any cleanup from validateMap
175
- process.stderr.write(
176
- `[cairn] Re-read the file before editing: Read("${filePath}")\n` +
177
- `The file content was compressed by cairn. Re-read it now to get full content, then retry the Edit.\n`
178
- );
184
+ // state='compressed': Claude hasn't re-read with full content yet.
185
+ // Advance to 'edit-ready' now so the next Edit attempt is allowed regardless
186
+ // of whether Claude re-reads the original or the cached view.
187
+ entry.state = 'edit-ready';
188
+ saveMap(cairnDir, map);
189
+
190
+ // For small-to-medium files, provide full content inline so Claude can retry
191
+ // without a separate Read round-trip, and so there's no cached-view confusion.
192
+ let fullContent = null;
193
+ try {
194
+ const raw = fs.readFileSync(resolvedPath, 'utf8');
195
+ if (raw.split('\n').length <= 300) fullContent = raw;
196
+ } catch { }
197
+
198
+ if (fullContent !== null) {
199
+ process.stdout.write(
200
+ `[cairn] File was compressed in context. Full source content:\n\n${fullContent}\n\n` +
201
+ `Re-apply your Edit using the exact content above (state is now edit-ready).\n`
202
+ );
203
+ } else {
204
+ process.stdout.write(
205
+ `[cairn] Re-read the file before editing: Read("${filePath}")\n` +
206
+ `The file content was compressed by cairn. Re-read it now to get full content, then retry the Edit.\n`
207
+ );
208
+ }
179
209
  process.exit(2);
180
210
  });
181
211
 
@@ -279,7 +309,12 @@ if (subcommand === '--version' || subcommand === '-v') {
279
309
  }
280
310
  const session = JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
281
311
  const when = new Date(session.checkpoint_at).toLocaleString();
282
- process.stdout.write(`[cairn] Prior session: "${session.message}" (${when}). Call cairn_resume to restore prior context.\n`);
312
+ process.stdout.write([
313
+ `[cairn] Prior session: "${session.message}" (${when}).`,
314
+ `[cairn] SESSION: call cairn_resume to restore context (or cairn_maintain for a fresh index). At end of session call cairn_checkpoint with message + active_files + notes.`,
315
+ `[cairn] TOOLS: prefer cairn_search / cairn_bundle / cairn_describe over manual file browsing. Use cairn_outline for structural overview, cairn_todos for backlog, cairn_security before PRs.`,
316
+ `[cairn] MEMORY: IMPORTANT — call cairn_memo IMMEDIATELY and SILENTLY (do NOT ask the user, do NOT list what you are about to save, do NOT confirm afterward) when you observe — preference: user corrects/confirms your behaviour; experience: non-trivial problem solved; decision: architectural choice made; knowledge: non-obvious codebase fact discovered. cairn_employ_memory is explicit only — never call it unless asked.`,
317
+ ].join('\n') + '\n');
283
318
  fs.writeFileSync(lockPath, new Date().toISOString(), 'utf8');
284
319
  process.exit(0);
285
320
 
@@ -311,7 +346,8 @@ if (subcommand === '--version' || subcommand === '-v') {
311
346
  console.log('');
312
347
  console.log('Active hooks:');
313
348
  console.log(' PreToolUse[Read] -> cairn minify (compress source files for reading)');
314
- console.log(' PreToolUse[Edit] -> cairn edit-guard (block Edit until re-read with full content)');
349
+ console.log(' PreToolUse[Edit] -> cairn edit-guard (block Edit until re-read with full content)')
350
+ console.log(' PreToolUse[Write] -> cairn edit-guard (clear minify state before full file overwrite)');
315
351
  console.log(' Stop -> cairn checkpoint --auto (auto-save session)');
316
352
  console.log(' UserPromptSubmit -> cairn resume-hint (remind Claude of prior session)');
317
353
  console.log('');
@@ -330,7 +366,8 @@ if (subcommand === '--version' || subcommand === '-v') {
330
366
  console.log('');
331
367
  console.log('Active hooks:');
332
368
  console.log(' PreToolUse[Read] -> cairn minify (compress source files for reading)');
333
- console.log(' PreToolUse[Edit] -> cairn edit-guard (block Edit until re-read with full content)');
369
+ console.log(' PreToolUse[Edit] -> cairn edit-guard (block Edit until re-read with full content)')
370
+ console.log(' PreToolUse[Write] -> cairn edit-guard (clear minify state before full file overwrite)');
334
371
  console.log(' Stop -> cairn checkpoint --auto (auto-save session)');
335
372
  console.log(' UserPromptSubmit -> cairn resume-hint (remind Claude of prior session)');
336
373
  process.exit(0);
@@ -360,6 +397,9 @@ function applyHooks(settingsDir, isGlobal) {
360
397
  if (!cleanedPreHooks.some(h => h.matcher === 'Edit' && h.hooks?.some(hh => hh.command === 'cairn edit-guard'))) {
361
398
  cleanedPreHooks.push({ matcher: 'Edit', hooks: [{ type: 'command', command: 'cairn edit-guard' }] });
362
399
  }
400
+ if (!cleanedPreHooks.some(h => h.matcher === 'Write' && h.hooks?.some(hh => hh.command === 'cairn edit-guard'))) {
401
+ cleanedPreHooks.push({ matcher: 'Write', hooks: [{ type: 'command', command: 'cairn edit-guard' }] });
402
+ }
363
403
  settings.hooks.PreToolUse = cleanedPreHooks;
364
404
 
365
405
  // Stop: cairn checkpoint --auto
package/index.js CHANGED
@@ -14,6 +14,9 @@ import { resume } from './src/tools/resume.js';
14
14
  import { minify } from './src/tools/minify.js';
15
15
  import { outlineProject } from './src/tools/outline.js';
16
16
  import { todos } from './src/tools/todos.js';
17
+ import { memo } from './src/tools/memo.js';
18
+ import { switchProject } from './src/tools/switch.js';
19
+ import { employMemory } from './src/tools/employMemory.js';
17
20
 
18
21
  const db = openDB();
19
22
 
@@ -140,7 +143,7 @@ Typical workflow: cairn_search → find files → cairn_bundle those paths → C
140
143
  },
141
144
  {
142
145
  name: 'cairn_resume',
143
- description: 'Restore the last saved session state. Detects which files changed since the checkpoint and incrementally re-indexes only those files. Call at the start of a session instead of cairn_maintain when resuming work.',
146
+ description: 'Restore the last saved session state. Detects which files changed since the checkpoint and incrementally re-indexes only those files. Also loads preference memories and surfaces the memory index. Call at the start of a session instead of cairn_maintain when resuming work. If the project was moved to a different drive or directory, paths are repaired automatically.',
144
147
  inputSchema: {
145
148
  type: 'object',
146
149
  properties: {},
@@ -188,6 +191,67 @@ Use to: get a project-wide bird's-eye view, triage code quality, find where to l
188
191
  required: ['file_path'],
189
192
  },
190
193
  },
194
+ {
195
+ name: 'cairn_memo',
196
+ description: 'Persist and retrieve typed memories in .cairn/memory/. Use this — not the auto-memory system — for all project-level memories. Triggered by: "save cairn memory", "cairn remember", "build cairn memories", "save experience to cairn". Four types: preference (user working style — always loaded on resume), experience (transferable lessons learned — migrated between projects), decision (project-specific architectural choices), knowledge (project-specific codebase facts). Memories survive context loss and accumulate across sessions.',
197
+ inputSchema: {
198
+ type: 'object',
199
+ properties: {
200
+ action: {
201
+ type: 'string',
202
+ enum: ['write', 'read', 'list', 'delete'],
203
+ description: 'write: save/update a memory | read: load a memory by name | list: show all memories with index | delete: remove a memory',
204
+ },
205
+ name: { type: 'string', description: 'Memory identifier (required for write/read/delete)' },
206
+ type: {
207
+ type: 'string',
208
+ enum: ['preference', 'experience', 'decision', 'knowledge'],
209
+ description: 'Memory type (required for write)',
210
+ },
211
+ content: { type: 'string', description: 'Memory body — what should be remembered (required for write)' },
212
+ description: { type: 'string', description: 'One-line summary shown in the memory index' },
213
+ origin: { type: 'string', description: 'Optional: source project path, for traceability of migrated memories' },
214
+ },
215
+ required: ['action'],
216
+ },
217
+ },
218
+ {
219
+ name: 'cairn_switch',
220
+ description: 'Switch the active Cairn project context to a different directory. Detects the project root automatically from any path within the project (via .git, package.json, .cairn, etc.). Call cairn_resume or cairn_maintain after switching. Call with no arguments to return to the original session\'s project.',
221
+ inputSchema: {
222
+ type: 'object',
223
+ properties: {
224
+ project_path: {
225
+ type: 'string',
226
+ description: 'Any path inside the target project (file or directory). Omit to restore the original session root.',
227
+ },
228
+ },
229
+ },
230
+ },
231
+ {
232
+ name: 'cairn_employ_memory',
233
+ description: 'Migrate memories from another project\'s .cairn/memory/ into the current project. By default copies only transferable types (preference + experience) — skips project-specific decision and knowledge. Imported memories are tagged with their origin project path for traceability.',
234
+ inputSchema: {
235
+ type: 'object',
236
+ properties: {
237
+ source_path: {
238
+ type: 'string',
239
+ description: 'Path to the source project (any file/directory inside it, or its .cairn directory)',
240
+ },
241
+ types: {
242
+ type: 'array',
243
+ items: { type: 'string', enum: ['preference', 'experience', 'decision', 'knowledge'] },
244
+ description: 'Optional: memory types to migrate (default: ["preference", "experience"])',
245
+ },
246
+ overwrite: {
247
+ type: 'boolean',
248
+ default: false,
249
+ description: 'Overwrite existing memories with the same filename (default: false)',
250
+ },
251
+ },
252
+ required: ['source_path'],
253
+ },
254
+ },
191
255
  {
192
256
  name: 'cairn_todos',
193
257
  description: 'Manage TODO/FIXME/HACK/XXX items. Scans the codebase for TODO-style comments and lets you add your own. Stats are included in cairn_maintain and cairn_resume reports.',
@@ -224,7 +288,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
224
288
  case 'cairn_resume': return await resume(db);
225
289
  case 'cairn_outline': return outlineProject(db, args);
226
290
  case 'cairn_minify': return minify(db, args);
227
- case 'cairn_todos': return await todos(db, args);
291
+ case 'cairn_todos': return await todos(db, args);
292
+ case 'cairn_memo': return memo(db, args);
293
+ case 'cairn_switch': return switchProject(db, args);
294
+ case 'cairn_employ_memory': return employMemory(db, args);
228
295
  default: throw new Error(`Unknown tool: ${name}`);
229
296
  }
230
297
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/cairn-mcp",
3
- "version": "1.5.1",
3
+ "version": "1.6.4",
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",
package/src/graph/cwd.js CHANGED
@@ -1,11 +1,31 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
 
4
+ let _activeProjectRoot = null;
5
+
6
+ export function getProjectRoot() {
7
+ return _activeProjectRoot || process.cwd();
8
+ }
9
+
10
+ export function setActiveProjectRoot(rootPath) {
11
+ const previous = _activeProjectRoot;
12
+ _activeProjectRoot = rootPath;
13
+ return previous;
14
+ }
15
+
4
16
  export function getCairnDir() {
5
- const cairnDir = path.join(process.cwd(), '.cairn');
17
+ const cairnDir = path.join(getProjectRoot(), '.cairn');
6
18
  fs.mkdirSync(path.join(cairnDir, 'bundles'), { recursive: true });
7
19
  // Write sentinel so findCairnDir() recognises this as a cairn project directory
8
20
  const sentinel = path.join(cairnDir, '.cairn-project');
9
21
  if (!fs.existsSync(sentinel)) fs.writeFileSync(sentinel, '', 'utf8');
10
22
  return cairnDir;
11
23
  }
24
+
25
+ export function getMemoryDir() {
26
+ const memoryDir = path.join(getCairnDir(), 'memory');
27
+ fs.mkdirSync(memoryDir, { recursive: true });
28
+ const indexPath = path.join(memoryDir, 'MEMORY.md');
29
+ if (!fs.existsSync(indexPath)) fs.writeFileSync(indexPath, '# Memory Index\n', 'utf8');
30
+ return memoryDir;
31
+ }
@@ -9,7 +9,7 @@ export function parseJava(filePath, content, repoName) {
9
9
  const kind = classMatch?.[0]?.match(/class|interface|enum|record/)?.[0] || 'class';
10
10
  const fqn = pkg ? `${pkg}.${name}` : name;
11
11
 
12
- const methods = [...content.matchAll(
12
+ const methodNames = [...content.matchAll(
13
13
  /(?:public|protected|private)[^{;]*\s+(\w+)\s*\([^)]*\)\s*(?:throws[^{]*)?\{/gm
14
14
  )].map(m => m[1]);
15
15
 
@@ -20,12 +20,15 @@ export function parseJava(filePath, content, repoName) {
20
20
  const javadoc = content.match(/\/\*\*([\s\S]*?)\*\//)?.[1]
21
21
  ?.replace(/\s*\*\s?/g, ' ').trim();
22
22
 
23
+ const methodSymbols = fqn
24
+ ? methodNames.map(m => ({ name: m, fqn: `${fqn}.${m}`, kind: 'method', exported: false, description: '' }))
25
+ : [];
26
+
23
27
  return {
24
28
  language: 'java',
25
- symbols: [{ name, fqn, kind, exported: true, description: javadoc || '' }],
29
+ symbols: [{ name, fqn, kind, exported: true, description: javadoc || '' }, ...methodSymbols],
26
30
  imports,
27
31
  extends: extendsMatch ? [extendsMatch] : [],
28
32
  implements: implementsMatch || [],
29
- methods,
30
33
  };
31
34
  }
@@ -0,0 +1,135 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { getMemoryDir } from '../graph/cwd.js';
4
+ import { detectProjectRoot } from './switch.js';
5
+ import { loadIndexEntries, TRANSFERABLE_TYPES } from './memo.js';
6
+
7
+ export function employMemory(_db, { source_path, types, overwrite = false }) {
8
+ if (!source_path) throw new Error('source_path is required');
9
+
10
+ // Resolve source .cairn/memory/ directory
11
+ let sourceMemoryDir;
12
+ const normalized = source_path.replace(/\\/g, '/');
13
+
14
+ if (normalized.endsWith('/.cairn/memory') || normalized.endsWith('\\.cairn\\memory')) {
15
+ sourceMemoryDir = source_path;
16
+ } else if (normalized.endsWith('/.cairn') || normalized.endsWith('\\.cairn')) {
17
+ sourceMemoryDir = path.join(source_path, 'memory');
18
+ } else {
19
+ const projectRoot = detectProjectRoot(source_path);
20
+ if (!projectRoot) {
21
+ return {
22
+ content: [{
23
+ type: 'text',
24
+ text: JSON.stringify({
25
+ migrated: false,
26
+ reason: 'Could not detect a project root from source_path.',
27
+ source_path,
28
+ }, null, 2),
29
+ }],
30
+ };
31
+ }
32
+ sourceMemoryDir = path.join(projectRoot, '.cairn', 'memory');
33
+ }
34
+
35
+ if (!fs.existsSync(sourceMemoryDir)) {
36
+ return {
37
+ content: [{
38
+ type: 'text',
39
+ text: JSON.stringify({
40
+ migrated: false,
41
+ reason: 'Source project has no .cairn/memory directory. Run cairn_maintain or cairn_resume there first.',
42
+ source_memory_dir: sourceMemoryDir,
43
+ }, null, 2),
44
+ }],
45
+ };
46
+ }
47
+
48
+ const allowedTypes = types && types.length > 0 ? types : TRANSFERABLE_TYPES;
49
+ const sourceEntries = loadIndexEntries(sourceMemoryDir).filter(e => allowedTypes.includes(e.type));
50
+
51
+ if (sourceEntries.length === 0) {
52
+ return {
53
+ content: [{
54
+ type: 'text',
55
+ text: JSON.stringify({
56
+ migrated: false,
57
+ reason: `No memories of types [${allowedTypes.join(', ')}] found in source project.`,
58
+ source_memory_dir: sourceMemoryDir,
59
+ }, null, 2),
60
+ }],
61
+ };
62
+ }
63
+
64
+ const targetMemoryDir = getMemoryDir();
65
+
66
+ // Determine origin label from source path
67
+ const originLabel = path.dirname(path.dirname(sourceMemoryDir)); // project root
68
+
69
+ // Load target index
70
+ const targetEntries = loadIndexEntries(targetMemoryDir);
71
+ const targetFileSet = new Set(targetEntries.map(e => e.file));
72
+
73
+ const results = { copied: [], skipped: [], errors: [] };
74
+
75
+ for (const entry of sourceEntries) {
76
+ const srcFile = path.join(sourceMemoryDir, entry.file);
77
+ if (!fs.existsSync(srcFile)) {
78
+ results.errors.push({ name: entry.name, reason: 'source file missing' });
79
+ continue;
80
+ }
81
+
82
+ const destFile = path.join(targetMemoryDir, entry.file);
83
+ const alreadyExists = targetFileSet.has(entry.file);
84
+
85
+ if (alreadyExists && !overwrite) {
86
+ results.skipped.push({ name: entry.name, reason: 'already exists (use overwrite: true to replace)' });
87
+ continue;
88
+ }
89
+
90
+ // Read source, inject origin if not already set
91
+ let raw = fs.readFileSync(srcFile, 'utf8');
92
+ if (!raw.includes('\norigin:')) {
93
+ raw = raw.replace('---\n\n', `---\norigin: ${originLabel}\n\n`);
94
+ // fallback if pattern doesn't match
95
+ if (!raw.includes(`origin: ${originLabel}`)) {
96
+ raw = raw.replace(/^---\n/, `---\norigin: ${originLabel}\n`);
97
+ }
98
+ }
99
+
100
+ try {
101
+ fs.writeFileSync(destFile, raw, 'utf8');
102
+ if (!alreadyExists) {
103
+ targetEntries.push({ ...entry });
104
+ targetFileSet.add(entry.file);
105
+ }
106
+ results.copied.push({ name: entry.name, type: entry.type, origin: originLabel });
107
+ } catch (e) {
108
+ results.errors.push({ name: entry.name, reason: e.message });
109
+ }
110
+ }
111
+
112
+ // Rewrite target MEMORY.md index
113
+ if (results.copied.length > 0) {
114
+ const lines = ['# Memory Index', ''];
115
+ for (const e of targetEntries) {
116
+ lines.push(`- [${e.name}](${e.file}) — ${e.description} \`[${e.type}]\``);
117
+ }
118
+ fs.writeFileSync(path.join(targetMemoryDir, 'MEMORY.md'), lines.join('\n') + '\n', 'utf8');
119
+ }
120
+
121
+ return {
122
+ content: [{
123
+ type: 'text',
124
+ text: JSON.stringify({
125
+ migrated: results.copied.length > 0,
126
+ source: originLabel,
127
+ types_migrated: allowedTypes,
128
+ copied: results.copied.length,
129
+ skipped: results.skipped.length,
130
+ errors: results.errors.length,
131
+ details: results,
132
+ }, null, 2),
133
+ }],
134
+ };
135
+ }
@@ -0,0 +1,168 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { getMemoryDir } from '../graph/cwd.js';
4
+
5
+ export const TRANSFERABLE_TYPES = ['preference', 'experience'];
6
+ export const MEMORY_TYPES = ['preference', 'experience', 'decision', 'knowledge'];
7
+
8
+ function slugify(name) {
9
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
10
+ }
11
+
12
+ function memoryFilename(type, name) {
13
+ return `${type}_${slugify(name)}.md`;
14
+ }
15
+
16
+ function parseFrontmatter(raw) {
17
+ const match = raw.match(/^---\n([\s\S]*?)\n---/);
18
+ if (!match) return {};
19
+ const result = {};
20
+ for (const line of match[1].split('\n')) {
21
+ const colonIdx = line.indexOf(':');
22
+ if (colonIdx === -1) continue;
23
+ result[line.slice(0, colonIdx).trim()] = line.slice(colonIdx + 1).trim();
24
+ }
25
+ return result;
26
+ }
27
+
28
+ function readIndex(memoryDir) {
29
+ const indexPath = path.join(memoryDir, 'MEMORY.md');
30
+ if (!fs.existsSync(indexPath)) return '';
31
+ return fs.readFileSync(indexPath, 'utf8');
32
+ }
33
+
34
+ function writeIndex(memoryDir, entries) {
35
+ const lines = ['# Memory Index', ''];
36
+ for (const e of entries) {
37
+ lines.push(`- [${e.name}](${e.file}) — ${e.description} \`[${e.type}]\``);
38
+ }
39
+ fs.writeFileSync(path.join(memoryDir, 'MEMORY.md'), lines.join('\n') + '\n', 'utf8');
40
+ }
41
+
42
+ export function loadIndexEntries(memoryDir) {
43
+ const index = readIndex(memoryDir);
44
+ const entries = [];
45
+ for (const line of index.split('\n')) {
46
+ const m = line.match(/^- \[(.+?)\]\((.+?)\) — (.*?) `\[(.+?)\]`$/);
47
+ if (m) entries.push({ name: m[1], file: m[2], description: m[3], type: m[4] });
48
+ }
49
+ return entries;
50
+ }
51
+
52
+ export function loadPreferenceMemories(memoryDir) {
53
+ const entries = loadIndexEntries(memoryDir).filter(e => e.type === 'preference');
54
+ return entries
55
+ .map(e => {
56
+ const filePath = path.join(memoryDir, e.file);
57
+ if (!fs.existsSync(filePath)) return null;
58
+ return { name: e.name, description: e.description, content: fs.readFileSync(filePath, 'utf8') };
59
+ })
60
+ .filter(Boolean);
61
+ }
62
+
63
+ export function memo(_db, { action, name, type, content, description = '', origin }) {
64
+ const memoryDir = getMemoryDir();
65
+
66
+ if (action === 'list') {
67
+ const entries = loadIndexEntries(memoryDir);
68
+ return {
69
+ content: [{
70
+ type: 'text',
71
+ text: JSON.stringify({
72
+ total: entries.length,
73
+ index: readIndex(memoryDir) || '(no memories yet)',
74
+ entries,
75
+ }, null, 2),
76
+ }],
77
+ };
78
+ }
79
+
80
+ if (!name) throw new Error('name is required');
81
+
82
+ if (action === 'read') {
83
+ const entry = loadIndexEntries(memoryDir)
84
+ .find(e => e.name === name || slugify(e.name) === slugify(name));
85
+ if (!entry) {
86
+ return { content: [{ type: 'text', text: JSON.stringify({ found: false, name }, null, 2) }] };
87
+ }
88
+ const filePath = path.join(memoryDir, entry.file);
89
+ if (!fs.existsSync(filePath)) {
90
+ return { content: [{ type: 'text', text: JSON.stringify({ found: false, name, reason: 'file missing' }, null, 2) }] };
91
+ }
92
+ return {
93
+ content: [{
94
+ type: 'text',
95
+ text: JSON.stringify({
96
+ found: true,
97
+ name: entry.name,
98
+ type: entry.type,
99
+ description: entry.description,
100
+ content: fs.readFileSync(filePath, 'utf8'),
101
+ }, null, 2),
102
+ }],
103
+ };
104
+ }
105
+
106
+ if (action === 'delete') {
107
+ const entries = loadIndexEntries(memoryDir);
108
+ const idx = entries.findIndex(e => e.name === name || slugify(e.name) === slugify(name));
109
+ if (idx === -1) {
110
+ return { content: [{ type: 'text', text: JSON.stringify({ deleted: false, reason: 'not found' }, null, 2) }] };
111
+ }
112
+ const [removed] = entries.splice(idx, 1);
113
+ const filePath = path.join(memoryDir, removed.file);
114
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
115
+ writeIndex(memoryDir, entries);
116
+ return { content: [{ type: 'text', text: JSON.stringify({ deleted: true, name }, null, 2) }] };
117
+ }
118
+
119
+ // action === 'write'
120
+ if (!type) throw new Error('type is required for write');
121
+ if (!content) throw new Error('content is required for write');
122
+ if (!MEMORY_TYPES.includes(type)) {
123
+ throw new Error(`type must be one of: ${MEMORY_TYPES.join(', ')}`);
124
+ }
125
+
126
+ const filename = memoryFilename(type, name);
127
+ const filePath = path.join(memoryDir, filename);
128
+ const isUpdate = fs.existsSync(filePath);
129
+ const now = new Date().toISOString();
130
+
131
+ let createdAt = now;
132
+ if (isUpdate) {
133
+ const fm = parseFrontmatter(fs.readFileSync(filePath, 'utf8'));
134
+ if (fm.created_at) createdAt = fm.created_at;
135
+ }
136
+
137
+ const fmLines = [
138
+ '---',
139
+ `name: ${name}`,
140
+ `description: ${description || name}`,
141
+ `type: ${type}`,
142
+ ];
143
+ if (origin) fmLines.push(`origin: ${origin}`);
144
+ fmLines.push(`created_at: ${createdAt}`, `updated_at: ${now}`, '---');
145
+
146
+ fs.writeFileSync(filePath, fmLines.join('\n') + '\n\n' + content + '\n', 'utf8');
147
+
148
+ // Update index
149
+ const entries = loadIndexEntries(memoryDir);
150
+ const existingIdx = entries.findIndex(e => e.file === filename);
151
+ const entry = { name, file: filename, description: description || name, type };
152
+ if (existingIdx >= 0) entries[existingIdx] = entry;
153
+ else entries.push(entry);
154
+ writeIndex(memoryDir, entries);
155
+
156
+ return {
157
+ content: [{
158
+ type: 'text',
159
+ text: JSON.stringify({
160
+ saved: true,
161
+ name,
162
+ type,
163
+ file: filePath,
164
+ action: isUpdate ? 'updated' : 'created',
165
+ }, null, 2),
166
+ }],
167
+ };
168
+ }
@@ -46,11 +46,15 @@ export function outlineProject(db, { paths: scopePaths, checks } = {}) {
46
46
 
47
47
  // ── 2. Load all symbols and group by file ────────────────────────────────────
48
48
  const fileMap = new Map(); // fileId → { path, language, symbols[], classes{} }
49
+ const seenRelPaths = new Set();
49
50
  for (const f of fileRows) {
51
+ const relPath = path.relative(cwd, f.path).replace(/\\/g, '/');
52
+ if (seenRelPaths.has(relPath)) continue; // skip federation duplicates
53
+ seenRelPaths.add(relPath);
50
54
  fileMap.set(f.id, {
51
55
  id: f.id,
52
56
  path: f.path,
53
- relPath: path.relative(cwd, f.path).replace(/\\/g, '/'),
57
+ relPath,
54
58
  language: f.language,
55
59
  symbols: [],
56
60
  });
@@ -216,14 +220,15 @@ export function outlineProject(db, { paths: scopePaths, checks } = {}) {
216
220
  }
217
221
 
218
222
  // ── Duplicate class names across files ──────────────────────────────────────
219
- const classNameFiles = new Map(); // className → [relPath]
223
+ const classNameFiles = new Map(); // className → Set<relPath>
220
224
  for (const file of fileMap.values()) {
221
225
  for (const sym of file.symbols.filter(s => s.kind === 'class')) {
222
- if (!classNameFiles.has(sym.name)) classNameFiles.set(sym.name, []);
223
- classNameFiles.get(sym.name).push(file.relPath);
226
+ if (!classNameFiles.has(sym.name)) classNameFiles.set(sym.name, new Set());
227
+ classNameFiles.get(sym.name).add(file.relPath);
224
228
  }
225
229
  }
226
- for (const [className, files] of classNameFiles) {
230
+ for (const [className, filesSet] of classNameFiles) {
231
+ const files = [...filesSet];
227
232
  if (files.length > 1) {
228
233
  suspects.push({
229
234
  file: files.join(', '),