@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 +55 -15
- package/index.js +69 -2
- package/package.json +1 -1
- package/src/graph/cwd.js +21 -1
- package/src/indexer/parsers/javaParser.js +6 -3
- package/src/tools/employMemory.js +135 -0
- package/src/tools/memo.js +168 -0
- package/src/tools/outline.js +10 -5
- package/src/tools/relocate.js +113 -0
- package/src/tools/resume.js +58 -23
- package/src/tools/switch.js +93 -0
- package/src/tools/todos.js +3 -2
- package/test/cairn-cli.test.js +8 -4
- package/test/memo.test.js +304 -0
- package/test/relocate.test.js +166 -0
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' →
|
|
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
|
-
//
|
|
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
|
|
141
|
-
//
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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(
|
|
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':
|
|
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.
|
|
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(
|
|
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
|
|
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
|
+
}
|
package/src/tools/outline.js
CHANGED
|
@@ -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
|
|
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 →
|
|
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).
|
|
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,
|
|
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(', '),
|