memoir-cli 3.5.0 → 3.6.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # memoir
4
4
 
5
- **Portable memory for every AI coding tool.**
5
+ **Sync AI memory across every coding tool. Zero config.**
6
6
 
7
7
  [![npm version](https://img.shields.io/npm/v/memoir-cli.svg?style=flat-square&color=7c6ef0)](https://npmjs.org/package/memoir-cli)
8
8
  [![npm downloads](https://img.shields.io/npm/dm/memoir-cli.svg?style=flat-square&color=7c6ef0)](https://npmjs.org/package/memoir-cli)
@@ -12,17 +12,16 @@
12
12
  </div>
13
13
 
14
14
  ```bash
15
- npm install -g memoir-cli
16
- memoir activate
15
+ npx memoir-cli
17
16
  ```
18
17
 
19
- Your AI now remembers across sessions, tools, and machines. Works with Claude Code, Cursor, Windsurf, Gemini, Copilot, and 6 more tools.
18
+ One command. No install, no config, no API keys. Your AI now has persistent memory across sessions, tools, and machines. Works with Claude Code, Cursor, Windsurf, Gemini CLI, GitHub Copilot, and 8 more tools.
20
19
 
21
20
  ---
22
21
 
23
22
  ## What it does
24
23
 
25
- memoir is an [MCP server](https://modelcontextprotocol.io) that gives your AI tools persistent memory. Your AI can search, save, and recall context automatically.
24
+ memoir is an [MCP memory server](https://modelcontextprotocol.io) that gives your AI tools persistent memory. Your AI can search, save, and recall context automatically — like a Claude Code backup that works everywhere.
26
25
 
27
26
  ```
28
27
  you: how does auth work in this project?
@@ -37,41 +36,15 @@ claude: Based on your previous sessions: this project uses JWT auth
37
36
 
38
37
  No re-explaining. memoir remembered.
39
38
 
40
- ## Setup
41
-
42
- ### 1. Install
39
+ ## Quick start
43
40
 
44
41
  ```bash
45
- npm install -g memoir-cli
46
- ```
47
-
48
- ### 2. Add MCP to your AI tool
49
-
50
- **Claude Code** — add to `~/.mcp.json`:
51
- ```json
52
- {
53
- "mcpServers": {
54
- "memoir": { "command": "memoir-mcp" }
55
- }
56
- }
42
+ npx memoir-cli
57
43
  ```
58
44
 
59
- **Cursor** add to `.cursor/mcp.json`:
60
- ```json
61
- {
62
- "mcpServers": {
63
- "memoir": { "command": "memoir-mcp" }
64
- }
65
- }
66
- ```
67
-
68
- ### 3. Activate in your project
45
+ That's it. memoir detects your AI tools, configures MCP, and activates memory. No global install needed.
69
46
 
70
- ```bash
71
- memoir activate
72
- ```
73
-
74
- That's it. Your AI now has 6 memory tools:
47
+ Your AI gets 7 memory tools:
75
48
 
76
49
  | MCP Tool | What it does |
77
50
  |----------|-------------|
@@ -79,6 +52,7 @@ That's it. Your AI now has 6 memory tools:
79
52
  | `memoir_remember` | Save context for future sessions |
80
53
  | `memoir_list` | Browse all memory files by tool |
81
54
  | `memoir_read` | Read a specific memory in full |
55
+ | `memoir_consolidate` | Analyze memories for duplicates, staleness, and bloat |
82
56
  | `memoir_status` | See which AI tools are detected |
83
57
  | `memoir_profiles` | Switch between work/personal |
84
58
 
@@ -86,21 +60,18 @@ That's it. Your AI now has 6 memory tools:
86
60
 
87
61
  Your AI forgets everything between sessions. You re-explain your codebase, your conventions, your decisions — every time.
88
62
 
89
- memoir fixes this by giving your AI a shared memory layer that works across **every tool you use**. Tell Claude something once. Cursor knows it too.
63
+ memoir fixes this by giving your AI a shared memory layer that works across **every tool you use**. Tell Claude something once. Cursor knows it too. Sync AI memory between tools, back it up to the cloud, restore it on any machine. And when your memories pile up, `memoir consolidate` cleans house — finds duplicates, flags stale context, and optionally uses AI to merge and prune.
90
64
 
91
- **11 tools supported:** Claude Code, Cursor, Windsurf, Gemini CLI, GitHub Copilot, OpenAI Codex, ChatGPT, Aider, Zed, Cline, Continue.dev
65
+ **13 tools supported:** Claude Code, Cursor, Windsurf, Gemini CLI, GitHub Copilot, OpenAI Codex, ChatGPT, Aider, Zed, Cline, Continue.dev, Augment, Trae.
92
66
 
93
67
  ## Sync across machines
94
68
 
95
69
  ```bash
96
- # Back up
97
- memoir push
98
-
99
- # Restore on any machine
100
- memoir restore -y
70
+ memoir push # back up AI memory + workspace + session
71
+ memoir restore -y # restore on any machine
101
72
  ```
102
73
 
103
- Push syncs AI memory, session context, workspace (git repos + uncommitted work), and project configs. E2E encrypted with AES-256-GCM.
74
+ Push syncs AI memory, cursorrules, session context, workspace (git repos + uncommitted work), and project configs. E2E encrypted with AES-256-GCM.
104
75
 
105
76
  ## Translate between AI tools
106
77
 
@@ -112,6 +83,16 @@ memoir migrate --from chatgpt --to all
112
83
  # Translate to every tool at once
113
84
  ```
114
85
 
86
+ ## Consolidate memories
87
+
88
+ ```bash
89
+ memoir consolidate # scan for duplicates, stale files, bloat
90
+ memoir consolidate --smart # AI-powered analysis (finds contradictions + merge candidates)
91
+ memoir consolidate --apply # interactively clean up
92
+ ```
93
+
94
+ Over time, memories pile up across tools. Consolidate finds exact and near-duplicates, flags files untouched for 60+ days, and catches contradictions where you told Claude one thing and Cursor another. With `--smart`, Gemini Flash does a semantic pass and suggests intelligent merges.
95
+
115
96
  ## Cloud sync
116
97
 
117
98
  ```bash
@@ -139,6 +120,7 @@ memoir share # create encrypted shareable link
139
120
  | `memoir cloud push` | Back up to memoir cloud |
140
121
  | `memoir cloud restore` | Restore from memoir cloud |
141
122
  | `memoir share` | Create encrypted shareable link |
123
+ | `memoir consolidate` | Find duplicates, stale memories, and bloat |
142
124
  | `memoir doctor` | Diagnose issues |
143
125
  | `memoir diff` | Show changes since last backup |
144
126
  | `memoir view` | Preview what's in your backup |
package/bin/memoir.js CHANGED
@@ -21,6 +21,7 @@ import { historyCommand } from '../src/commands/history.js';
21
21
  import { projectsListCommand, projectsTodoCommand } from '../src/commands/projects.js';
22
22
  import { upgradeCommand } from '../src/commands/upgrade.js';
23
23
  import { activateCommand, deactivateCommand } from '../src/commands/activate.js';
24
+ import { consolidateCommand } from '../src/commands/consolidate.js';
24
25
  import { createRequire } from 'module';
25
26
 
26
27
  const require = createRequire(import.meta.url);
@@ -554,6 +555,21 @@ projects
554
555
  }
555
556
  });
556
557
 
558
+ program
559
+ .command('consolidate')
560
+ .alias('tidy')
561
+ .description('Analyze and clean up your AI memories — find duplicates, stale files, and contradictions')
562
+ .option('--smart', 'Use AI to analyze memories and suggest merges (requires Gemini API key)')
563
+ .option('--apply', 'Interactively apply suggested changes (delete, merge, prune)')
564
+ .action(async (options) => {
565
+ try {
566
+ await consolidateCommand(options);
567
+ } catch (err) {
568
+ console.error(chalk.red('\n✖ Error during consolidation:'), err.message);
569
+ process.exit(1);
570
+ }
571
+ });
572
+
557
573
  program
558
574
  .command('mcp')
559
575
  .description('Start the MCP server (for Claude Code, Cursor, VS Code integration)')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memoir-cli",
3
- "version": "3.5.0",
3
+ "version": "3.6.0",
4
4
  "mcpName": "io.github.camgitt/memoir",
5
5
  "description": "MCP server that gives Claude, Cursor, and Gemini long-term memory across sessions. Your AI remembers your codebase, decisions, and preferences — across tools and machines.",
6
6
  "main": "src/index.js",
@@ -0,0 +1,477 @@
1
+ import chalk from 'chalk';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import ora from 'ora';
6
+ import boxen from 'boxen';
7
+ import gradient from 'gradient-string';
8
+ import inquirer from 'inquirer';
9
+ import { getConfig, getGeminiApiKey } from '../config.js';
10
+ import { adapters } from '../adapters/index.js';
11
+
12
+ const home = os.homedir();
13
+
14
+ // ── Helpers ──────────────────────────────────────────────────────────────────
15
+
16
+ async function readMemoryFiles(adapter) {
17
+ const files = [];
18
+
19
+ if (adapter.customExtract) {
20
+ for (const file of adapter.files) {
21
+ const filePath = path.join(adapter.source, file);
22
+ if (await fs.pathExists(filePath)) {
23
+ try {
24
+ const content = await fs.readFile(filePath, 'utf8');
25
+ const stat = await fs.stat(filePath);
26
+ files.push({ path: file, fullPath: filePath, content, tool: adapter.name, icon: adapter.icon, mtime: stat.mtimeMs, size: content.length });
27
+ } catch {}
28
+ }
29
+ }
30
+ return files;
31
+ }
32
+
33
+ if (!(await fs.pathExists(adapter.source))) return files;
34
+
35
+ const walk = async (dir, prefix = '') => {
36
+ let entries;
37
+ try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
38
+
39
+ for (const entry of entries) {
40
+ const fullPath = path.join(dir, entry.name);
41
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
42
+
43
+ if (entry.isDirectory()) {
44
+ if (adapter.filter(fullPath)) {
45
+ await walk(fullPath, relPath);
46
+ }
47
+ } else if (/\.(md|json|yml|yaml)$/.test(entry.name)) {
48
+ if (adapter.filter(fullPath)) {
49
+ try {
50
+ const content = await fs.readFile(fullPath, 'utf8');
51
+ const stat = await fs.stat(fullPath);
52
+ files.push({ path: relPath, fullPath, content, tool: adapter.name, icon: adapter.icon, mtime: stat.mtimeMs, size: content.length });
53
+ } catch {}
54
+ }
55
+ }
56
+ }
57
+ };
58
+
59
+ await walk(adapter.source);
60
+ return files;
61
+ }
62
+
63
+ function daysAgo(mtimeMs) {
64
+ return Math.floor((Date.now() - mtimeMs) / (1000 * 60 * 60 * 24));
65
+ }
66
+
67
+ function contentFingerprint(content) {
68
+ // Normalize whitespace and case for comparison
69
+ return content.toLowerCase().replace(/\s+/g, ' ').trim();
70
+ }
71
+
72
+ function similarity(a, b) {
73
+ // Jaccard similarity on word sets
74
+ const wordsA = new Set(a.toLowerCase().split(/\s+/).filter(w => w.length > 3));
75
+ const wordsB = new Set(b.toLowerCase().split(/\s+/).filter(w => w.length > 3));
76
+ if (wordsA.size === 0 || wordsB.size === 0) return 0;
77
+ let intersection = 0;
78
+ for (const w of wordsA) {
79
+ if (wordsB.has(w)) intersection++;
80
+ }
81
+ return intersection / (wordsA.size + wordsB.size - intersection);
82
+ }
83
+
84
+ // ── Analysis ─────────────────────────────────────────────────────────────────
85
+
86
+ function analyzeMemories(allFiles) {
87
+ const issues = { duplicates: [], stale: [], bloated: [], contradictions: [], empty: [] };
88
+
89
+ // 1. Find exact duplicates (same content across tools)
90
+ const fingerprints = new Map();
91
+ for (const file of allFiles) {
92
+ const fp = contentFingerprint(file.content);
93
+ if (fp.length < 10) {
94
+ issues.empty.push(file);
95
+ continue;
96
+ }
97
+ if (!fingerprints.has(fp)) {
98
+ fingerprints.set(fp, []);
99
+ }
100
+ fingerprints.get(fp).push(file);
101
+ }
102
+ for (const [, group] of fingerprints) {
103
+ if (group.length > 1) {
104
+ issues.duplicates.push(group);
105
+ }
106
+ }
107
+
108
+ // 2. Find near-duplicates (>70% word overlap)
109
+ const nonDuplicateFiles = allFiles.filter(f => contentFingerprint(f.content).length >= 10);
110
+ const alreadyDuplicate = new Set(issues.duplicates.flat().map(f => f.fullPath));
111
+
112
+ for (let i = 0; i < nonDuplicateFiles.length; i++) {
113
+ for (let j = i + 1; j < nonDuplicateFiles.length; j++) {
114
+ const a = nonDuplicateFiles[i];
115
+ const b = nonDuplicateFiles[j];
116
+ if (alreadyDuplicate.has(a.fullPath) && alreadyDuplicate.has(b.fullPath)) continue;
117
+ const sim = similarity(a.content, b.content);
118
+ if (sim > 0.7) {
119
+ issues.duplicates.push([a, b]);
120
+ alreadyDuplicate.add(a.fullPath);
121
+ alreadyDuplicate.add(b.fullPath);
122
+ }
123
+ }
124
+ }
125
+
126
+ // 3. Find stale memories (not modified in 60+ days)
127
+ for (const file of allFiles) {
128
+ const age = daysAgo(file.mtime);
129
+ if (age > 60) {
130
+ issues.stale.push({ ...file, age });
131
+ }
132
+ }
133
+
134
+ // 4. Find bloated files (>10KB)
135
+ for (const file of allFiles) {
136
+ if (file.size > 10240) {
137
+ issues.bloated.push(file);
138
+ }
139
+ }
140
+
141
+ return issues;
142
+ }
143
+
144
+ // ── LLM Consolidation ────────────────────────────────────────────────────────
145
+
146
+ async function llmConsolidate(allFiles, apiKey) {
147
+ // Build a summary of all memories for the LLM
148
+ const memoryDigest = allFiles
149
+ .filter(f => f.content.trim().length > 10)
150
+ .map(f => `[${f.tool} / ${f.path}] (${daysAgo(f.mtime)}d old, ${f.size}B)\n${f.content.slice(0, 500)}${f.content.length > 500 ? '...' : ''}`)
151
+ .join('\n\n---\n\n');
152
+
153
+ const prompt = `You are a memory consolidation engine. Analyze these AI tool memory files and produce a consolidation report.
154
+
155
+ MEMORIES:
156
+ ${memoryDigest}
157
+
158
+ Produce a JSON response with these fields:
159
+ {
160
+ "merge_groups": [
161
+ { "files": ["tool/path1", "tool/path2"], "reason": "why these should be merged", "merged_content": "the consolidated content" }
162
+ ],
163
+ "prune": [
164
+ { "file": "tool/path", "reason": "why this should be removed" }
165
+ ],
166
+ "contradictions": [
167
+ { "files": ["tool/path1", "tool/path2"], "description": "what contradicts" }
168
+ ],
169
+ "summary": "1-2 sentence summary of the consolidation"
170
+ }
171
+
172
+ Rules:
173
+ - Only suggest merging files that have significant content overlap or cover the same topic
174
+ - Only suggest pruning files that are clearly outdated, empty, or superseded
175
+ - Flag contradictions where two files give conflicting instructions about the same thing
176
+ - Be conservative — when in doubt, keep the memory
177
+ - Return valid JSON only, no markdown fences`;
178
+
179
+ const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`, {
180
+ method: 'POST',
181
+ headers: { 'Content-Type': 'application/json' },
182
+ body: JSON.stringify({
183
+ contents: [{ parts: [{ text: prompt }] }],
184
+ generationConfig: { maxOutputTokens: 4000, temperature: 0.2, responseMimeType: 'application/json' }
185
+ })
186
+ });
187
+
188
+ if (!response.ok) {
189
+ throw new Error(`Gemini API error: ${response.status}`);
190
+ }
191
+
192
+ const data = await response.json();
193
+ const text = data.candidates?.[0]?.content?.parts?.[0]?.text;
194
+ if (!text) throw new Error('Empty response from Gemini');
195
+
196
+ return JSON.parse(text);
197
+ }
198
+
199
+ // ── Display ──────────────────────────────────────────────────────────────────
200
+
201
+ function formatSize(bytes) {
202
+ if (bytes < 1024) return `${bytes}B`;
203
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
204
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
205
+ }
206
+
207
+ function printIssues(issues, allFiles) {
208
+ const totalIssues = issues.duplicates.length + issues.stale.length + issues.bloated.length + issues.empty.length;
209
+
210
+ if (totalIssues === 0) {
211
+ console.log('\n' + boxen(
212
+ chalk.green.bold('Your memories look clean!') + '\n\n' +
213
+ chalk.gray(`Scanned ${allFiles.length} files across all tools. No issues found.`),
214
+ { padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
215
+ ) + '\n');
216
+ return false;
217
+ }
218
+
219
+ console.log('\n' + boxen(
220
+ gradient.pastel(' Consolidation Report ') + '\n\n' +
221
+ chalk.white(`Scanned ${chalk.cyan(allFiles.length)} memory files`) + chalk.gray(` | ${totalIssues} issues found`),
222
+ { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderStyle: 'round', borderColor: 'cyan', dimBorder: true }
223
+ ));
224
+
225
+ if (issues.duplicates.length > 0) {
226
+ console.log('\n' + chalk.yellow.bold(` Duplicates (${issues.duplicates.length})`));
227
+ for (const group of issues.duplicates) {
228
+ const sim = group.length === 2 ? ` ${Math.round(similarity(group[0].content, group[1].content) * 100)}% similar` : ' exact match';
229
+ console.log(chalk.gray(` ┌${sim}`));
230
+ for (const f of group) {
231
+ console.log(` │ ${f.icon} ${chalk.cyan(f.tool)} ${chalk.white(f.path)} ${chalk.gray(formatSize(f.size))}`);
232
+ }
233
+ console.log(chalk.gray(' └'));
234
+ }
235
+ }
236
+
237
+ if (issues.stale.length > 0) {
238
+ console.log('\n' + chalk.yellow.bold(` Stale (${issues.stale.length}) — not modified in 60+ days`));
239
+ for (const f of issues.stale.sort((a, b) => b.age - a.age).slice(0, 15)) {
240
+ console.log(` ${f.icon} ${chalk.cyan(f.tool)} ${chalk.white(f.path)} ${chalk.gray(`${f.age}d ago`)}`);
241
+ }
242
+ if (issues.stale.length > 15) {
243
+ console.log(chalk.gray(` ...and ${issues.stale.length - 15} more`));
244
+ }
245
+ }
246
+
247
+ if (issues.bloated.length > 0) {
248
+ console.log('\n' + chalk.yellow.bold(` Bloated (${issues.bloated.length}) — over 10KB`));
249
+ for (const f of issues.bloated.sort((a, b) => b.size - a.size)) {
250
+ console.log(` ${f.icon} ${chalk.cyan(f.tool)} ${chalk.white(f.path)} ${chalk.red(formatSize(f.size))}`);
251
+ }
252
+ }
253
+
254
+ if (issues.empty.length > 0) {
255
+ console.log('\n' + chalk.yellow.bold(` Empty / near-empty (${issues.empty.length})`));
256
+ for (const f of issues.empty) {
257
+ console.log(` ${f.icon} ${chalk.cyan(f.tool)} ${chalk.white(f.path)}`);
258
+ }
259
+ }
260
+
261
+ console.log('');
262
+ return true;
263
+ }
264
+
265
+ function printLlmReport(report) {
266
+ console.log('\n' + boxen(
267
+ gradient.pastel(' AI Consolidation '),
268
+ { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderStyle: 'round', borderColor: 'magenta', dimBorder: true }
269
+ ));
270
+
271
+ if (report.summary) {
272
+ console.log('\n ' + chalk.white(report.summary));
273
+ }
274
+
275
+ if (report.merge_groups?.length > 0) {
276
+ console.log('\n' + chalk.magenta.bold(` Merge suggestions (${report.merge_groups.length})`));
277
+ for (const group of report.merge_groups) {
278
+ console.log(chalk.gray(` ┌ ${group.reason}`));
279
+ for (const file of group.files) {
280
+ console.log(` │ ${chalk.cyan(file)}`);
281
+ }
282
+ console.log(chalk.gray(' └'));
283
+ }
284
+ }
285
+
286
+ if (report.contradictions?.length > 0) {
287
+ console.log('\n' + chalk.red.bold(` Contradictions (${report.contradictions.length})`));
288
+ for (const c of report.contradictions) {
289
+ console.log(chalk.gray(` ┌ ${c.description}`));
290
+ for (const file of c.files) {
291
+ console.log(` │ ${chalk.cyan(file)}`);
292
+ }
293
+ console.log(chalk.gray(' └'));
294
+ }
295
+ }
296
+
297
+ if (report.prune?.length > 0) {
298
+ console.log('\n' + chalk.yellow.bold(` Prune suggestions (${report.prune.length})`));
299
+ for (const p of report.prune) {
300
+ console.log(` ${chalk.cyan(p.file)} ${chalk.gray('— ' + p.reason)}`);
301
+ }
302
+ }
303
+
304
+ console.log('');
305
+ }
306
+
307
+ // ── Actions ──────────────────────────────────────────────────────────────────
308
+
309
+ async function applyPrune(files, allFiles) {
310
+ const choices = files.map(f => ({
311
+ name: `${f.icon || ''} ${f.tool || ''} ${f.path} ${chalk.gray(`(${f.age ? f.age + 'd old' : formatSize(f.size)})`)}`,
312
+ value: f,
313
+ checked: false
314
+ }));
315
+
316
+ const { toDelete } = await inquirer.prompt([{
317
+ type: 'checkbox',
318
+ name: 'toDelete',
319
+ message: 'Select memories to delete:',
320
+ choices
321
+ }]);
322
+
323
+ if (toDelete.length === 0) {
324
+ console.log(chalk.gray(' Nothing selected.\n'));
325
+ return 0;
326
+ }
327
+
328
+ const { confirm } = await inquirer.prompt([{
329
+ type: 'confirm',
330
+ name: 'confirm',
331
+ message: `Delete ${toDelete.length} file(s)? This cannot be undone.`,
332
+ default: false
333
+ }]);
334
+
335
+ if (!confirm) {
336
+ console.log(chalk.gray(' Cancelled.\n'));
337
+ return 0;
338
+ }
339
+
340
+ let deleted = 0;
341
+ for (const file of toDelete) {
342
+ try {
343
+ await fs.remove(file.fullPath);
344
+ console.log(chalk.red(` ✖ Deleted: ${file.tool}/${file.path}`));
345
+ deleted++;
346
+ } catch (err) {
347
+ console.log(chalk.red(` ✖ Failed to delete ${file.path}: ${err.message}`));
348
+ }
349
+ }
350
+
351
+ return deleted;
352
+ }
353
+
354
+ async function applyMerge(duplicateGroups, allFiles) {
355
+ let merged = 0;
356
+
357
+ for (const group of duplicateGroups) {
358
+ console.log(chalk.gray('\n ┌ Duplicate group:'));
359
+ for (const f of group) {
360
+ console.log(` │ ${f.icon} ${chalk.cyan(f.tool)}/${chalk.white(f.path)} ${chalk.gray(`(${daysAgo(f.mtime)}d old)`)}`);
361
+ }
362
+ console.log(chalk.gray(' └'));
363
+
364
+ // Keep the newest file, offer to delete the rest
365
+ const sorted = [...group].sort((a, b) => b.mtime - a.mtime);
366
+ const keep = sorted[0];
367
+ const remove = sorted.slice(1);
368
+
369
+ console.log(chalk.green(` Keep: ${keep.tool}/${keep.path} (newest)`));
370
+ for (const r of remove) {
371
+ console.log(chalk.red(` Remove: ${r.tool}/${r.path}`));
372
+ }
373
+
374
+ const { confirm } = await inquirer.prompt([{
375
+ type: 'confirm',
376
+ name: 'confirm',
377
+ message: `Remove ${remove.length} duplicate(s), keep the newest?`,
378
+ default: true
379
+ }]);
380
+
381
+ if (confirm) {
382
+ for (const r of remove) {
383
+ try {
384
+ await fs.remove(r.fullPath);
385
+ console.log(chalk.red(` ✖ Removed: ${r.tool}/${r.path}`));
386
+ merged++;
387
+ } catch (err) {
388
+ console.log(chalk.red(` ✖ Failed: ${err.message}`));
389
+ }
390
+ }
391
+ }
392
+ }
393
+
394
+ return merged;
395
+ }
396
+
397
+ // ── Main Command ─────────────────────────────────────────────────────────────
398
+
399
+ export async function consolidateCommand(options = {}) {
400
+ console.log();
401
+ const spinner = ora({ text: chalk.gray('Scanning memories across all tools...'), spinner: 'dots' }).start();
402
+
403
+ // Collect all memory files
404
+ const allFiles = [];
405
+ for (const adapter of adapters) {
406
+ spinner.text = `${adapter.icon} Scanning ${chalk.cyan(adapter.name)}...`;
407
+ const files = await readMemoryFiles(adapter);
408
+ allFiles.push(...files);
409
+ }
410
+
411
+ if (allFiles.length === 0) {
412
+ spinner.fail(chalk.red('No memory files found.'));
413
+ return;
414
+ }
415
+
416
+ spinner.text = chalk.gray(`Analyzing ${allFiles.length} files...`);
417
+
418
+ // Run heuristic analysis
419
+ const issues = analyzeMemories(allFiles);
420
+
421
+ spinner.stop();
422
+
423
+ // Print heuristic report
424
+ const hasIssues = printIssues(issues, allFiles);
425
+
426
+ // Run LLM analysis if --smart
427
+ let llmReport = null;
428
+ if (options.smart) {
429
+ const apiKey = await getGeminiApiKey();
430
+ if (!apiKey) {
431
+ console.log(chalk.yellow(' No Gemini API key found. Set GEMINI_API_KEY or run memoir init to configure.'));
432
+ console.log(chalk.gray(' Skipping AI-powered analysis.\n'));
433
+ } else {
434
+ const llmSpinner = ora({ text: chalk.gray('Running AI-powered consolidation...'), spinner: 'dots' }).start();
435
+ try {
436
+ llmReport = await llmConsolidate(allFiles, apiKey);
437
+ llmSpinner.stop();
438
+ printLlmReport(llmReport);
439
+ } catch (err) {
440
+ llmSpinner.fail(chalk.yellow(`AI analysis failed: ${err.message}`));
441
+ }
442
+ }
443
+ }
444
+
445
+ // Apply changes if --apply
446
+ if (options.apply && hasIssues) {
447
+ let totalActions = 0;
448
+
449
+ if (issues.empty.length > 0) {
450
+ console.log(chalk.white.bold(' Clean up empty files?\n'));
451
+ totalActions += await applyPrune(issues.empty, allFiles);
452
+ }
453
+
454
+ if (issues.duplicates.length > 0) {
455
+ console.log(chalk.white.bold(' Merge duplicates?\n'));
456
+ totalActions += await applyMerge(issues.duplicates, allFiles);
457
+ }
458
+
459
+ if (issues.stale.length > 0) {
460
+ console.log(chalk.white.bold(' Prune stale memories?\n'));
461
+ totalActions += await applyPrune(issues.stale, allFiles);
462
+ }
463
+
464
+ if (totalActions > 0) {
465
+ console.log('\n' + boxen(
466
+ chalk.green.bold(`Consolidated ${totalActions} file(s)`) + '\n' +
467
+ chalk.gray('Run memoir push to sync changes to your backup.'),
468
+ { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderStyle: 'round', borderColor: 'green', dimBorder: true }
469
+ ) + '\n');
470
+ }
471
+ } else if (hasIssues && !options.apply) {
472
+ console.log(chalk.gray(' Run ') + chalk.cyan('memoir consolidate --apply') + chalk.gray(' to clean up.'));
473
+ if (!options.smart) {
474
+ console.log(chalk.gray(' Run ') + chalk.cyan('memoir consolidate --smart') + chalk.gray(' for AI-powered analysis.\n'));
475
+ }
476
+ }
477
+ }
package/src/mcp.js CHANGED
@@ -402,6 +402,122 @@ server.tool(
402
402
  }
403
403
  );
404
404
 
405
+ server.tool(
406
+ 'memoir_consolidate',
407
+ 'Analyze all AI tool memories for duplicates, stale files, contradictions, and bloat. Returns a consolidation report with actionable suggestions. Use this to help users keep their AI memory clean.',
408
+ {
409
+ smart: z.boolean().optional().describe('Use AI (Gemini Flash) for deeper analysis — finds semantic duplicates, contradictions, and merge candidates. Requires GEMINI_API_KEY.'),
410
+ },
411
+ async ({ smart }) => {
412
+ // Collect all memory files
413
+ const allFiles = [];
414
+ for (const adapter of adapters) {
415
+ const files = [];
416
+ if (adapter.customExtract) {
417
+ for (const file of adapter.files) {
418
+ const filePath = path.join(adapter.source, file);
419
+ if (await fs.pathExists(filePath)) {
420
+ try {
421
+ const content = await fs.readFile(filePath, 'utf8');
422
+ const stat = await fs.stat(filePath);
423
+ files.push({ path: file, fullPath: filePath, content, tool: adapter.name, icon: adapter.icon, mtime: stat.mtimeMs, size: content.length });
424
+ } catch {}
425
+ }
426
+ }
427
+ } else if (await fs.pathExists(adapter.source)) {
428
+ const walk = async (dir, prefix = '') => {
429
+ let entries;
430
+ try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
431
+ for (const entry of entries) {
432
+ const fullPath = path.join(dir, entry.name);
433
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
434
+ if (entry.isDirectory()) {
435
+ if (adapter.filter(fullPath)) await walk(fullPath, relPath);
436
+ } else if (/\.(md|json|yml|yaml)$/.test(entry.name)) {
437
+ if (adapter.filter(fullPath)) {
438
+ try {
439
+ const content = await fs.readFile(fullPath, 'utf8');
440
+ const stat = await fs.stat(fullPath);
441
+ files.push({ path: relPath, fullPath, content, tool: adapter.name, icon: adapter.icon, mtime: stat.mtimeMs, size: content.length });
442
+ } catch {}
443
+ }
444
+ }
445
+ }
446
+ };
447
+ await walk(adapter.source);
448
+ }
449
+ allFiles.push(...files);
450
+ }
451
+
452
+ if (allFiles.length === 0) {
453
+ return { content: [{ type: 'text', text: 'No memory files found across any AI tools.' }] };
454
+ }
455
+
456
+ // Heuristic analysis
457
+ const daysAgo = (ms) => Math.floor((Date.now() - ms) / (1000 * 60 * 60 * 24));
458
+ const fingerprint = (c) => c.toLowerCase().replace(/\s+/g, ' ').trim();
459
+ const wordSim = (a, b) => {
460
+ const wA = new Set(a.toLowerCase().split(/\s+/).filter(w => w.length > 3));
461
+ const wB = new Set(b.toLowerCase().split(/\s+/).filter(w => w.length > 3));
462
+ if (!wA.size || !wB.size) return 0;
463
+ let n = 0; for (const w of wA) { if (wB.has(w)) n++; }
464
+ return n / (wA.size + wB.size - n);
465
+ };
466
+
467
+ const duplicates = [];
468
+ const stale = [];
469
+ const bloated = [];
470
+ const empty = [];
471
+ const fps = new Map();
472
+
473
+ for (const f of allFiles) {
474
+ const fp = fingerprint(f.content);
475
+ if (fp.length < 10) { empty.push(f); continue; }
476
+ if (!fps.has(fp)) fps.set(fp, []);
477
+ fps.get(fp).push(f);
478
+ }
479
+ for (const [, group] of fps) {
480
+ if (group.length > 1) duplicates.push(group.map(f => `${f.tool}/${f.path}`));
481
+ }
482
+ for (const f of allFiles) {
483
+ if (daysAgo(f.mtime) > 60) stale.push({ file: `${f.tool}/${f.path}`, age: daysAgo(f.mtime) });
484
+ if (f.size > 10240) bloated.push({ file: `${f.tool}/${f.path}`, size: f.size });
485
+ }
486
+
487
+ let report = `Memoir Consolidation Report\n${'─'.repeat(30)}\nScanned: ${allFiles.length} files\n\n`;
488
+
489
+ if (duplicates.length) {
490
+ report += `Duplicates (${duplicates.length}):\n`;
491
+ for (const group of duplicates) report += ` ${group.join(' = ')}\n`;
492
+ report += '\n';
493
+ }
494
+ if (stale.length) {
495
+ report += `Stale — 60+ days (${stale.length}):\n`;
496
+ for (const s of stale.sort((a, b) => b.age - a.age).slice(0, 15)) report += ` ${s.file} (${s.age}d)\n`;
497
+ if (stale.length > 15) report += ` ...and ${stale.length - 15} more\n`;
498
+ report += '\n';
499
+ }
500
+ if (bloated.length) {
501
+ report += `Bloated — over 10KB (${bloated.length}):\n`;
502
+ for (const b of bloated) report += ` ${b.file} (${(b.size / 1024).toFixed(1)}KB)\n`;
503
+ report += '\n';
504
+ }
505
+ if (empty.length) {
506
+ report += `Empty / near-empty (${empty.length}):\n`;
507
+ for (const e of empty) report += ` ${e.tool}/${e.path}\n`;
508
+ report += '\n';
509
+ }
510
+ if (!duplicates.length && !stale.length && !bloated.length && !empty.length) {
511
+ report += 'No issues found. Your memories look clean!\n';
512
+ }
513
+
514
+ report += '\nRun `memoir consolidate --apply` in terminal to interactively clean up.';
515
+ if (!smart) report += '\nRun `memoir consolidate --smart` for AI-powered semantic analysis.';
516
+
517
+ return { content: [{ type: 'text', text: report }] };
518
+ }
519
+ );
520
+
405
521
  // ── Resources ────────────────────────────────────────────────────────────────
406
522
 
407
523
  // Expose detected tools as browsable resources