memoir-cli 3.1.0 → 3.2.0
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/GAMEPLAN.md +235 -0
- package/README.md +152 -54
- package/bin/memoir.js +78 -3
- package/package.json +9 -4
- package/src/commands/projects.js +240 -0
- package/src/commands/push.js +18 -4
- package/src/commands/restore.js +197 -3
- package/src/commands/share.js +192 -0
- package/src/commands/upgrade.js +107 -0
- package/src/context/capture.js +223 -1
- package/src/mcp.js +429 -0
- package/src/providers/index.js +6 -6
- package/src/security/encryption.js +98 -46
- package/src/workspace/tracker.js +4 -4
package/src/context/capture.js
CHANGED
|
@@ -61,6 +61,7 @@ export function parseSession(sessionPath, maxSizeMB = 10) {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
function parseLines(lines) {
|
|
64
|
+
const assistantTexts = [];
|
|
64
65
|
const result = {
|
|
65
66
|
sessionId: null,
|
|
66
67
|
slug: null,
|
|
@@ -95,9 +96,14 @@ function parseLines(lines) {
|
|
|
95
96
|
}
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
// Tool uses from assistant
|
|
99
|
+
// Tool uses and text from assistant
|
|
99
100
|
if (obj.type === 'assistant' && Array.isArray(obj.message?.content)) {
|
|
100
101
|
for (const block of obj.message.content) {
|
|
102
|
+
if (block.type === 'text' && block.text) {
|
|
103
|
+
// Capture assistant text for decision extraction (limit size)
|
|
104
|
+
if (block.text.length < 2000) assistantTexts.push(block.text);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
101
107
|
if (block.type !== 'tool_use') continue;
|
|
102
108
|
const name = block.name;
|
|
103
109
|
const input = block.input || {};
|
|
@@ -139,9 +145,225 @@ function parseLines(lines) {
|
|
|
139
145
|
result.filesRead = [...result.filesRead];
|
|
140
146
|
result.errors = [...new Set(result.errors)].slice(0, 10);
|
|
141
147
|
|
|
148
|
+
// Extract decisions from user + assistant messages
|
|
149
|
+
result.decisions = extractDecisions(result.userMessages, assistantTexts);
|
|
150
|
+
|
|
142
151
|
return result;
|
|
143
152
|
}
|
|
144
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Extract durable decisions from session conversation.
|
|
156
|
+
* These are things like renames, tech choices, preferences — stuff that should persist.
|
|
157
|
+
*/
|
|
158
|
+
function extractDecisions(userMessages, assistantTexts) {
|
|
159
|
+
const decisions = [];
|
|
160
|
+
const allText = [...userMessages, ...assistantTexts].join('\n');
|
|
161
|
+
|
|
162
|
+
// Patterns that indicate a decision was made
|
|
163
|
+
const patterns = [
|
|
164
|
+
// Renames / naming
|
|
165
|
+
{ regex: /(?:rename|call|name)\s+(?:it|this|the (?:project|app|tool|product))\s+(?:to\s+)?["']?([A-Z][a-zA-Z0-9_-]+)["']?/gi, type: 'rename' },
|
|
166
|
+
{ regex: /(?:the\s+)?(?:new\s+)?name\s+(?:is|will be|should be)\s+["']?([A-Z][a-zA-Z0-9_-]+)["']?/gi, type: 'rename' },
|
|
167
|
+
{ regex: /(?:rebrand|rebranding)\s+(?:to|as)\s+["']?([A-Z][a-zA-Z0-9_-]+)["']?/gi, type: 'rename' },
|
|
168
|
+
// Tech choices
|
|
169
|
+
{ regex: /(?:let'?s|we(?:'ll| will| should)?|going to|decided to)\s+use\s+([A-Z][a-zA-Z0-9_./-]+)\s+(?:for|instead|as|to)/gi, type: 'tech' },
|
|
170
|
+
{ regex: /(?:switch|migrate|move)\s+(?:from\s+\S+\s+)?to\s+([A-Z][a-zA-Z0-9_./-]+)/gi, type: 'tech' },
|
|
171
|
+
// Architecture / design
|
|
172
|
+
{ regex: /(?:let'?s|we(?:'ll| will| should)?)\s+(?:go with|pick|choose)\s+(.{5,60}?)(?:\.|$|,|\n)/gi, type: 'design' },
|
|
173
|
+
// Stack choices
|
|
174
|
+
{ regex: /(?:stack|framework|database|backend|frontend)\s+(?:is|will be|should be)\s+(.{5,60}?)(?:\.|$|,|\n)/gi, type: 'stack' },
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
for (const { regex, type } of patterns) {
|
|
178
|
+
let match;
|
|
179
|
+
while ((match = regex.exec(allText)) !== null) {
|
|
180
|
+
const value = match[1].trim().replace(/["']+$/, '');
|
|
181
|
+
if (value.length > 2 && value.length < 80) {
|
|
182
|
+
// Avoid duplicates
|
|
183
|
+
const existing = decisions.find(d => d.value.toLowerCase() === value.toLowerCase());
|
|
184
|
+
if (!existing) {
|
|
185
|
+
decisions.push({ type, value, context: match[0].trim().slice(0, 120) });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Look for explicit "remember this" instructions from the user
|
|
192
|
+
for (const msg of userMessages) {
|
|
193
|
+
// Only match when user is clearly asking to remember something
|
|
194
|
+
const rememberMatch = msg.match(/(?:remember (?:that|this)|note that|keep in mind that|from now on)[:\s]+(.{10,150})/i);
|
|
195
|
+
if (rememberMatch) {
|
|
196
|
+
decisions.push({ type: 'user-note', value: rememberMatch[1].trim(), context: msg.slice(0, 120) });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return decisions.slice(0, 20); // Cap at 20 decisions per session
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Write extracted decisions to Claude's persistent memory.
|
|
205
|
+
* This ensures decisions survive across sessions and machines.
|
|
206
|
+
*/
|
|
207
|
+
export function persistDecisions(decisions, claudeSource) {
|
|
208
|
+
if (!decisions || decisions.length === 0) return 0;
|
|
209
|
+
|
|
210
|
+
const claudeDir = claudeSource || path.join(home, '.claude');
|
|
211
|
+
const projectsDir = path.join(claudeDir, 'projects');
|
|
212
|
+
if (!fs.existsSync(projectsDir)) return 0;
|
|
213
|
+
|
|
214
|
+
// Find the HOME-level memory dir (not project-specific)
|
|
215
|
+
// This is the dir that matches the user's home path encoding
|
|
216
|
+
let homeKey;
|
|
217
|
+
if (process.platform === 'win32') {
|
|
218
|
+
homeKey = home.replace(/\\/g, '-').replace(/:/g, '-');
|
|
219
|
+
} else {
|
|
220
|
+
homeKey = '-' + home.replace(/^\//, '').replace(/\//g, '-');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Try exact match first, then detect from existing dirs
|
|
224
|
+
let memDir = path.join(projectsDir, homeKey, 'memory');
|
|
225
|
+
if (!fs.existsSync(memDir)) {
|
|
226
|
+
// Fallback: find dirs with memory/ subfolder, pick shortest name (likely home-level)
|
|
227
|
+
const entries = fs.readdirSync(projectsDir, { withFileTypes: true })
|
|
228
|
+
.filter(e => e.isDirectory() && fs.existsSync(path.join(projectsDir, e.name, 'memory')));
|
|
229
|
+
if (entries.length === 0) return 0;
|
|
230
|
+
// Shortest dir name is most likely the home key (not a sub-project)
|
|
231
|
+
const homeEntry = entries.sort((a, b) => a.name.length - b.name.length)[0];
|
|
232
|
+
memDir = path.join(projectsDir, homeEntry.name, 'memory');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
fs.mkdirSync(memDir, { recursive: true });
|
|
236
|
+
const decisionsFile = path.join(memDir, 'session-decisions.md');
|
|
237
|
+
const memoryMdPath = path.join(memDir, 'MEMORY.md');
|
|
238
|
+
|
|
239
|
+
// Read existing decisions file or create new
|
|
240
|
+
let existing = '';
|
|
241
|
+
if (fs.existsSync(decisionsFile)) {
|
|
242
|
+
existing = fs.readFileSync(decisionsFile, 'utf8');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Format new decisions
|
|
246
|
+
const date = new Date().toISOString().split('T')[0];
|
|
247
|
+
const newEntries = decisions.map(d => {
|
|
248
|
+
if (d.type === 'rename') return `- **Renamed:** ${d.context}`;
|
|
249
|
+
if (d.type === 'tech') return `- **Tech choice:** ${d.context}`;
|
|
250
|
+
if (d.type === 'design') return `- **Decision:** ${d.context}`;
|
|
251
|
+
if (d.type === 'stack') return `- **Stack:** ${d.context}`;
|
|
252
|
+
if (d.type === 'user-note') return `- **Note:** ${d.value}`;
|
|
253
|
+
return `- ${d.context}`;
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Check for duplicates against existing content
|
|
257
|
+
const fresh = newEntries.filter(entry => !existing.includes(entry));
|
|
258
|
+
if (fresh.length === 0) return 0;
|
|
259
|
+
|
|
260
|
+
const section = `\n### ${date}\n${fresh.join('\n')}\n`;
|
|
261
|
+
|
|
262
|
+
if (!existing) {
|
|
263
|
+
// Create new file with frontmatter
|
|
264
|
+
const content = `---
|
|
265
|
+
name: Session Decisions
|
|
266
|
+
description: Project decisions extracted from coding sessions — renames, tech choices, architecture
|
|
267
|
+
type: project
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
# Decisions from coding sessions
|
|
271
|
+
${section}`;
|
|
272
|
+
fs.writeFileSync(decisionsFile, content);
|
|
273
|
+
} else {
|
|
274
|
+
// Append to existing
|
|
275
|
+
fs.writeFileSync(decisionsFile, existing.trimEnd() + '\n' + section);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Ensure MEMORY.md references the decisions file
|
|
279
|
+
if (fs.existsSync(memoryMdPath)) {
|
|
280
|
+
const memoryMd = fs.readFileSync(memoryMdPath, 'utf8');
|
|
281
|
+
if (!memoryMd.includes('session-decisions.md')) {
|
|
282
|
+
const addition = `\n- [Session Decisions](session-decisions.md) — project renames, tech choices, architecture decisions from coding sessions\n`;
|
|
283
|
+
fs.writeFileSync(memoryMdPath, memoryMd.trimEnd() + addition);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return fresh.length;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Promote memories from project-scoped dirs to the home-level scope.
|
|
292
|
+
* Claude scopes memory per working directory — memories saved in ~/memoir
|
|
293
|
+
* are invisible from ~/btc-trader. This copies important .md files to the
|
|
294
|
+
* home-level scope so they're accessible from ANY directory.
|
|
295
|
+
*
|
|
296
|
+
* Only promotes files with frontmatter type: user or type: project (not ephemeral ones).
|
|
297
|
+
*/
|
|
298
|
+
export function promoteMemoriesToGlobal() {
|
|
299
|
+
const claudeDir = path.join(home, '.claude');
|
|
300
|
+
const projectsDir = path.join(claudeDir, 'projects');
|
|
301
|
+
if (!fs.existsSync(projectsDir)) return 0;
|
|
302
|
+
|
|
303
|
+
// Find the home-level key
|
|
304
|
+
let homeKey;
|
|
305
|
+
if (process.platform === 'win32') {
|
|
306
|
+
homeKey = home.replace(/\\/g, '-').replace(/:/g, '-');
|
|
307
|
+
} else {
|
|
308
|
+
homeKey = '-' + home.replace(/^\//, '').replace(/\//g, '-');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const homeMemDir = path.join(projectsDir, homeKey, 'memory');
|
|
312
|
+
fs.mkdirSync(homeMemDir, { recursive: true });
|
|
313
|
+
|
|
314
|
+
const homeMemoryMdPath = path.join(homeMemDir, 'MEMORY.md');
|
|
315
|
+
let homeMemoryMd = '';
|
|
316
|
+
if (fs.existsSync(homeMemoryMdPath)) {
|
|
317
|
+
homeMemoryMd = fs.readFileSync(homeMemoryMdPath, 'utf8');
|
|
318
|
+
} else {
|
|
319
|
+
homeMemoryMd = '# Project Memory\n';
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
let promoted = 0;
|
|
323
|
+
const entries = fs.readdirSync(projectsDir, { withFileTypes: true })
|
|
324
|
+
.filter(e => e.isDirectory() && e.name !== homeKey);
|
|
325
|
+
|
|
326
|
+
for (const entry of entries) {
|
|
327
|
+
const memDir = path.join(projectsDir, entry.name, 'memory');
|
|
328
|
+
if (!fs.existsSync(memDir)) continue;
|
|
329
|
+
|
|
330
|
+
const files = fs.readdirSync(memDir)
|
|
331
|
+
.filter(f => f.endsWith('.md') && f !== 'MEMORY.md' && f !== 'handoff.md');
|
|
332
|
+
|
|
333
|
+
for (const file of files) {
|
|
334
|
+
const destPath = path.join(homeMemDir, file);
|
|
335
|
+
// Skip if already exists in home scope
|
|
336
|
+
if (fs.existsSync(destPath)) continue;
|
|
337
|
+
|
|
338
|
+
const content = fs.readFileSync(path.join(memDir, file), 'utf8');
|
|
339
|
+
|
|
340
|
+
// Only promote files with type: user or type: project
|
|
341
|
+
const typeMatch = content.match(/^type:\s*(user|project)/m);
|
|
342
|
+
if (!typeMatch) continue;
|
|
343
|
+
|
|
344
|
+
// Copy to home scope
|
|
345
|
+
fs.writeFileSync(destPath, content);
|
|
346
|
+
|
|
347
|
+
// Add to MEMORY.md if not already referenced
|
|
348
|
+
if (!homeMemoryMd.includes(file)) {
|
|
349
|
+
const nameMatch = content.match(/^name:\s*(.+)/m);
|
|
350
|
+
const descMatch = content.match(/^description:\s*(.+)/m);
|
|
351
|
+
const name = nameMatch ? nameMatch[1].trim() : file.replace('.md', '').replace(/-/g, ' ');
|
|
352
|
+
const desc = descMatch ? descMatch[1].trim() : '';
|
|
353
|
+
homeMemoryMd += `- [${name}](${file})${desc ? ' — ' + desc : ''}\n`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
promoted++;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (promoted > 0) {
|
|
361
|
+
fs.writeFileSync(homeMemoryMdPath, homeMemoryMd);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return promoted;
|
|
365
|
+
}
|
|
366
|
+
|
|
145
367
|
/**
|
|
146
368
|
* Generate a concise handoff markdown from parsed session
|
|
147
369
|
* This is what gets injected into the AI tool on the other machine
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Memoir MCP Server
|
|
4
|
+
*
|
|
5
|
+
* Exposes memoir's memory management as MCP tools for Claude Code, Cursor, VS Code, etc.
|
|
6
|
+
* Run via: memoir mcp (or directly: node src/mcp.js)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
|
+
import fs from 'fs-extra';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import os from 'os';
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
import { getConfig, listProfiles, getActiveProfileName } from './config.js';
|
|
16
|
+
import { adapters } from './adapters/index.js';
|
|
17
|
+
|
|
18
|
+
const home = os.homedir();
|
|
19
|
+
|
|
20
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Read all memory files from a tool adapter's source directory
|
|
24
|
+
*/
|
|
25
|
+
async function readMemoryFiles(adapter) {
|
|
26
|
+
const files = [];
|
|
27
|
+
|
|
28
|
+
if (adapter.customExtract) {
|
|
29
|
+
for (const file of adapter.files) {
|
|
30
|
+
const filePath = path.join(adapter.source, file);
|
|
31
|
+
if (await fs.pathExists(filePath)) {
|
|
32
|
+
try {
|
|
33
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
34
|
+
files.push({ path: file, content, tool: adapter.name });
|
|
35
|
+
} catch {}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return files;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!(await fs.pathExists(adapter.source))) return files;
|
|
42
|
+
|
|
43
|
+
const walk = async (dir, prefix = '') => {
|
|
44
|
+
let entries;
|
|
45
|
+
try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
46
|
+
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
const fullPath = path.join(dir, entry.name);
|
|
49
|
+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
50
|
+
|
|
51
|
+
if (entry.isDirectory()) {
|
|
52
|
+
if (adapter.filter(fullPath)) {
|
|
53
|
+
await walk(fullPath, relPath);
|
|
54
|
+
}
|
|
55
|
+
} else if (entry.name.endsWith('.md') || entry.name.endsWith('.json') || entry.name.endsWith('.yml') || entry.name.endsWith('.yaml')) {
|
|
56
|
+
if (adapter.filter(fullPath)) {
|
|
57
|
+
try {
|
|
58
|
+
const content = await fs.readFile(fullPath, 'utf8');
|
|
59
|
+
files.push({ path: relPath, content, tool: adapter.name });
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
await walk(adapter.source);
|
|
67
|
+
return files;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Search across all memory files for a query (case-insensitive keyword match)
|
|
72
|
+
*/
|
|
73
|
+
async function searchMemories(query) {
|
|
74
|
+
const results = [];
|
|
75
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
76
|
+
|
|
77
|
+
for (const adapter of adapters) {
|
|
78
|
+
const files = await readMemoryFiles(adapter);
|
|
79
|
+
for (const file of files) {
|
|
80
|
+
const lower = file.content.toLowerCase();
|
|
81
|
+
const score = terms.reduce((s, t) => s + (lower.includes(t) ? 1 : 0), 0);
|
|
82
|
+
if (score > 0) {
|
|
83
|
+
results.push({ ...file, score, relevance: score / terms.length });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Also search per-project AI config files
|
|
89
|
+
const projectFiles = ['CLAUDE.md', 'GEMINI.md', 'CHATGPT.md', '.cursorrules', '.windsurfrules', '.clinerules'];
|
|
90
|
+
const skipDirs = new Set(['node_modules', '.git', '.next', '.vercel', 'dist', 'build', '__pycache__', '.venv', 'venv', '.cache', 'Library', '.Trash', 'Applications', 'Downloads']);
|
|
91
|
+
|
|
92
|
+
const scanProjects = async (dir, depth = 0) => {
|
|
93
|
+
if (depth > 3) return;
|
|
94
|
+
let entries;
|
|
95
|
+
try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
96
|
+
|
|
97
|
+
for (const file of projectFiles) {
|
|
98
|
+
const filePath = path.join(dir, file);
|
|
99
|
+
if (await fs.pathExists(filePath)) {
|
|
100
|
+
try {
|
|
101
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
102
|
+
const lower = content.toLowerCase();
|
|
103
|
+
const score = terms.reduce((s, t) => s + (lower.includes(t) ? 1 : 0), 0);
|
|
104
|
+
if (score > 0) {
|
|
105
|
+
results.push({
|
|
106
|
+
path: `${path.basename(dir)}/${file}`,
|
|
107
|
+
content,
|
|
108
|
+
tool: `Project: ${path.basename(dir)}`,
|
|
109
|
+
score,
|
|
110
|
+
relevance: score / terms.length
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
} catch {}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
if (!entry.isDirectory()) continue;
|
|
119
|
+
if (entry.name.startsWith('.') && entry.name !== '.github') continue;
|
|
120
|
+
if (skipDirs.has(entry.name)) continue;
|
|
121
|
+
await scanProjects(path.join(dir, entry.name), depth + 1);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
await scanProjects(home);
|
|
126
|
+
|
|
127
|
+
return results.sort((a, b) => b.score - a.score);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Get list of detected tools with status
|
|
132
|
+
*/
|
|
133
|
+
async function getDetectedTools() {
|
|
134
|
+
const detected = [];
|
|
135
|
+
for (const adapter of adapters) {
|
|
136
|
+
let found = false;
|
|
137
|
+
if (adapter.customExtract) {
|
|
138
|
+
for (const file of adapter.files) {
|
|
139
|
+
if (await fs.pathExists(path.join(adapter.source, file))) { found = true; break; }
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
found = await fs.pathExists(adapter.source);
|
|
143
|
+
}
|
|
144
|
+
detected.push({ name: adapter.name, icon: adapter.icon, installed: found });
|
|
145
|
+
}
|
|
146
|
+
return detected;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── MCP Server ───────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
const server = new McpServer({
|
|
152
|
+
name: 'memoir',
|
|
153
|
+
version: '3.2.0',
|
|
154
|
+
}, {
|
|
155
|
+
capabilities: {
|
|
156
|
+
tools: {},
|
|
157
|
+
resources: {},
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ── Tools ────────────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
server.tool(
|
|
164
|
+
'memoir_status',
|
|
165
|
+
'Show which AI tools are detected on this machine and memoir configuration status',
|
|
166
|
+
{},
|
|
167
|
+
async () => {
|
|
168
|
+
const config = await getConfig();
|
|
169
|
+
const tools = await getDetectedTools();
|
|
170
|
+
const profile = await getActiveProfileName();
|
|
171
|
+
const profiles = await listProfiles();
|
|
172
|
+
|
|
173
|
+
const installed = tools.filter(t => t.installed);
|
|
174
|
+
const toolList = installed.map(t => ` ${t.icon} ${t.name}`).join('\n');
|
|
175
|
+
const notInstalled = tools.filter(t => !t.installed).map(t => t.name).join(', ');
|
|
176
|
+
|
|
177
|
+
let configStatus;
|
|
178
|
+
if (config) {
|
|
179
|
+
const dest = config.provider === 'git' ? config.gitRepo : config.localPath;
|
|
180
|
+
configStatus = `Connected → ${dest}`;
|
|
181
|
+
} else {
|
|
182
|
+
configStatus = 'Not configured (run: memoir init)';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
content: [{
|
|
187
|
+
type: 'text',
|
|
188
|
+
text: [
|
|
189
|
+
`Memoir Status`,
|
|
190
|
+
`─────────────`,
|
|
191
|
+
`Config: ${configStatus}`,
|
|
192
|
+
`Profile: ${profile} (${profiles.length} total)`,
|
|
193
|
+
`Encryption: ${config?.encrypt ? 'enabled' : 'disabled'}`,
|
|
194
|
+
``,
|
|
195
|
+
`Detected AI Tools (${installed.length}):`,
|
|
196
|
+
toolList,
|
|
197
|
+
``,
|
|
198
|
+
notInstalled.length > 0 ? `Also supports: ${notInstalled}` : '',
|
|
199
|
+
].filter(Boolean).join('\n')
|
|
200
|
+
}]
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
server.tool(
|
|
206
|
+
'memoir_recall',
|
|
207
|
+
'Search across all AI tool memories, project configs, and session context for relevant information. Use this to find what you know about a topic, project, or tool.',
|
|
208
|
+
{ query: z.string().describe('Search query — keywords or topic to find in memories') },
|
|
209
|
+
async ({ query }) => {
|
|
210
|
+
const results = await searchMemories(query);
|
|
211
|
+
|
|
212
|
+
if (results.length === 0) {
|
|
213
|
+
return {
|
|
214
|
+
content: [{ type: 'text', text: `No memories found matching "${query}".` }]
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Return top 10 results with content
|
|
219
|
+
const top = results.slice(0, 10);
|
|
220
|
+
const output = top.map((r, i) => {
|
|
221
|
+
const preview = r.content.length > 500 ? r.content.slice(0, 500) + '...' : r.content;
|
|
222
|
+
return [
|
|
223
|
+
`── ${i + 1}. ${r.tool} / ${r.path} (relevance: ${Math.round(r.relevance * 100)}%) ──`,
|
|
224
|
+
preview,
|
|
225
|
+
].join('\n');
|
|
226
|
+
}).join('\n\n');
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
content: [{
|
|
230
|
+
type: 'text',
|
|
231
|
+
text: `Found ${results.length} memories matching "${query}":\n\n${output}`
|
|
232
|
+
}]
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
server.tool(
|
|
238
|
+
'memoir_remember',
|
|
239
|
+
'Save a memory to a specific AI tool\'s memory files. Use this to persist important context, decisions, or facts for future sessions.',
|
|
240
|
+
{
|
|
241
|
+
content: z.string().describe('The memory content to save (markdown format)'),
|
|
242
|
+
filename: z.string().describe('Filename for the memory (e.g. "auth-setup.md", "project-goals.md")'),
|
|
243
|
+
tool: z.string().optional().describe('Which AI tool to save to: "claude", "gemini", "cursor", etc. Defaults to claude.'),
|
|
244
|
+
project: z.string().optional().describe('Project directory path to save a project-level memory (e.g. CLAUDE.md). If provided, saves to that project directory instead of global tool config.'),
|
|
245
|
+
},
|
|
246
|
+
async ({ content, filename, tool, project }) => {
|
|
247
|
+
// Project-level memory
|
|
248
|
+
if (project) {
|
|
249
|
+
const projectDir = project.startsWith('/') ? project : path.join(home, project);
|
|
250
|
+
if (!(await fs.pathExists(projectDir))) {
|
|
251
|
+
return { content: [{ type: 'text', text: `Project directory not found: ${projectDir}` }] };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Default to CLAUDE.md for project-level memories
|
|
255
|
+
const targetFile = filename || 'CLAUDE.md';
|
|
256
|
+
const targetPath = path.join(projectDir, targetFile);
|
|
257
|
+
|
|
258
|
+
// Append to existing file or create new
|
|
259
|
+
if (await fs.pathExists(targetPath)) {
|
|
260
|
+
const existing = await fs.readFile(targetPath, 'utf8');
|
|
261
|
+
await fs.writeFile(targetPath, existing + '\n\n' + content);
|
|
262
|
+
} else {
|
|
263
|
+
await fs.writeFile(targetPath, content);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
content: [{ type: 'text', text: `Saved to ${targetPath}` }]
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Global tool memory
|
|
272
|
+
const toolKey = (tool || 'claude').toLowerCase();
|
|
273
|
+
|
|
274
|
+
// Find the right directory for the tool
|
|
275
|
+
let targetDir;
|
|
276
|
+
if (toolKey === 'claude') {
|
|
277
|
+
// Save to Claude's memory system
|
|
278
|
+
const claudeMemDir = path.join(home, '.claude', 'projects', '-Users-' + path.basename(home), 'memory');
|
|
279
|
+
await fs.ensureDir(claudeMemDir);
|
|
280
|
+
targetDir = claudeMemDir;
|
|
281
|
+
} else if (toolKey === 'gemini') {
|
|
282
|
+
targetDir = path.join(home, '.gemini');
|
|
283
|
+
} else if (toolKey === 'cursor') {
|
|
284
|
+
const cursorDir = process.platform === 'win32'
|
|
285
|
+
? path.join(process.env.APPDATA || '', 'Cursor', 'User', 'rules')
|
|
286
|
+
: path.join(home, 'Library', 'Application Support', 'Cursor', 'User', 'rules');
|
|
287
|
+
await fs.ensureDir(cursorDir);
|
|
288
|
+
targetDir = cursorDir;
|
|
289
|
+
} else {
|
|
290
|
+
return { content: [{ type: 'text', text: `Unsupported tool for writing: ${toolKey}. Supported: claude, gemini, cursor` }] };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!filename.endsWith('.md')) filename += '.md';
|
|
294
|
+
const targetPath = path.join(targetDir, filename);
|
|
295
|
+
await fs.writeFile(targetPath, content);
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
content: [{ type: 'text', text: `Memory saved to ${targetPath}` }]
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
server.tool(
|
|
304
|
+
'memoir_list',
|
|
305
|
+
'List all memory files across all detected AI tools and projects',
|
|
306
|
+
{
|
|
307
|
+
tool: z.string().optional().describe('Filter to a specific tool: "claude", "gemini", "cursor", etc. Leave empty for all.'),
|
|
308
|
+
},
|
|
309
|
+
async ({ tool }) => {
|
|
310
|
+
const allFiles = [];
|
|
311
|
+
|
|
312
|
+
for (const adapter of adapters) {
|
|
313
|
+
if (tool) {
|
|
314
|
+
const key = tool.toLowerCase();
|
|
315
|
+
if (!adapter.name.toLowerCase().includes(key)) continue;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const files = await readMemoryFiles(adapter);
|
|
319
|
+
for (const f of files) {
|
|
320
|
+
allFiles.push({ tool: adapter.name, icon: adapter.icon, path: f.path, size: f.content.length });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (allFiles.length === 0) {
|
|
325
|
+
return { content: [{ type: 'text', text: tool ? `No memory files found for ${tool}.` : 'No memory files found.' }] };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Group by tool
|
|
329
|
+
const grouped = {};
|
|
330
|
+
for (const f of allFiles) {
|
|
331
|
+
if (!grouped[f.tool]) grouped[f.tool] = [];
|
|
332
|
+
grouped[f.tool].push(f);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const output = Object.entries(grouped).map(([toolName, files]) => {
|
|
336
|
+
const icon = files[0]?.icon || '';
|
|
337
|
+
const fileList = files.map(f => {
|
|
338
|
+
const sizeStr = f.size < 1024 ? `${f.size}B` : `${(f.size / 1024).toFixed(1)}KB`;
|
|
339
|
+
return ` ${f.path} (${sizeStr})`;
|
|
340
|
+
}).join('\n');
|
|
341
|
+
return `${icon} ${toolName} (${files.length} files)\n${fileList}`;
|
|
342
|
+
}).join('\n\n');
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
content: [{ type: 'text', text: `Memory files (${allFiles.length} total):\n\n${output}` }]
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
server.tool(
|
|
351
|
+
'memoir_read',
|
|
352
|
+
'Read the full content of a specific memory file',
|
|
353
|
+
{
|
|
354
|
+
tool: z.string().describe('Tool name: "claude", "gemini", "cursor", etc.'),
|
|
355
|
+
filepath: z.string().describe('Relative file path within the tool\'s memory directory'),
|
|
356
|
+
},
|
|
357
|
+
async ({ tool, filepath }) => {
|
|
358
|
+
const toolKey = tool.toLowerCase();
|
|
359
|
+
const adapter = adapters.find(a => a.name.toLowerCase().includes(toolKey));
|
|
360
|
+
|
|
361
|
+
if (!adapter) {
|
|
362
|
+
return { content: [{ type: 'text', text: `Unknown tool: ${tool}. Available: ${adapters.map(a => a.name).join(', ')}` }] };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const fullPath = path.join(adapter.source, filepath);
|
|
366
|
+
|
|
367
|
+
if (!(await fs.pathExists(fullPath))) {
|
|
368
|
+
return { content: [{ type: 'text', text: `File not found: ${filepath} in ${adapter.name}` }] };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
try {
|
|
372
|
+
const content = await fs.readFile(fullPath, 'utf8');
|
|
373
|
+
return {
|
|
374
|
+
content: [{ type: 'text', text: `── ${adapter.name} / ${filepath} ──\n\n${content}` }]
|
|
375
|
+
};
|
|
376
|
+
} catch (err) {
|
|
377
|
+
return { content: [{ type: 'text', text: `Error reading file: ${err.message}` }] };
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
server.tool(
|
|
383
|
+
'memoir_profiles',
|
|
384
|
+
'List and manage memoir profiles (personal, work, etc.)',
|
|
385
|
+
{},
|
|
386
|
+
async () => {
|
|
387
|
+
const profiles = await listProfiles();
|
|
388
|
+
const active = await getActiveProfileName();
|
|
389
|
+
|
|
390
|
+
if (profiles.length === 0) {
|
|
391
|
+
return { content: [{ type: 'text', text: 'No profiles configured. Run: memoir init' }] };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const list = profiles.map(p => ` ${p === active ? '● ' : ' '}${p}${p === active ? ' (active)' : ''}`).join('\n');
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
content: [{
|
|
398
|
+
type: 'text',
|
|
399
|
+
text: `Memoir Profiles:\n\n${list}\n\nSwitch with: memoir profile switch <name>`
|
|
400
|
+
}]
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
// ── Resources ────────────────────────────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
// Expose detected tools as browsable resources
|
|
408
|
+
server.resource(
|
|
409
|
+
'detected-tools',
|
|
410
|
+
'memoir://tools',
|
|
411
|
+
{ description: 'List of AI tools detected on this machine', mimeType: 'text/plain' },
|
|
412
|
+
async () => {
|
|
413
|
+
const tools = await getDetectedTools();
|
|
414
|
+
const text = tools.map(t => `${t.icon} ${t.name}: ${t.installed ? 'installed' : 'not found'}`).join('\n');
|
|
415
|
+
return { contents: [{ uri: 'memoir://tools', text, mimeType: 'text/plain' }] };
|
|
416
|
+
}
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
// ── Start ────────────────────────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
async function main() {
|
|
422
|
+
const transport = new StdioServerTransport();
|
|
423
|
+
await server.connect(transport);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
main().catch((err) => {
|
|
427
|
+
process.stderr.write(`Memoir MCP server error: ${err.message}\n`);
|
|
428
|
+
process.exit(1);
|
|
429
|
+
});
|