memoir-cli 3.4.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 +25 -43
- package/bin/memoir.js +22 -29
- package/package.json +1 -1
- package/src/commands/consolidate.js +477 -0
- package/src/commands/push.js +15 -8
- package/src/commands/restore.js +14 -8
- package/src/config.js +45 -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);
|
|
@@ -53,41 +54,18 @@ async function checkForUpdate() {
|
|
|
53
54
|
} catch {}
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
//
|
|
57
|
+
// When run with no args: auto-push (zero-config)
|
|
57
58
|
if (process.argv.length <= 2) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
chalk.gray(' Your AI remembers everything.') + '\n\n' +
|
|
61
|
-
chalk.white.bold('Quick Start:') + '\n' +
|
|
62
|
-
chalk.cyan(' memoir init ') + chalk.gray('— first-time setup') + '\n' +
|
|
63
|
-
chalk.cyan(' memoir push ') + chalk.gray('— back up your AI memory') + '\n' +
|
|
64
|
-
chalk.cyan(' memoir restore ') + chalk.gray('— restore on a new machine') + '\n' +
|
|
65
|
-
chalk.cyan(' memoir snapshot ') + chalk.gray('— capture your current session') + '\n' +
|
|
66
|
-
chalk.cyan(' memoir resume ') + chalk.gray('— pick up where you left off') + '\n' +
|
|
67
|
-
chalk.cyan(' memoir status ') + chalk.gray('— see detected AI tools') + '\n' +
|
|
68
|
-
chalk.cyan(' memoir profile ') + chalk.gray('— manage profiles (personal/work)') + '\n' +
|
|
69
|
-
chalk.cyan(' memoir projects ') + chalk.gray('— see all your projects at a glance') + '\n' +
|
|
70
|
-
chalk.cyan(' memoir activate ') + chalk.gray('— enable auto-recall in this project') + '\n' +
|
|
71
|
-
chalk.cyan(' memoir encrypt ') + chalk.gray('— toggle E2E encryption') + '\n' +
|
|
72
|
-
chalk.cyan(' memoir update ') + chalk.gray('— update to latest version') + '\n' +
|
|
73
|
-
chalk.cyan(' memoir upgrade ') + chalk.gray('— view plans & upgrade') + '\n\n' +
|
|
74
|
-
chalk.white.bold('Cloud (Pro):') + '\n' +
|
|
75
|
-
chalk.cyan(' memoir login ') + chalk.gray('— sign in to memoir cloud') + '\n' +
|
|
76
|
-
chalk.cyan(' memoir cloud push ') + chalk.gray('— back up to the cloud') + '\n' +
|
|
77
|
-
chalk.cyan(' memoir cloud restore ') + chalk.gray('— restore from cloud') + '\n' +
|
|
78
|
-
chalk.cyan(' memoir share ') + chalk.gray('— share memory via secure link') + '\n' +
|
|
79
|
-
chalk.cyan(' memoir history ') + chalk.gray('— view backup versions') + '\n\n' +
|
|
80
|
-
chalk.gray(' Tip: use --profile work to sync a specific profile') + '\n\n' +
|
|
81
|
-
chalk.gray(`v${VERSION}`),
|
|
82
|
-
{ padding: 1, borderStyle: 'round', borderColor: 'cyan', dimBorder: true }
|
|
83
|
-
) + '\n');
|
|
84
|
-
process.exit(0);
|
|
59
|
+
// Pass 'push' as the command so Commander routes to pushCommand
|
|
60
|
+
process.argv.push('push');
|
|
85
61
|
}
|
|
86
62
|
|
|
87
63
|
// Custom help banner
|
|
88
64
|
program.addHelpText('beforeAll', '\n' + boxen(
|
|
89
65
|
gradient.pastel.multiline(' memoir ') + '\n' +
|
|
90
|
-
chalk.gray(' Your AI remembers everything.')
|
|
66
|
+
chalk.gray(' Your AI remembers everything.') + '\n\n' +
|
|
67
|
+
chalk.white.bold('Zero-config:') + ' just run ' + chalk.cyan('memoir') + ' or ' + chalk.cyan('npx memoir-cli') + '\n' +
|
|
68
|
+
chalk.gray('Auto-detects your GitHub, creates a private repo, and backs up.'),
|
|
91
69
|
{ padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderStyle: 'round', borderColor: 'cyan', dimBorder: true }
|
|
92
70
|
) + '\n');
|
|
93
71
|
|
|
@@ -577,6 +555,21 @@ projects
|
|
|
577
555
|
}
|
|
578
556
|
});
|
|
579
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
|
+
|
|
580
573
|
program
|
|
581
574
|
.command('mcp')
|
|
582
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.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/commands/push.js
CHANGED
|
@@ -5,7 +5,7 @@ import os from 'os';
|
|
|
5
5
|
import ora from 'ora';
|
|
6
6
|
import boxen from 'boxen';
|
|
7
7
|
import gradient from 'gradient-string';
|
|
8
|
-
import { getConfig } from '../config.js';
|
|
8
|
+
import { getConfig, autoSetup } from '../config.js';
|
|
9
9
|
import { extractMemories, adapters } from '../adapters/index.js';
|
|
10
10
|
import { syncToLocal, syncToGit } from '../providers/index.js';
|
|
11
11
|
import inquirer from 'inquirer';
|
|
@@ -17,15 +17,22 @@ import { scanWorkspace } from '../workspace/tracker.js';
|
|
|
17
17
|
import { promptActivate } from './activate.js';
|
|
18
18
|
|
|
19
19
|
export async function pushCommand(options = {}) {
|
|
20
|
-
|
|
20
|
+
let config = await getConfig(options.profile);
|
|
21
21
|
|
|
22
22
|
if (!config) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
// Zero-config: auto-detect GitHub user, create repo, save config
|
|
24
|
+
const setupSpinner = ora({ text: chalk.gray('Setting up memoir automatically...'), spinner: 'dots' }).start();
|
|
25
|
+
config = await autoSetup();
|
|
26
|
+
if (config) {
|
|
27
|
+
setupSpinner.succeed(chalk.green('Auto-configured') + chalk.gray(` → ${config.gitRepo}`));
|
|
28
|
+
} else {
|
|
29
|
+
setupSpinner.fail(chalk.red('Could not detect GitHub username'));
|
|
30
|
+
console.log('\n' + boxen(
|
|
31
|
+
chalk.white('Run ') + chalk.cyan.bold('memoir init') + chalk.white(' to set up manually.'),
|
|
32
|
+
{ padding: 1, borderStyle: 'round', borderColor: 'yellow' }
|
|
33
|
+
) + '\n');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
console.log();
|
package/src/commands/restore.js
CHANGED
|
@@ -6,7 +6,7 @@ import ora from 'ora';
|
|
|
6
6
|
import boxen from 'boxen';
|
|
7
7
|
import gradient from 'gradient-string';
|
|
8
8
|
import inquirer from 'inquirer';
|
|
9
|
-
import { getConfig } from '../config.js';
|
|
9
|
+
import { getConfig, autoSetup } from '../config.js';
|
|
10
10
|
import { fetchFromLocal, fetchFromGit } from '../providers/restore.js';
|
|
11
11
|
import { decryptDirectory, verifyPassphrase } from '../security/encryption.js';
|
|
12
12
|
import { detectLocalHomeKey } from '../adapters/restore.js';
|
|
@@ -23,15 +23,21 @@ export async function restoreCommand(options = {}) {
|
|
|
23
23
|
return restoreFromShare(options);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
let config = await getConfig(options.profile);
|
|
27
27
|
|
|
28
28
|
if (!config) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
const setupSpinner = ora({ text: chalk.gray('Setting up memoir automatically...'), spinner: 'dots' }).start();
|
|
30
|
+
config = await autoSetup();
|
|
31
|
+
if (config) {
|
|
32
|
+
setupSpinner.succeed(chalk.green('Auto-configured') + chalk.gray(` → ${config.gitRepo}`));
|
|
33
|
+
} else {
|
|
34
|
+
setupSpinner.fail(chalk.red('Could not detect GitHub username'));
|
|
35
|
+
console.log('\n' + boxen(
|
|
36
|
+
chalk.white('Run ') + chalk.cyan.bold('memoir init') + chalk.white(' to set up manually.'),
|
|
37
|
+
{ padding: 1, borderStyle: 'round', borderColor: 'yellow' }
|
|
38
|
+
) + '\n');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
console.log();
|
package/src/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
|
+
import { execFileSync } from 'child_process';
|
|
4
5
|
|
|
5
6
|
const CONFIG_DIR = process.platform === 'win32'
|
|
6
7
|
? path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'memoir')
|
|
@@ -114,6 +115,50 @@ export async function deleteProfile(name) {
|
|
|
114
115
|
await saveConfig(raw);
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
// Zero-config auto-setup: detect GitHub user, create repo, save config, return it
|
|
119
|
+
export async function autoSetup() {
|
|
120
|
+
// Try gh CLI first, then git config
|
|
121
|
+
let username = '';
|
|
122
|
+
try {
|
|
123
|
+
username = execFileSync('gh', ['api', 'user', '--jq', '.login'], { encoding: 'utf8', timeout: 5000 }).trim();
|
|
124
|
+
} catch {
|
|
125
|
+
try {
|
|
126
|
+
username = execFileSync('git', ['config', '--global', 'user.name'], { encoding: 'utf8' }).trim();
|
|
127
|
+
} catch {}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!username) return null; // Can't auto-setup without a username
|
|
131
|
+
|
|
132
|
+
const repo = 'ai-memory';
|
|
133
|
+
const gitRepo = `https://github.com/${username}/${repo}.git`;
|
|
134
|
+
|
|
135
|
+
// Try to create the repo if it doesn't exist (best-effort)
|
|
136
|
+
try {
|
|
137
|
+
execFileSync('gh', ['repo', 'view', `${username}/${repo}`], { stdio: 'ignore', timeout: 5000 });
|
|
138
|
+
} catch {
|
|
139
|
+
try {
|
|
140
|
+
execFileSync('gh', ['repo', 'create', `${username}/${repo}`, '--private', '--description', 'AI memory backup (memoir-cli)'], { stdio: 'ignore', timeout: 10000 });
|
|
141
|
+
} catch {
|
|
142
|
+
// If gh isn't available, user will need to create repo manually — that's fine, syncToGit will handle it
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const config = {
|
|
147
|
+
version: 2,
|
|
148
|
+
activeProfile: 'default',
|
|
149
|
+
profiles: {
|
|
150
|
+
default: {
|
|
151
|
+
provider: 'git',
|
|
152
|
+
gitRepo,
|
|
153
|
+
encrypt: false // Skip encryption for zero-config — user can enable later with `memoir encrypt`
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
await saveConfig(config);
|
|
159
|
+
return config.profiles.default;
|
|
160
|
+
}
|
|
161
|
+
|
|
117
162
|
export async function getGeminiApiKey() {
|
|
118
163
|
const raw = await getRawConfig();
|
|
119
164
|
return raw?.geminiApiKey || process.env.GEMINI_API_KEY || null;
|
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
|