Sign In

memoir-cli

Package Overview
Dependencies
Maintainers
1
Versions
51
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

memoir-cli - npm Package Compare versions

Comparing version
3.5.0
to
3.6.0
+477
src/commands/consolidate.js
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import os from 'os';
import ora from 'ora';
import boxen from 'boxen';
import gradient from 'gradient-string';
import inquirer from 'inquirer';
import { getConfig, getGeminiApiKey } from '../config.js';
import { adapters } from '../adapters/index.js';
const home = os.homedir();
// ── Helpers ──────────────────────────────────────────────────────────────────
async function readMemoryFiles(adapter) {
const files = [];
if (adapter.customExtract) {
for (const file of adapter.files) {
const filePath = path.join(adapter.source, file);
if (await fs.pathExists(filePath)) {
try {
const content = await fs.readFile(filePath, 'utf8');
const stat = await fs.stat(filePath);
files.push({ path: file, fullPath: filePath, content, tool: adapter.name, icon: adapter.icon, mtime: stat.mtimeMs, size: content.length });
} catch {}
}
}
return files;
}
if (!(await fs.pathExists(adapter.source))) return files;
const walk = async (dir, prefix = '') => {
let entries;
try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
if (adapter.filter(fullPath)) {
await walk(fullPath, relPath);
}
} else if (/\.(md|json|yml|yaml)$/.test(entry.name)) {
if (adapter.filter(fullPath)) {
try {
const content = await fs.readFile(fullPath, 'utf8');
const stat = await fs.stat(fullPath);
files.push({ path: relPath, fullPath, content, tool: adapter.name, icon: adapter.icon, mtime: stat.mtimeMs, size: content.length });
} catch {}
}
}
}
};
await walk(adapter.source);
return files;
}
function daysAgo(mtimeMs) {
return Math.floor((Date.now() - mtimeMs) / (1000 * 60 * 60 * 24));
}
function contentFingerprint(content) {
// Normalize whitespace and case for comparison
return content.toLowerCase().replace(/\s+/g, ' ').trim();
}
function similarity(a, b) {
// Jaccard similarity on word sets
const wordsA = new Set(a.toLowerCase().split(/\s+/).filter(w => w.length > 3));
const wordsB = new Set(b.toLowerCase().split(/\s+/).filter(w => w.length > 3));
if (wordsA.size === 0 || wordsB.size === 0) return 0;
let intersection = 0;
for (const w of wordsA) {
if (wordsB.has(w)) intersection++;
}
return intersection / (wordsA.size + wordsB.size - intersection);
}
// ── Analysis ─────────────────────────────────────────────────────────────────
function analyzeMemories(allFiles) {
const issues = { duplicates: [], stale: [], bloated: [], contradictions: [], empty: [] };
// 1. Find exact duplicates (same content across tools)
const fingerprints = new Map();
for (const file of allFiles) {
const fp = contentFingerprint(file.content);
if (fp.length < 10) {
issues.empty.push(file);
continue;
}
if (!fingerprints.has(fp)) {
fingerprints.set(fp, []);
}
fingerprints.get(fp).push(file);
}
for (const [, group] of fingerprints) {
if (group.length > 1) {
issues.duplicates.push(group);
}
}
// 2. Find near-duplicates (>70% word overlap)
const nonDuplicateFiles = allFiles.filter(f => contentFingerprint(f.content).length >= 10);
const alreadyDuplicate = new Set(issues.duplicates.flat().map(f => f.fullPath));
for (let i = 0; i < nonDuplicateFiles.length; i++) {
for (let j = i + 1; j < nonDuplicateFiles.length; j++) {
const a = nonDuplicateFiles[i];
const b = nonDuplicateFiles[j];
if (alreadyDuplicate.has(a.fullPath) && alreadyDuplicate.has(b.fullPath)) continue;
const sim = similarity(a.content, b.content);
if (sim > 0.7) {
issues.duplicates.push([a, b]);
alreadyDuplicate.add(a.fullPath);
alreadyDuplicate.add(b.fullPath);
}
}
}
// 3. Find stale memories (not modified in 60+ days)
for (const file of allFiles) {
const age = daysAgo(file.mtime);
if (age > 60) {
issues.stale.push({ ...file, age });
}
}
// 4. Find bloated files (>10KB)
for (const file of allFiles) {
if (file.size > 10240) {
issues.bloated.push(file);
}
}
return issues;
}
// ── LLM Consolidation ────────────────────────────────────────────────────────
async function llmConsolidate(allFiles, apiKey) {
// Build a summary of all memories for the LLM
const memoryDigest = allFiles
.filter(f => f.content.trim().length > 10)
.map(f => `[${f.tool} / ${f.path}] (${daysAgo(f.mtime)}d old, ${f.size}B)\n${f.content.slice(0, 500)}${f.content.length > 500 ? '...' : ''}`)
.join('\n\n---\n\n');
const prompt = `You are a memory consolidation engine. Analyze these AI tool memory files and produce a consolidation report.
MEMORIES:
${memoryDigest}
Produce a JSON response with these fields:
{
"merge_groups": [
{ "files": ["tool/path1", "tool/path2"], "reason": "why these should be merged", "merged_content": "the consolidated content" }
],
"prune": [
{ "file": "tool/path", "reason": "why this should be removed" }
],
"contradictions": [
{ "files": ["tool/path1", "tool/path2"], "description": "what contradicts" }
],
"summary": "1-2 sentence summary of the consolidation"
}
Rules:
- Only suggest merging files that have significant content overlap or cover the same topic
- Only suggest pruning files that are clearly outdated, empty, or superseded
- Flag contradictions where two files give conflicting instructions about the same thing
- Be conservative — when in doubt, keep the memory
- Return valid JSON only, no markdown fences`;
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { maxOutputTokens: 4000, temperature: 0.2, responseMimeType: 'application/json' }
})
});
if (!response.ok) {
throw new Error(`Gemini API error: ${response.status}`);
}
const data = await response.json();
const text = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (!text) throw new Error('Empty response from Gemini');
return JSON.parse(text);
}
// ── Display ──────────────────────────────────────────────────────────────────
function formatSize(bytes) {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
function printIssues(issues, allFiles) {
const totalIssues = issues.duplicates.length + issues.stale.length + issues.bloated.length + issues.empty.length;
if (totalIssues === 0) {
console.log('\n' + boxen(
chalk.green.bold('Your memories look clean!') + '\n\n' +
chalk.gray(`Scanned ${allFiles.length} files across all tools. No issues found.`),
{ padding: 1, borderStyle: 'round', borderColor: 'green', dimBorder: true }
) + '\n');
return false;
}
console.log('\n' + boxen(
gradient.pastel(' Consolidation Report ') + '\n\n' +
chalk.white(`Scanned ${chalk.cyan(allFiles.length)} memory files`) + chalk.gray(` | ${totalIssues} issues found`),
{ padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderStyle: 'round', borderColor: 'cyan', dimBorder: true }
));
if (issues.duplicates.length > 0) {
console.log('\n' + chalk.yellow.bold(` Duplicates (${issues.duplicates.length})`));
for (const group of issues.duplicates) {
const sim = group.length === 2 ? ` ${Math.round(similarity(group[0].content, group[1].content) * 100)}% similar` : ' exact match';
console.log(chalk.gray(` ┌${sim}`));
for (const f of group) {
console.log(` │ ${f.icon} ${chalk.cyan(f.tool)} ${chalk.white(f.path)} ${chalk.gray(formatSize(f.size))}`);
}
console.log(chalk.gray(' └'));
}
}
if (issues.stale.length > 0) {
console.log('\n' + chalk.yellow.bold(` Stale (${issues.stale.length}) — not modified in 60+ days`));
for (const f of issues.stale.sort((a, b) => b.age - a.age).slice(0, 15)) {
console.log(` ${f.icon} ${chalk.cyan(f.tool)} ${chalk.white(f.path)} ${chalk.gray(`${f.age}d ago`)}`);
}
if (issues.stale.length > 15) {
console.log(chalk.gray(` ...and ${issues.stale.length - 15} more`));
}
}
if (issues.bloated.length > 0) {
console.log('\n' + chalk.yellow.bold(` Bloated (${issues.bloated.length}) — over 10KB`));
for (const f of issues.bloated.sort((a, b) => b.size - a.size)) {
console.log(` ${f.icon} ${chalk.cyan(f.tool)} ${chalk.white(f.path)} ${chalk.red(formatSize(f.size))}`);
}
}
if (issues.empty.length > 0) {
console.log('\n' + chalk.yellow.bold(` Empty / near-empty (${issues.empty.length})`));
for (const f of issues.empty) {
console.log(` ${f.icon} ${chalk.cyan(f.tool)} ${chalk.white(f.path)}`);
}
}
console.log('');
return true;
}
function printLlmReport(report) {
console.log('\n' + boxen(
gradient.pastel(' AI Consolidation '),
{ padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderStyle: 'round', borderColor: 'magenta', dimBorder: true }
));
if (report.summary) {
console.log('\n ' + chalk.white(report.summary));
}
if (report.merge_groups?.length > 0) {
console.log('\n' + chalk.magenta.bold(` Merge suggestions (${report.merge_groups.length})`));
for (const group of report.merge_groups) {
console.log(chalk.gray(` ┌ ${group.reason}`));
for (const file of group.files) {
console.log(` │ ${chalk.cyan(file)}`);
}
console.log(chalk.gray(' └'));
}
}
if (report.contradictions?.length > 0) {
console.log('\n' + chalk.red.bold(` Contradictions (${report.contradictions.length})`));
for (const c of report.contradictions) {
console.log(chalk.gray(` ┌ ${c.description}`));
for (const file of c.files) {
console.log(` │ ${chalk.cyan(file)}`);
}
console.log(chalk.gray(' └'));
}
}
if (report.prune?.length > 0) {
console.log('\n' + chalk.yellow.bold(` Prune suggestions (${report.prune.length})`));
for (const p of report.prune) {
console.log(` ${chalk.cyan(p.file)} ${chalk.gray('— ' + p.reason)}`);
}
}
console.log('');
}
// ── Actions ──────────────────────────────────────────────────────────────────
async function applyPrune(files, allFiles) {
const choices = files.map(f => ({
name: `${f.icon || ''} ${f.tool || ''} ${f.path} ${chalk.gray(`(${f.age ? f.age + 'd old' : formatSize(f.size)})`)}`,
value: f,
checked: false
}));
const { toDelete } = await inquirer.prompt([{
type: 'checkbox',
name: 'toDelete',
message: 'Select memories to delete:',
choices
}]);
if (toDelete.length === 0) {
console.log(chalk.gray(' Nothing selected.\n'));
return 0;
}
const { confirm } = await inquirer.prompt([{
type: 'confirm',
name: 'confirm',
message: `Delete ${toDelete.length} file(s)? This cannot be undone.`,
default: false
}]);
if (!confirm) {
console.log(chalk.gray(' Cancelled.\n'));
return 0;
}
let deleted = 0;
for (const file of toDelete) {
try {
await fs.remove(file.fullPath);
console.log(chalk.red(` ✖ Deleted: ${file.tool}/${file.path}`));
deleted++;
} catch (err) {
console.log(chalk.red(` ✖ Failed to delete ${file.path}: ${err.message}`));
}
}
return deleted;
}
async function applyMerge(duplicateGroups, allFiles) {
let merged = 0;
for (const group of duplicateGroups) {
console.log(chalk.gray('\n ┌ Duplicate group:'));
for (const f of group) {
console.log(` │ ${f.icon} ${chalk.cyan(f.tool)}/${chalk.white(f.path)} ${chalk.gray(`(${daysAgo(f.mtime)}d old)`)}`);
}
console.log(chalk.gray(' └'));
// Keep the newest file, offer to delete the rest
const sorted = [...group].sort((a, b) => b.mtime - a.mtime);
const keep = sorted[0];
const remove = sorted.slice(1);
console.log(chalk.green(` Keep: ${keep.tool}/${keep.path} (newest)`));
for (const r of remove) {
console.log(chalk.red(` Remove: ${r.tool}/${r.path}`));
}
const { confirm } = await inquirer.prompt([{
type: 'confirm',
name: 'confirm',
message: `Remove ${remove.length} duplicate(s), keep the newest?`,
default: true
}]);
if (confirm) {
for (const r of remove) {
try {
await fs.remove(r.fullPath);
console.log(chalk.red(` ✖ Removed: ${r.tool}/${r.path}`));
merged++;
} catch (err) {
console.log(chalk.red(` ✖ Failed: ${err.message}`));
}
}
}
}
return merged;
}
// ── Main Command ─────────────────────────────────────────────────────────────
export async function consolidateCommand(options = {}) {
console.log();
const spinner = ora({ text: chalk.gray('Scanning memories across all tools...'), spinner: 'dots' }).start();
// Collect all memory files
const allFiles = [];
for (const adapter of adapters) {
spinner.text = `${adapter.icon} Scanning ${chalk.cyan(adapter.name)}...`;
const files = await readMemoryFiles(adapter);
allFiles.push(...files);
}
if (allFiles.length === 0) {
spinner.fail(chalk.red('No memory files found.'));
return;
}
spinner.text = chalk.gray(`Analyzing ${allFiles.length} files...`);
// Run heuristic analysis
const issues = analyzeMemories(allFiles);
spinner.stop();
// Print heuristic report
const hasIssues = printIssues(issues, allFiles);
// Run LLM analysis if --smart
let llmReport = null;
if (options.smart) {
const apiKey = await getGeminiApiKey();
if (!apiKey) {
console.log(chalk.yellow(' No Gemini API key found. Set GEMINI_API_KEY or run memoir init to configure.'));
console.log(chalk.gray(' Skipping AI-powered analysis.\n'));
} else {
const llmSpinner = ora({ text: chalk.gray('Running AI-powered consolidation...'), spinner: 'dots' }).start();
try {
llmReport = await llmConsolidate(allFiles, apiKey);
llmSpinner.stop();
printLlmReport(llmReport);
} catch (err) {
llmSpinner.fail(chalk.yellow(`AI analysis failed: ${err.message}`));
}
}
}
// Apply changes if --apply
if (options.apply && hasIssues) {
let totalActions = 0;
if (issues.empty.length > 0) {
console.log(chalk.white.bold(' Clean up empty files?\n'));
totalActions += await applyPrune(issues.empty, allFiles);
}
if (issues.duplicates.length > 0) {
console.log(chalk.white.bold(' Merge duplicates?\n'));
totalActions += await applyMerge(issues.duplicates, allFiles);
}
if (issues.stale.length > 0) {
console.log(chalk.white.bold(' Prune stale memories?\n'));
totalActions += await applyPrune(issues.stale, allFiles);
}
if (totalActions > 0) {
console.log('\n' + boxen(
chalk.green.bold(`Consolidated ${totalActions} file(s)`) + '\n' +
chalk.gray('Run memoir push to sync changes to your backup.'),
{ padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderStyle: 'round', borderColor: 'green', dimBorder: true }
) + '\n');
}
} else if (hasIssues && !options.apply) {
console.log(chalk.gray(' Run ') + chalk.cyan('memoir consolidate --apply') + chalk.gray(' to clean up.'));
if (!options.smart) {
console.log(chalk.gray(' Run ') + chalk.cyan('memoir consolidate --smart') + chalk.gray(' for AI-powered analysis.\n'));
}
}
}
+16
-0

@@ -24,2 +24,3 @@ #!/usr/bin/env node

import { activateCommand, deactivateCommand } from '../src/commands/activate.js';
import { consolidateCommand } from '../src/commands/consolidate.js';
import { createRequire } from 'module';

@@ -559,2 +560,17 @@

program
.command('consolidate')
.alias('tidy')
.description('Analyze and clean up your AI memories — find duplicates, stale files, and contradictions')
.option('--smart', 'Use AI to analyze memories and suggest merges (requires Gemini API key)')
.option('--apply', 'Interactively apply suggested changes (delete, merge, prune)')
.action(async (options) => {
try {
await consolidateCommand(options);
} catch (err) {
console.error(chalk.red('\n✖ Error during consolidation:'), err.message);
process.exit(1);
}
});
program
.command('mcp')

@@ -561,0 +577,0 @@ .description('Start the MCP server (for Claude Code, Cursor, VS Code integration)')

+1
-1
{
"name": "memoir-cli",
"version": "3.5.0",
"version": "3.6.0",
"mcpName": "io.github.camgitt/memoir",

@@ -5,0 +5,0 @@ "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.",

+25
-43

@@ -5,3 +5,3 @@ <div align="center">

**Portable memory for every AI coding tool.**
**Sync AI memory across every coding tool. Zero config.**

@@ -16,7 +16,6 @@ [![npm version](https://img.shields.io/npm/v/memoir-cli.svg?style=flat-square&color=7c6ef0)](https://npmjs.org/package/memoir-cli)

```bash
npm install -g memoir-cli
memoir activate
npx memoir-cli
```
Your AI now remembers across sessions, tools, and machines. Works with Claude Code, Cursor, Windsurf, Gemini, Copilot, and 6 more tools.
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.

@@ -27,3 +26,3 @@ ---

memoir is an [MCP server](https://modelcontextprotocol.io) that gives your AI tools persistent memory. Your AI can search, save, and recall context automatically.
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.

@@ -43,38 +42,12 @@ ```

## Setup
## Quick start
### 1. Install
```bash
npm install -g memoir-cli
npx memoir-cli
```
### 2. Add MCP to your AI tool
That's it. memoir detects your AI tools, configures MCP, and activates memory. No global install needed.
**Claude Code** — add to `~/.mcp.json`:
```json
{
"mcpServers": {
"memoir": { "command": "memoir-mcp" }
}
}
```
Your AI gets 7 memory tools:
**Cursor** — add to `.cursor/mcp.json`:
```json
{
"mcpServers": {
"memoir": { "command": "memoir-mcp" }
}
}
```
### 3. Activate in your project
```bash
memoir activate
```
That's it. Your AI now has 6 memory tools:
| MCP Tool | What it does |

@@ -86,2 +59,3 @@ |----------|-------------|

| `memoir_read` | Read a specific memory in full |
| `memoir_consolidate` | Analyze memories for duplicates, staleness, and bloat |
| `memoir_status` | See which AI tools are detected |

@@ -94,5 +68,5 @@ | `memoir_profiles` | Switch between work/personal |

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.
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.
**11 tools supported:** Claude Code, Cursor, Windsurf, Gemini CLI, GitHub Copilot, OpenAI Codex, ChatGPT, Aider, Zed, Cline, Continue.dev
**13 tools supported:** Claude Code, Cursor, Windsurf, Gemini CLI, GitHub Copilot, OpenAI Codex, ChatGPT, Aider, Zed, Cline, Continue.dev, Augment, Trae.

@@ -102,10 +76,7 @@ ## Sync across machines

```bash
# Back up
memoir push
# Restore on any machine
memoir restore -y
memoir push # back up AI memory + workspace + session
memoir restore -y # restore on any machine
```
Push syncs AI memory, session context, workspace (git repos + uncommitted work), and project configs. E2E encrypted with AES-256-GCM.
Push syncs AI memory, cursorrules, session context, workspace (git repos + uncommitted work), and project configs. E2E encrypted with AES-256-GCM.

@@ -122,2 +93,12 @@ ## Translate between AI tools

## Consolidate memories
```bash
memoir consolidate # scan for duplicates, stale files, bloat
memoir consolidate --smart # AI-powered analysis (finds contradictions + merge candidates)
memoir consolidate --apply # interactively clean up
```
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.
## Cloud sync

@@ -150,2 +131,3 @@

| `memoir share` | Create encrypted shareable link |
| `memoir consolidate` | Find duplicates, stale memories, and bloat |
| `memoir doctor` | Diagnose issues |

@@ -152,0 +134,0 @@ | `memoir diff` | Show changes since last backup |

@@ -405,2 +405,118 @@ #!/usr/bin/env node

server.tool(
'memoir_consolidate',
'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.',
{
smart: z.boolean().optional().describe('Use AI (Gemini Flash) for deeper analysis — finds semantic duplicates, contradictions, and merge candidates. Requires GEMINI_API_KEY.'),
},
async ({ smart }) => {
// Collect all memory files
const allFiles = [];
for (const adapter of adapters) {
const files = [];
if (adapter.customExtract) {
for (const file of adapter.files) {
const filePath = path.join(adapter.source, file);
if (await fs.pathExists(filePath)) {
try {
const content = await fs.readFile(filePath, 'utf8');
const stat = await fs.stat(filePath);
files.push({ path: file, fullPath: filePath, content, tool: adapter.name, icon: adapter.icon, mtime: stat.mtimeMs, size: content.length });
} catch {}
}
}
} else if (await fs.pathExists(adapter.source)) {
const walk = async (dir, prefix = '') => {
let entries;
try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
if (adapter.filter(fullPath)) await walk(fullPath, relPath);
} else if (/\.(md|json|yml|yaml)$/.test(entry.name)) {
if (adapter.filter(fullPath)) {
try {
const content = await fs.readFile(fullPath, 'utf8');
const stat = await fs.stat(fullPath);
files.push({ path: relPath, fullPath, content, tool: adapter.name, icon: adapter.icon, mtime: stat.mtimeMs, size: content.length });
} catch {}
}
}
}
};
await walk(adapter.source);
}
allFiles.push(...files);
}
if (allFiles.length === 0) {
return { content: [{ type: 'text', text: 'No memory files found across any AI tools.' }] };
}
// Heuristic analysis
const daysAgo = (ms) => Math.floor((Date.now() - ms) / (1000 * 60 * 60 * 24));
const fingerprint = (c) => c.toLowerCase().replace(/\s+/g, ' ').trim();
const wordSim = (a, b) => {
const wA = new Set(a.toLowerCase().split(/\s+/).filter(w => w.length > 3));
const wB = new Set(b.toLowerCase().split(/\s+/).filter(w => w.length > 3));
if (!wA.size || !wB.size) return 0;
let n = 0; for (const w of wA) { if (wB.has(w)) n++; }
return n / (wA.size + wB.size - n);
};
const duplicates = [];
const stale = [];
const bloated = [];
const empty = [];
const fps = new Map();
for (const f of allFiles) {
const fp = fingerprint(f.content);
if (fp.length < 10) { empty.push(f); continue; }
if (!fps.has(fp)) fps.set(fp, []);
fps.get(fp).push(f);
}
for (const [, group] of fps) {
if (group.length > 1) duplicates.push(group.map(f => `${f.tool}/${f.path}`));
}
for (const f of allFiles) {
if (daysAgo(f.mtime) > 60) stale.push({ file: `${f.tool}/${f.path}`, age: daysAgo(f.mtime) });
if (f.size > 10240) bloated.push({ file: `${f.tool}/${f.path}`, size: f.size });
}
let report = `Memoir Consolidation Report\n${'─'.repeat(30)}\nScanned: ${allFiles.length} files\n\n`;
if (duplicates.length) {
report += `Duplicates (${duplicates.length}):\n`;
for (const group of duplicates) report += ` ${group.join(' = ')}\n`;
report += '\n';
}
if (stale.length) {
report += `Stale — 60+ days (${stale.length}):\n`;
for (const s of stale.sort((a, b) => b.age - a.age).slice(0, 15)) report += ` ${s.file} (${s.age}d)\n`;
if (stale.length > 15) report += ` ...and ${stale.length - 15} more\n`;
report += '\n';
}
if (bloated.length) {
report += `Bloated — over 10KB (${bloated.length}):\n`;
for (const b of bloated) report += ` ${b.file} (${(b.size / 1024).toFixed(1)}KB)\n`;
report += '\n';
}
if (empty.length) {
report += `Empty / near-empty (${empty.length}):\n`;
for (const e of empty) report += ` ${e.tool}/${e.path}\n`;
report += '\n';
}
if (!duplicates.length && !stale.length && !bloated.length && !empty.length) {
report += 'No issues found. Your memories look clean!\n';
}
report += '\nRun `memoir consolidate --apply` in terminal to interactively clean up.';
if (!smart) report += '\nRun `memoir consolidate --smart` for AI-powered semantic analysis.';
return { content: [{ type: 'text', text: report }] };
}
);
// ── Resources ────────────────────────────────────────────────────────────────

@@ -407,0 +523,0 @@