memoir-cli 3.5.0 → 3.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -43
- package/bin/memoir.js +16 -0
- package/package.json +3 -2
- package/src/adapters/restore.js +76 -0
- package/src/commands/consolidate.js +477 -0
- package/src/mcp.js +116 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# memoir
|
|
4
4
|
|
|
5
|
-
**
|
|
5
|
+
**Sync AI memory across every coding tool. Zero config.**
|
|
6
6
|
|
|
7
7
|
[](https://npmjs.org/package/memoir-cli)
|
|
8
8
|
[](https://npmjs.org/package/memoir-cli)
|
|
@@ -12,17 +12,16 @@
|
|
|
12
12
|
</div>
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
|
-
|
|
16
|
-
memoir activate
|
|
15
|
+
npx memoir-cli
|
|
17
16
|
```
|
|
18
17
|
|
|
19
|
-
Your AI now
|
|
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
|
-
##
|
|
41
|
-
|
|
42
|
-
### 1. Install
|
|
39
|
+
## Quick start
|
|
43
40
|
|
|
44
41
|
```bash
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
-
#
|
|
97
|
-
memoir
|
|
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.
|
|
3
|
+
"version": "3.6.1",
|
|
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",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
21
|
"start": "node bin/memoir.js",
|
|
22
|
-
"test": "bash test-
|
|
22
|
+
"test": "node test-cross-machine.mjs && bash test-cross-machine-e2e.sh",
|
|
23
|
+
"test:legacy": "bash test-local.sh",
|
|
23
24
|
"postinstall": "node -e \"try{const c='\\x1b[36m',r='\\x1b[0m',g='\\x1b[90m';console.log('\\n '+c+'memoir'+r+' installed.\\n Run '+c+'memoir activate'+r+' in any project to give your AI long-term memory.\\n '+g+'https://memoir.sh'+r+'\\n')}catch{}\""
|
|
24
25
|
},
|
|
25
26
|
"keywords": [
|
package/src/adapters/restore.js
CHANGED
|
@@ -14,9 +14,19 @@ export function detectLocalHomeKey(adapterSource) {
|
|
|
14
14
|
if (!fs.existsSync(localProjectsDir)) return null;
|
|
15
15
|
|
|
16
16
|
const entries = fs.readdirSync(localProjectsDir)
|
|
17
|
+
.filter(e => !e.startsWith('.'))
|
|
17
18
|
.filter(e => fs.statSync(path.join(localProjectsDir, e)).isDirectory());
|
|
18
19
|
if (entries.length === 0) return null;
|
|
19
20
|
|
|
21
|
+
// Prefer the key that matches this machine's homedir encoding.
|
|
22
|
+
// Stale foreign dirs (from older memoir versions) can have newer mtimes,
|
|
23
|
+
// so mtime alone is unreliable — the encoded homedir is the ground truth.
|
|
24
|
+
const home = os.homedir();
|
|
25
|
+
const expectedKey = process.platform === 'win32'
|
|
26
|
+
? home.replace(/\\/g, '-').replace(/:/g, '-')
|
|
27
|
+
: '-' + home.replace(/^\//, '').replace(/\//g, '-');
|
|
28
|
+
if (entries.includes(expectedKey)) return expectedKey;
|
|
29
|
+
|
|
20
30
|
// Find dirs with a memory/ subfolder that aren't sub-projects of another dir
|
|
21
31
|
const candidates = entries.filter(entry => {
|
|
22
32
|
const hasMemory = fs.existsSync(path.join(localProjectsDir, entry, 'memory'));
|
|
@@ -143,6 +153,57 @@ function remapProjectPaths(backupDir, adapterSource) {
|
|
|
143
153
|
return remaps;
|
|
144
154
|
}
|
|
145
155
|
|
|
156
|
+
// Scan local ~/.claude/projects/ for foreign home-key dirs left behind by older
|
|
157
|
+
// memoir versions (when remap was unreliable). Merge their memory/ into the local
|
|
158
|
+
// home key and move the stale dir into .memoir-archived-{ts}/ so nothing is lost.
|
|
159
|
+
export async function cleanupLocalForeignKeys(adapterSource) {
|
|
160
|
+
const projectsDir = path.join(adapterSource, 'projects');
|
|
161
|
+
if (!await fs.pathExists(projectsDir)) return { archived: [], merged: 0 };
|
|
162
|
+
|
|
163
|
+
const localHomeKey = detectLocalHomeKey(adapterSource);
|
|
164
|
+
if (!localHomeKey) return { archived: [], merged: 0 };
|
|
165
|
+
|
|
166
|
+
const home = os.homedir();
|
|
167
|
+
const localUsername = path.basename(home).toLowerCase();
|
|
168
|
+
|
|
169
|
+
const entries = (await fs.readdir(projectsDir, { withFileTypes: true }))
|
|
170
|
+
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
|
|
171
|
+
.map(e => e.name);
|
|
172
|
+
|
|
173
|
+
const foreignKeys = [];
|
|
174
|
+
for (const entry of entries) {
|
|
175
|
+
if (entry === localHomeKey) continue;
|
|
176
|
+
if (entry.startsWith(localHomeKey + '-')) continue;
|
|
177
|
+
// Leave alt encodings of THIS machine alone (contain local username)
|
|
178
|
+
if (entry.toLowerCase().includes(localUsername)) continue;
|
|
179
|
+
// Must have memory/ — dirs without it are project dirs that Claude won't read anyway
|
|
180
|
+
if (!await fs.pathExists(path.join(projectsDir, entry, 'memory'))) continue;
|
|
181
|
+
foreignKeys.push(entry);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (foreignKeys.length === 0) return { archived: [], merged: 0 };
|
|
185
|
+
|
|
186
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
187
|
+
const archiveDir = path.join(projectsDir, `.memoir-archived-${ts}`);
|
|
188
|
+
await fs.ensureDir(archiveDir);
|
|
189
|
+
|
|
190
|
+
const localMemDir = path.join(projectsDir, localHomeKey, 'memory');
|
|
191
|
+
await fs.ensureDir(localMemDir);
|
|
192
|
+
|
|
193
|
+
let merged = 0;
|
|
194
|
+
for (const key of foreignKeys) {
|
|
195
|
+
const foreignMemDir = path.join(projectsDir, key, 'memory');
|
|
196
|
+
if (await fs.pathExists(foreignMemDir)) {
|
|
197
|
+
const before = (await fs.readdir(localMemDir)).length;
|
|
198
|
+
await mergeMemoryDirs(foreignMemDir, localMemDir);
|
|
199
|
+
merged += (await fs.readdir(localMemDir)).length - before;
|
|
200
|
+
}
|
|
201
|
+
await fs.move(path.join(projectsDir, key), path.join(archiveDir, key));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { archived: foreignKeys, merged };
|
|
205
|
+
}
|
|
206
|
+
|
|
146
207
|
// Merge memory dirs from a foreign machine — copies files that don't exist locally,
|
|
147
208
|
// and for files that exist on both, keeps the newer version.
|
|
148
209
|
async function mergeMemoryDirs(src, dest) {
|
|
@@ -291,6 +352,21 @@ export async function restoreMemories(sourceDir, spinner, onlyFilter = null, aut
|
|
|
291
352
|
if (confirm) {
|
|
292
353
|
const changes = { added: [], updated: [], skipped: [] };
|
|
293
354
|
|
|
355
|
+
// Clean up stale foreign home-key dirs left on this machine by older
|
|
356
|
+
// memoir versions. Must run before remap so detectLocalHomeKey sees a
|
|
357
|
+
// clean local state.
|
|
358
|
+
if (adapter.name === 'Claude CLI') {
|
|
359
|
+
try {
|
|
360
|
+
const { archived, merged } = await cleanupLocalForeignKeys(adapter.source);
|
|
361
|
+
if (archived.length > 0) {
|
|
362
|
+
spinner.stop();
|
|
363
|
+
console.log(chalk.gray(` Cleaned up ${archived.length} stale foreign dir(s) on this machine${merged > 0 ? ` (merged ${merged} new file(s))` : ''}`));
|
|
364
|
+
for (const k of archived) console.log(chalk.gray(` archived: ${k}`));
|
|
365
|
+
spinner.start();
|
|
366
|
+
}
|
|
367
|
+
} catch {}
|
|
368
|
+
}
|
|
369
|
+
|
|
294
370
|
// Remap Claude project paths from source machine to this machine
|
|
295
371
|
if (adapter.name === 'Claude CLI') {
|
|
296
372
|
const remaps = remapProjectPaths(backupDir, adapter.source);
|
|
@@ -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
|