memoir-cli 3.11.3 → 3.14.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 +129 -124
- package/bin/memoir-work.js +9 -0
- package/bin/memoir.js +72 -8
- package/docs/AUDIT-REMEDIATION.md +55 -0
- package/docs/CASE_TAPE_AMNESIA.md +39 -0
- package/docs/HANDOFF-SECURITY-AUDIT.md +106 -0
- package/docs/LOCAL-HANDOFF-VALIDATION.md +129 -0
- package/docs/MCP-V2-MIGRATION.md +17 -0
- package/docs/PROJECT-HANDOFF.md +255 -0
- package/docs/PROJECT-VIEW-DEBUG.md +66 -0
- package/docs/PROJECT-VIEW-VALIDATION.md +136 -0
- package/docs/RELEASE-3.14-VALIDATION.md +36 -0
- package/docs/RELIABILITY-ROLLOUT.md +57 -0
- package/docs/RETRIEVAL-INDEX.md +45 -0
- package/docs/RETRIEVAL-RESULTS.md +26 -0
- package/docs/SPEC.md +684 -0
- package/evals/CONTINUITY-PROTOCOL.md +45 -0
- package/evals/cases.json +200 -0
- package/evals/results/retrieval-2026-09-05.json +5333 -0
- package/evals/retrieval-performance.mjs +99 -0
- package/evals/run.mjs +87 -0
- package/package.json +13 -5
- package/src/adapters/index.js +13 -6
- package/src/adapters/restore.js +83 -36
- package/src/cloud/auth.js +12 -15
- package/src/cloud/constants.js +6 -2
- package/src/cloud/storage.js +130 -93
- package/src/commands/activate.js +43 -9
- package/src/commands/cloud.js +56 -5
- package/src/commands/consolidate.js +49 -10
- package/src/commands/diff.js +2 -2
- package/src/commands/doctor.js +3 -3
- package/src/commands/forget.js +100 -0
- package/src/commands/push.js +164 -161
- package/src/commands/recall.js +42 -0
- package/src/commands/restore.js +32 -44
- package/src/commands/resume.js +15 -164
- package/src/commands/session.js +51 -9
- package/src/commands/snapshot.js +6 -7
- package/src/commands/status.js +23 -1
- package/src/commands/upgrade.js +13 -11
- package/src/commands/validate.js +16 -0
- package/src/commands/view.js +2 -2
- package/src/commands/why.js +4 -3
- package/src/config.js +9 -40
- package/src/context/capture.js +135 -33
- package/src/context/handoffs.js +72 -0
- package/src/events/summary.js +122 -0
- package/src/integrations/setup.js +88 -0
- package/src/mcp.js +151 -283
- package/src/memory/lexical-index.js +65 -0
- package/src/memory/repository.js +16 -0
- package/src/memory/scope.js +65 -0
- package/src/memory/search.js +598 -0
- package/src/memory/store.js +141 -0
- package/src/providers/index.js +182 -51
- package/src/providers/restore.js +5 -1
- package/src/security/encryption.js +34 -60
- package/src/security/files.js +155 -0
- package/src/session/brief.js +47 -0
- package/src/session/inject.js +12 -6
- package/src/session/lock.js +39 -118
- package/src/session/migrations.js +6 -0
- package/src/session/render.js +34 -4
- package/src/session/state.js +305 -34
- package/src/work/cli.js +64 -0
- package/src/work/errors.js +8 -0
- package/src/work/server.js +28 -0
- package/src/work/setup.js +96 -0
- package/src/work/store.js +340 -0
- package/src/work/ui/app.js +205 -0
- package/src/work/ui/index.html +30 -0
- package/src/work/ui/style.css +3 -0
- package/src/work/view.js +93 -0
- package/src/workspace/tracker.js +84 -332
- package/supabase/migrations/202609050001_backup_versions.sql +50 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
|
|
6
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
7
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
8
|
+
import { readSafeFile, writeSafeFile } from '../security/files.js';
|
|
9
|
+
import { withSessionLock } from '../session/lock.js';
|
|
10
|
+
|
|
11
|
+
const serverPath = fileURLToPath(new URL('../mcp.js', import.meta.url));
|
|
12
|
+
const clients = {
|
|
13
|
+
claude: { marker: '.claude', config: '.mcp.json' },
|
|
14
|
+
codex: { marker: '.codex', config: '.codex/config.toml' },
|
|
15
|
+
cursor: { marker: '.cursor', config: '.cursor/mcp.json' },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export async function verifyServer(project) {
|
|
19
|
+
const transport = new StdioClientTransport({
|
|
20
|
+
command: process.execPath,
|
|
21
|
+
args: [serverPath],
|
|
22
|
+
env: { ...process.env, MEMOIR_PROJECT_ROOT: project, DO_NOT_TRACK: '1' },
|
|
23
|
+
stderr: 'pipe',
|
|
24
|
+
});
|
|
25
|
+
const client = new Client({ name: 'memoir-setup-check', version: '1.0.0' });
|
|
26
|
+
try {
|
|
27
|
+
await client.connect(transport, { timeout: 10000 });
|
|
28
|
+
const { tools } = await client.listTools();
|
|
29
|
+
for (const name of ['memoir_remember', 'memoir_recall', 'memoir_session']) {
|
|
30
|
+
if (!tools.some(t => t.name === name)) throw new Error('Server is missing required tool: ' + name);
|
|
31
|
+
}
|
|
32
|
+
return true;
|
|
33
|
+
} finally { await client.close(); }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Project configuration supplies an explicit scope even for clients that
|
|
37
|
+
// launch all stdio servers with a home-directory working directory.
|
|
38
|
+
export async function setupIntegrations({ project = process.cwd(), tool = 'auto', check = true } = {}) {
|
|
39
|
+
project = await fs.realpath(path.resolve(project));
|
|
40
|
+
const selected = tool === 'all' ? Object.keys(clients) : tool === 'auto'
|
|
41
|
+
? Object.keys(clients).filter(name => fs.existsSync(path.join(os.homedir(), clients[name].marker)) || fs.existsSync(path.join(project, clients[name].config)))
|
|
42
|
+
: tool.split(',').map(s => s.trim());
|
|
43
|
+
if (selected.some(name => !clients[name])) throw new Error('Supported clients: claude, codex, cursor, all, auto');
|
|
44
|
+
if (check && selected.length) await verifyServer(project);
|
|
45
|
+
const results = [];
|
|
46
|
+
for (const name of selected) {
|
|
47
|
+
const relative = clients[name].config;
|
|
48
|
+
const entry = { command: process.execPath, args: [serverPath], env: { MEMOIR_PROJECT_ROOT: project } };
|
|
49
|
+
await withSessionLock(path.join(project, '.memoir-setup.lock'), async () => {
|
|
50
|
+
let original = '';
|
|
51
|
+
try { original = (await readSafeFile(project, relative)).toString('utf8'); }
|
|
52
|
+
catch (err) { if (err.code !== 'ENOENT') throw err; }
|
|
53
|
+
const toml = name === 'codex';
|
|
54
|
+
const parsed = original.trim() ? (toml ? parseToml(original) : JSON.parse(original)) : {};
|
|
55
|
+
const key = toml ? 'mcp_servers' : 'mcpServers';
|
|
56
|
+
const existing = parsed[key]?.memoir;
|
|
57
|
+
if (existing) {
|
|
58
|
+
const matches = existing.command === entry.command && JSON.stringify(existing.args) === JSON.stringify(entry.args) && existing.env?.MEMOIR_PROJECT_ROOT === project;
|
|
59
|
+
results.push({ tool: name, path: path.join(project, relative), status: matches ? 'ready' : 'existing-configuration', verified: matches && check });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
let updated;
|
|
63
|
+
if (toml) {
|
|
64
|
+
// Preserve comments and unrelated formatting. Reject configurations
|
|
65
|
+
// whose inline table cannot be extended rather than rewriting them.
|
|
66
|
+
updated = original.trimEnd() + '\n\n' + stringifyToml({ mcp_servers: { memoir: entry } });
|
|
67
|
+
parseToml(updated);
|
|
68
|
+
} else {
|
|
69
|
+
if (parsed[key] != null && (typeof parsed[key] !== 'object' || Array.isArray(parsed[key]))) throw new Error('Invalid MCP server configuration');
|
|
70
|
+
parsed[key] = { ...(parsed[key] || {}), memoir: entry };
|
|
71
|
+
updated = JSON.stringify(parsed, null, 2) + '\n';
|
|
72
|
+
}
|
|
73
|
+
if (original) await writeSafeFile(project, relative + '.memoir-backup', original);
|
|
74
|
+
await writeSafeFile(project, relative, updated);
|
|
75
|
+
results.push({ tool: name, path: path.join(project, relative), status: 'configured', verified: check });
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return results;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function setupCommand(options = {}) {
|
|
82
|
+
const results = await setupIntegrations(options);
|
|
83
|
+
if (!results.length) console.log('No supported clients detected. Use memoir setup --tool claude,codex,cursor to select them.');
|
|
84
|
+
for (const result of results) console.log(result.tool + ': ' + result.status + ' — ' + result.path);
|
|
85
|
+
if (results.some(r => r.status === 'existing-configuration')) console.log('Existing memoir entries were preserved. Review their command and project scope before using them.');
|
|
86
|
+
if (results.length) console.log('The Memoir server passed its startup check. Restart the client and approve/trust its project MCP configuration when prompted.');
|
|
87
|
+
return results;
|
|
88
|
+
}
|
package/src/mcp.js
CHANGED
|
@@ -24,122 +24,27 @@ import {
|
|
|
24
24
|
addQuestion,
|
|
25
25
|
getMachineId,
|
|
26
26
|
} from './session/state.js';
|
|
27
|
+
import { appendEvent } from './events/log.js';
|
|
27
28
|
import { renderSession } from './session/render.js';
|
|
28
29
|
import { injectInto, detectAvailableTargets } from './session/inject.js';
|
|
29
30
|
import { findDecisions } from './commands/why.js';
|
|
31
|
+
import { matchDecisions, hideDecision } from './session/state.js';
|
|
32
|
+
import { readMemoryFiles, searchMemories, formatRecallResults, withFrontmatterLists } from './memory/search.js';
|
|
33
|
+
import { buildResumeBrief, formatResumeBrief } from './session/brief.js';
|
|
34
|
+
import { rememberMemory, memoryRoot, forgetStoredMemory, readStoredMemories } from './memory/store.js';
|
|
35
|
+
import { memoryFilename, relativeFile, readSafeFile } from './security/files.js';
|
|
36
|
+
import { visibleMemory, sessionView } from './memory/scope.js';
|
|
37
|
+
import { parseFrontmatter } from './commands/validate.js';
|
|
30
38
|
import { capture as track } from './telemetry.js';
|
|
39
|
+
import { createRequire } from 'module';
|
|
31
40
|
|
|
32
41
|
const home = os.homedir();
|
|
42
|
+
const { version: VERSION } = createRequire(import.meta.url)('../package.json');
|
|
33
43
|
|
|
34
44
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
*/
|
|
39
|
-
async function readMemoryFiles(adapter) {
|
|
40
|
-
const files = [];
|
|
41
|
-
|
|
42
|
-
if (adapter.customExtract) {
|
|
43
|
-
for (const file of adapter.files) {
|
|
44
|
-
const filePath = path.join(adapter.source, file);
|
|
45
|
-
if (await fs.pathExists(filePath)) {
|
|
46
|
-
try {
|
|
47
|
-
const content = await fs.readFile(filePath, 'utf8');
|
|
48
|
-
files.push({ path: file, content, tool: adapter.name });
|
|
49
|
-
} catch {}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
return files;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
if (!(await fs.pathExists(adapter.source))) return files;
|
|
56
|
-
|
|
57
|
-
const walk = async (dir, prefix = '') => {
|
|
58
|
-
let entries;
|
|
59
|
-
try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
60
|
-
|
|
61
|
-
for (const entry of entries) {
|
|
62
|
-
const fullPath = path.join(dir, entry.name);
|
|
63
|
-
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
64
|
-
|
|
65
|
-
if (entry.isDirectory()) {
|
|
66
|
-
if (adapter.filter(fullPath)) {
|
|
67
|
-
await walk(fullPath, relPath);
|
|
68
|
-
}
|
|
69
|
-
} else if (entry.name.endsWith('.md') || entry.name.endsWith('.json') || entry.name.endsWith('.yml') || entry.name.endsWith('.yaml')) {
|
|
70
|
-
if (adapter.filter(fullPath)) {
|
|
71
|
-
try {
|
|
72
|
-
const content = await fs.readFile(fullPath, 'utf8');
|
|
73
|
-
files.push({ path: relPath, content, tool: adapter.name });
|
|
74
|
-
} catch {}
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
await walk(adapter.source);
|
|
81
|
-
return files;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Search across all memory files for a query (case-insensitive keyword match)
|
|
86
|
-
*/
|
|
87
|
-
async function searchMemories(query) {
|
|
88
|
-
const results = [];
|
|
89
|
-
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
90
|
-
|
|
91
|
-
for (const adapter of adapters) {
|
|
92
|
-
const files = await readMemoryFiles(adapter);
|
|
93
|
-
for (const file of files) {
|
|
94
|
-
const lower = file.content.toLowerCase();
|
|
95
|
-
const score = terms.reduce((s, t) => s + (lower.includes(t) ? 1 : 0), 0);
|
|
96
|
-
if (score > 0) {
|
|
97
|
-
results.push({ ...file, score, relevance: score / terms.length });
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Also search per-project AI config files
|
|
103
|
-
const projectFiles = ['CLAUDE.md', 'GEMINI.md', 'CHATGPT.md', '.cursorrules', '.windsurfrules', '.clinerules'];
|
|
104
|
-
const skipDirs = new Set(['node_modules', '.git', '.next', '.vercel', 'dist', 'build', '__pycache__', '.venv', 'venv', '.cache', 'Library', '.Trash', 'Applications', 'Downloads']);
|
|
105
|
-
|
|
106
|
-
const scanProjects = async (dir, depth = 0) => {
|
|
107
|
-
if (depth > 3) return;
|
|
108
|
-
let entries;
|
|
109
|
-
try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
110
|
-
|
|
111
|
-
for (const file of projectFiles) {
|
|
112
|
-
const filePath = path.join(dir, file);
|
|
113
|
-
if (await fs.pathExists(filePath)) {
|
|
114
|
-
try {
|
|
115
|
-
const content = await fs.readFile(filePath, 'utf8');
|
|
116
|
-
const lower = content.toLowerCase();
|
|
117
|
-
const score = terms.reduce((s, t) => s + (lower.includes(t) ? 1 : 0), 0);
|
|
118
|
-
if (score > 0) {
|
|
119
|
-
results.push({
|
|
120
|
-
path: `${path.basename(dir)}/${file}`,
|
|
121
|
-
content,
|
|
122
|
-
tool: `Project: ${path.basename(dir)}`,
|
|
123
|
-
score,
|
|
124
|
-
relevance: score / terms.length
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
} catch {}
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
for (const entry of entries) {
|
|
132
|
-
if (!entry.isDirectory()) continue;
|
|
133
|
-
if (entry.name.startsWith('.') && entry.name !== '.github') continue;
|
|
134
|
-
if (skipDirs.has(entry.name)) continue;
|
|
135
|
-
await scanProjects(path.join(dir, entry.name), depth + 1);
|
|
136
|
-
}
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
await scanProjects(home);
|
|
140
|
-
|
|
141
|
-
return results.sort((a, b) => b.score - a.score);
|
|
142
|
-
}
|
|
45
|
+
// readMemoryFiles / searchMemories live in ./memory/search.js (cached,
|
|
46
|
+
// field-weighted, passage-returning) so the CLI's `memoir recall` and tests
|
|
47
|
+
// share one implementation with this server.
|
|
143
48
|
|
|
144
49
|
/**
|
|
145
50
|
* Get list of detected tools with status
|
|
@@ -164,7 +69,7 @@ async function getDetectedTools() {
|
|
|
164
69
|
|
|
165
70
|
const server = new McpServer({
|
|
166
71
|
name: 'memoir',
|
|
167
|
-
version:
|
|
72
|
+
version: VERSION,
|
|
168
73
|
}, {
|
|
169
74
|
capabilities: {
|
|
170
75
|
tools: {},
|
|
@@ -182,9 +87,17 @@ const _registerTool = server.tool.bind(server);
|
|
|
182
87
|
server.tool = (name, ...rest) => {
|
|
183
88
|
const handler = rest[rest.length - 1];
|
|
184
89
|
if (typeof handler === 'function') {
|
|
185
|
-
rest[rest.length - 1] = (...args) => {
|
|
186
|
-
|
|
187
|
-
|
|
90
|
+
rest[rest.length - 1] = async (...args) => {
|
|
91
|
+
const started = Date.now();
|
|
92
|
+
let success = false;
|
|
93
|
+
try {
|
|
94
|
+
const result = await handler(...args);
|
|
95
|
+
success = result?.isError !== true;
|
|
96
|
+
return result;
|
|
97
|
+
} finally {
|
|
98
|
+
try { track('mcp_tool_used', { tool: name, success }); } catch {}
|
|
99
|
+
appendEvent('mcp_tool_used', { tool: name, success, ms: Date.now() - started }).catch(() => {});
|
|
100
|
+
}
|
|
188
101
|
};
|
|
189
102
|
}
|
|
190
103
|
return _registerTool(name, ...rest);
|
|
@@ -236,113 +149,39 @@ server.tool(
|
|
|
236
149
|
|
|
237
150
|
server.tool(
|
|
238
151
|
'memoir_recall',
|
|
239
|
-
'Search
|
|
240
|
-
{
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// Return top 10 results with content
|
|
251
|
-
const top = results.slice(0, 10);
|
|
252
|
-
const output = top.map((r, i) => {
|
|
253
|
-
const preview = r.content.length > 500 ? r.content.slice(0, 500) + '...' : r.content;
|
|
254
|
-
return [
|
|
255
|
-
`── ${i + 1}. ${r.tool} / ${r.path} (relevance: ${Math.round(r.relevance * 100)}%) ──`,
|
|
256
|
-
preview,
|
|
257
|
-
].join('\n');
|
|
258
|
-
}).join('\n\n');
|
|
259
|
-
|
|
260
|
-
return {
|
|
261
|
-
content: [{
|
|
262
|
-
type: 'text',
|
|
263
|
-
text: `Found ${results.length} memories matching "${query}":\n\n${output}`
|
|
264
|
-
}]
|
|
265
|
-
};
|
|
152
|
+
'Search memories and decisions for the current or selected project, including shared memories. Hidden, deleted, superseded, and unrelated project records are excluded. Returns the matched passages (not file headers) from the best files, ranked by how well each file covers all your terms — aliases, names, and descriptions weigh more than body prose. Use this before answering questions about a project, a past decision, or a tool. Use memoir_read to see a whole file.',
|
|
153
|
+
{
|
|
154
|
+
query: z.string().describe('Search query — keywords or topic to find in memories. Multi-word queries rank files that match every word highest.'),
|
|
155
|
+
limit: z.number().int().min(1).max(30).optional().describe('Max results to return (default 10)'),
|
|
156
|
+
project: z.string().optional().describe('Project directory or identity. Defaults to the current working project; shared memories are also included.'),
|
|
157
|
+
budget: z.number().int().min(256).max(16000).optional().describe('Total character budget for returned evidence (default 6000).'),
|
|
158
|
+
},
|
|
159
|
+
async ({ query, limit, project, budget }) => {
|
|
160
|
+
const res = await searchMemories(query, { limit: limit || 10, project, budget });
|
|
161
|
+
return { content: [{ type: 'text', text: formatRecallResults(query, res) }] };
|
|
266
162
|
}
|
|
267
163
|
);
|
|
268
164
|
|
|
269
165
|
server.tool(
|
|
270
166
|
'memoir_remember',
|
|
271
|
-
'Save a memory
|
|
167
|
+
'Save a durable, project-scoped memory in Memoir. Returns a stable ID and revision. Use this to persist important context, decisions, or facts for future sessions. Give the file frontmatter (type, name, description) and ALWAYS pass aliases — the other names, nicknames, or phrasings someone might search for this under (e.g. a "vertical swipe feed" surface should carry aliases like "tiktok", "reels", "/tape"). Recall weights aliases heaviest; a memory without them can only be found by the exact words it happens to use.',
|
|
272
168
|
{
|
|
273
|
-
content: z.string().describe('The memory content to save (markdown
|
|
169
|
+
content: z.string().describe('The memory content to save (markdown, ideally with --- frontmatter: type, name, description)'),
|
|
274
170
|
filename: z.string().describe('Filename for the memory (e.g. "auth-setup.md", "project-goals.md")'),
|
|
275
|
-
|
|
276
|
-
|
|
171
|
+
aliases: z.array(z.string()).optional().describe('Other names/phrasings this memory should be findable under. Written into frontmatter `aliases:`. Strongly recommended.'),
|
|
172
|
+
tags: z.array(z.string()).optional().describe('Topic tags. Written into frontmatter `tags:`.'),
|
|
173
|
+
tool: z.string().optional().describe('Originating tool, recorded as provenance. Memory is stored in Memoir and is readable across clients.'),
|
|
174
|
+
scope: z.enum(['project', 'shared']).optional().describe('Default project scope; shared is for deliberately reusable preferences across projects.'),
|
|
175
|
+
project: z.string().optional().describe('Project directory or identity for this memory. Defaults to the working project; does not modify project files.'),
|
|
277
176
|
},
|
|
278
|
-
async ({ content, filename, tool, project }) => {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// Default to CLAUDE.md for project-level memories.
|
|
287
|
-
// Guards mirror the global branch below and memoir_read above:
|
|
288
|
-
// model-supplied filename must be a bare markdown name — no
|
|
289
|
-
// separators, no traversal — and must resolve inside the project
|
|
290
|
-
// dir. Without this, filename:".zshrc" appends to a shell rc
|
|
291
|
-
// (code execution on next shell) and "package.json" corrupts
|
|
292
|
-
// real files.
|
|
293
|
-
let targetFile = filename || 'CLAUDE.md';
|
|
294
|
-
if (!targetFile.endsWith('.md')) targetFile += '.md';
|
|
295
|
-
if (targetFile.includes('/') || targetFile.includes('\\') || targetFile.includes('..')) {
|
|
296
|
-
return { content: [{ type: 'text', text: `Invalid filename: ${filename} (must be a bare .md name)` }] };
|
|
297
|
-
}
|
|
298
|
-
const projBase = path.resolve(projectDir);
|
|
299
|
-
const targetPath = path.resolve(projBase, targetFile);
|
|
300
|
-
if (!targetPath.startsWith(projBase + path.sep)) {
|
|
301
|
-
return { content: [{ type: 'text', text: `Invalid filename: ${filename}` }] };
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// Append to existing file or create new
|
|
305
|
-
if (await fs.pathExists(targetPath)) {
|
|
306
|
-
const existing = await fs.readFile(targetPath, 'utf8');
|
|
307
|
-
await fs.writeFile(targetPath, existing + '\n\n' + content);
|
|
308
|
-
} else {
|
|
309
|
-
await fs.writeFile(targetPath, content);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
return {
|
|
313
|
-
content: [{ type: 'text', text: `Saved to ${targetPath}` }]
|
|
314
|
-
};
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// Global tool memory
|
|
318
|
-
const toolKey = (tool || 'claude').toLowerCase();
|
|
319
|
-
|
|
320
|
-
// Find the right directory for the tool
|
|
321
|
-
let targetDir;
|
|
322
|
-
if (toolKey === 'claude') {
|
|
323
|
-
// Save to Claude's memory system
|
|
324
|
-
const claudeMemDir = path.join(home, '.claude', 'projects', '-Users-' + path.basename(home), 'memory');
|
|
325
|
-
await fs.ensureDir(claudeMemDir);
|
|
326
|
-
targetDir = claudeMemDir;
|
|
327
|
-
} else if (toolKey === 'gemini') {
|
|
328
|
-
targetDir = path.join(home, '.gemini');
|
|
329
|
-
} else if (toolKey === 'cursor') {
|
|
330
|
-
const cursorDir = process.platform === 'win32'
|
|
331
|
-
? path.join(process.env.APPDATA || '', 'Cursor', 'User', 'rules')
|
|
332
|
-
: path.join(home, 'Library', 'Application Support', 'Cursor', 'User', 'rules');
|
|
333
|
-
await fs.ensureDir(cursorDir);
|
|
334
|
-
targetDir = cursorDir;
|
|
335
|
-
} else {
|
|
336
|
-
return { content: [{ type: 'text', text: `Unsupported tool for writing: ${toolKey}. Supported: claude, gemini, cursor` }] };
|
|
177
|
+
async ({ content, filename, aliases, tags, tool, project, scope }) => {
|
|
178
|
+
try {
|
|
179
|
+
memoryFilename(filename);
|
|
180
|
+
const saved = await rememberMemory({ content, filename, aliases, tags, tool, project, scope });
|
|
181
|
+
return { content: [{ type: 'text', text: 'Memory saved: ' + saved.id + ' (revision ' + saved.revision + ', project ' + saved.project + '). Read with tool "memoir" and filepath "' + saved.path + '".' }] };
|
|
182
|
+
} catch (err) {
|
|
183
|
+
return { isError: true, content: [{ type: 'text', text: err.message }] };
|
|
337
184
|
}
|
|
338
|
-
|
|
339
|
-
if (!filename.endsWith('.md')) filename += '.md';
|
|
340
|
-
const targetPath = path.join(targetDir, filename);
|
|
341
|
-
await fs.writeFile(targetPath, content);
|
|
342
|
-
|
|
343
|
-
return {
|
|
344
|
-
content: [{ type: 'text', text: `Memory saved to ${targetPath}` }]
|
|
345
|
-
};
|
|
346
185
|
}
|
|
347
186
|
);
|
|
348
187
|
|
|
@@ -354,6 +193,11 @@ server.tool(
|
|
|
354
193
|
},
|
|
355
194
|
async ({ tool }) => {
|
|
356
195
|
const allFiles = [];
|
|
196
|
+
if (!tool || tool.toLowerCase() === 'memoir') {
|
|
197
|
+
for (const file of await readStoredMemories()) {
|
|
198
|
+
if (visibleMemory(parseFrontmatter(file.content).fields)) allFiles.push({ tool: 'Memoir', icon: '🧠', path: file.path, size: file.content.length });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
357
201
|
|
|
358
202
|
for (const adapter of adapters) {
|
|
359
203
|
if (tool) {
|
|
@@ -363,6 +207,7 @@ server.tool(
|
|
|
363
207
|
|
|
364
208
|
const files = await readMemoryFiles(adapter);
|
|
365
209
|
for (const f of files) {
|
|
210
|
+
if (!visibleMemory(f)) continue;
|
|
366
211
|
allFiles.push({ tool: adapter.name, icon: adapter.icon, path: f.path, size: f.content.length });
|
|
367
212
|
}
|
|
368
213
|
}
|
|
@@ -399,35 +244,35 @@ server.tool(
|
|
|
399
244
|
{
|
|
400
245
|
tool: z.string().describe('Tool name: "claude", "gemini", "cursor", etc.'),
|
|
401
246
|
filepath: z.string().describe('Relative file path within the tool\'s memory directory'),
|
|
247
|
+
project: z.string().optional().describe('Project directory or identity; defaults to the working project.'),
|
|
402
248
|
},
|
|
403
|
-
async ({ tool, filepath }) => {
|
|
404
|
-
const toolKey = tool.toLowerCase();
|
|
405
|
-
const adapter = adapters.find(a => a.name.toLowerCase().includes(toolKey));
|
|
406
|
-
|
|
407
|
-
if (!adapter) {
|
|
408
|
-
return { content: [{ type: 'text', text: `Unknown tool: ${tool}. Available: ${adapters.map(a => a.name).join(', ')}` }] };
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// Containment: filepath comes from the model, and the model reads
|
|
412
|
-
// attacker-influenceable text all day. Without this, "../.ssh/id_rsa"
|
|
413
|
-
// resolves outside the adapter dir and the file is returned verbatim.
|
|
414
|
-
const base = path.resolve(adapter.source);
|
|
415
|
-
const fullPath = path.resolve(base, filepath);
|
|
416
|
-
if (fullPath !== base && !fullPath.startsWith(base + path.sep)) {
|
|
417
|
-
return { content: [{ type: 'text', text: `Invalid path: ${filepath} (must stay inside ${adapter.name}'s directory)` }] };
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
if (!(await fs.pathExists(fullPath))) {
|
|
421
|
-
return { content: [{ type: 'text', text: `File not found: ${filepath} in ${adapter.name}` }] };
|
|
422
|
-
}
|
|
423
|
-
|
|
249
|
+
async ({ tool, filepath, project }) => {
|
|
424
250
|
try {
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
251
|
+
const rel = relativeFile(filepath);
|
|
252
|
+
const toolKey = tool.toLowerCase();
|
|
253
|
+
let root, name;
|
|
254
|
+
if (toolKey === 'memoir') {
|
|
255
|
+
if (!/^[a-f0-9]{64}\.md$/.test(rel)) throw new Error('Use the memory ID returned by remember or recall.');
|
|
256
|
+
root = memoryRoot;
|
|
257
|
+
name = 'Memoir';
|
|
258
|
+
} else {
|
|
259
|
+
const adapter = adapters.find(a => a.name.toLowerCase().includes(toolKey));
|
|
260
|
+
if (!adapter) throw new Error('Unknown memory tool');
|
|
261
|
+
const permitted = adapter.customExtract ? adapter.files.includes(rel) : adapter.filter(path.join(adapter.source, rel));
|
|
262
|
+
if (!permitted) throw new Error('This file is excluded from the memory adapter');
|
|
263
|
+
root = adapter.source;
|
|
264
|
+
name = adapter.name;
|
|
265
|
+
}
|
|
266
|
+
let content = (await readSafeFile(root, rel)).toString('utf8');
|
|
267
|
+
const { fields } = parseFrontmatter(content);
|
|
268
|
+
if (toolKey.includes('claude')) fields.claudeProjectKey = rel.match(/^projects\/([^/]+)\//)?.[1];
|
|
269
|
+
if (!visibleMemory(fields, { project })) throw new Error('This memory is hidden, expired, superseded, or belongs to another project.');
|
|
270
|
+
// Generated session blocks are projections; authoritative decisions are
|
|
271
|
+
// retrieved from state so old projections cannot bypass a deletion.
|
|
272
|
+
content = content.replace(/<!--\s*memoir:session-block[^>]*-->[\s\S]*?<!--\s*\/memoir:session-block\s*-->/g, '');
|
|
273
|
+
return { content: [{ type: 'text', text: '── ' + name + ' / ' + rel + ' ──\n\n' + content }] };
|
|
429
274
|
} catch (err) {
|
|
430
|
-
return { content: [{ type: 'text', text:
|
|
275
|
+
return { isError: true, content: [{ type: 'text', text: err.message }] };
|
|
431
276
|
}
|
|
432
277
|
}
|
|
433
278
|
);
|
|
@@ -459,47 +304,19 @@ server.tool(
|
|
|
459
304
|
'memoir_consolidate',
|
|
460
305
|
'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.',
|
|
461
306
|
{
|
|
462
|
-
smart: z.boolean().optional().describe('
|
|
307
|
+
smart: z.boolean().optional().describe('Compatibility option. This MCP tool performs local analysis only; use memoir consolidate --smart in the CLI for external model analysis.'),
|
|
463
308
|
},
|
|
464
309
|
async ({ smart }) => {
|
|
465
|
-
// Collect all memory files
|
|
466
310
|
const allFiles = [];
|
|
311
|
+
for (const file of await readStoredMemories()) {
|
|
312
|
+
if (visibleMemory(parseFrontmatter(file.content).fields)) allFiles.push({
|
|
313
|
+
...file, size: file.content.length, mtime: Date.parse(parseFrontmatter(file.content).fields.updated) || 0,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
467
316
|
for (const adapter of adapters) {
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
for (const file of adapter.files) {
|
|
471
|
-
const filePath = path.join(adapter.source, file);
|
|
472
|
-
if (await fs.pathExists(filePath)) {
|
|
473
|
-
try {
|
|
474
|
-
const content = await fs.readFile(filePath, 'utf8');
|
|
475
|
-
const stat = await fs.stat(filePath);
|
|
476
|
-
files.push({ path: file, fullPath: filePath, content, tool: adapter.name, icon: adapter.icon, mtime: stat.mtimeMs, size: content.length });
|
|
477
|
-
} catch {}
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
} else if (await fs.pathExists(adapter.source)) {
|
|
481
|
-
const walk = async (dir, prefix = '') => {
|
|
482
|
-
let entries;
|
|
483
|
-
try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
484
|
-
for (const entry of entries) {
|
|
485
|
-
const fullPath = path.join(dir, entry.name);
|
|
486
|
-
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
487
|
-
if (entry.isDirectory()) {
|
|
488
|
-
if (adapter.filter(fullPath)) await walk(fullPath, relPath);
|
|
489
|
-
} else if (/\.(md|json|yml|yaml)$/.test(entry.name)) {
|
|
490
|
-
if (adapter.filter(fullPath)) {
|
|
491
|
-
try {
|
|
492
|
-
const content = await fs.readFile(fullPath, 'utf8');
|
|
493
|
-
const stat = await fs.stat(fullPath);
|
|
494
|
-
files.push({ path: relPath, fullPath, content, tool: adapter.name, icon: adapter.icon, mtime: stat.mtimeMs, size: content.length });
|
|
495
|
-
} catch {}
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
};
|
|
500
|
-
await walk(adapter.source);
|
|
317
|
+
for (const doc of await readMemoryFiles(adapter)) {
|
|
318
|
+
if (visibleMemory(doc)) allFiles.push({ ...doc, size: doc.content.length, mtime: doc.mtimeMs });
|
|
501
319
|
}
|
|
502
|
-
allFiles.push(...files);
|
|
503
320
|
}
|
|
504
321
|
|
|
505
322
|
if (allFiles.length === 0) {
|
|
@@ -590,36 +407,39 @@ async function refreshPinnedBlock() {
|
|
|
590
407
|
|
|
591
408
|
server.tool(
|
|
592
409
|
'memoir_set_goal',
|
|
593
|
-
'Set the current goal for this session. Use when the user states what they want to work on, or when a clear focus emerges.
|
|
410
|
+
'Set the current goal for this session. Use when the user states what they want to work on, or when a clear focus emerges. Available through the scoped session and resume tools.',
|
|
594
411
|
{ text: z.string().describe('The goal, one short sentence') },
|
|
595
412
|
async ({ text }) => {
|
|
596
|
-
await addGoal(text);
|
|
413
|
+
const state = await addGoal(text);
|
|
597
414
|
await refreshPinnedBlock();
|
|
598
|
-
|
|
415
|
+
const replaced = (state.replacedGoals || []).map((g) => `"${g.text}"`);
|
|
416
|
+
return { content: [{ type: 'text', text: `Goal set: ${text}${replaced.length ? `\nGoals list is full (3) — replaced: ${replaced.join('; ')}. Re-set it with memoir_set_goal if that was wrong.` : ''}` }] };
|
|
599
417
|
}
|
|
600
418
|
);
|
|
601
419
|
|
|
602
420
|
server.tool(
|
|
603
421
|
'memoir_add_next',
|
|
604
|
-
'Add a next action to the current session. Use when the user decides on a concrete next step, or when you finish something and the logical next move is clear.',
|
|
422
|
+
'Add a next action to the current session. Use when the user decides on a concrete next step, or when you finish something and the logical next move is clear. The list holds 8; when it is full the oldest item is PARKED (still shown in the pinned block, still completable), never dropped — the response names anything parked.',
|
|
605
423
|
{ text: z.string().describe('The action, one short imperative sentence') },
|
|
606
424
|
async ({ text }) => {
|
|
607
|
-
await addNext(text);
|
|
425
|
+
const state = await addNext(text);
|
|
608
426
|
await refreshPinnedBlock();
|
|
609
|
-
|
|
427
|
+
const parked = (state.justParked || []).map((p) => `"${p.text}"`);
|
|
428
|
+
return { content: [{ type: 'text', text: `Next: ${text}${parked.length ? `\nList was full — parked (still open, still in the block): ${parked.join('; ')}` : ''}` }] };
|
|
610
429
|
}
|
|
611
430
|
);
|
|
612
431
|
|
|
613
432
|
server.tool(
|
|
614
433
|
'memoir_complete_next',
|
|
615
|
-
'Mark a next action as complete (removes it from the pinned list). Match by substring — pass the relevant keywords, not the whole text.',
|
|
434
|
+
'Mark a next action as complete (removes it from the pinned list, parked items included). Match by substring — pass the relevant keywords, not the whole text.',
|
|
616
435
|
{ match: z.string().describe('Substring to match against existing next actions') },
|
|
617
436
|
async ({ match }) => {
|
|
437
|
+
const count = (st) => st.current.next_actions.length + (st.current.parked_actions || []).length;
|
|
618
438
|
const before = await readSession();
|
|
619
|
-
const beforeCount = before
|
|
439
|
+
const beforeCount = count(before);
|
|
620
440
|
await completeNext(match);
|
|
621
441
|
const after = await readSession();
|
|
622
|
-
const removed = beforeCount - after
|
|
442
|
+
const removed = beforeCount - count(after);
|
|
623
443
|
await refreshPinnedBlock();
|
|
624
444
|
return {
|
|
625
445
|
content: [{
|
|
@@ -666,12 +486,12 @@ server.tool(
|
|
|
666
486
|
'Show the current session state — goals, next actions, open questions, recent decisions, recent sessions across machines. Use this to catch up at the start of a session, or when you need to orient yourself on what was decided.',
|
|
667
487
|
{},
|
|
668
488
|
async () => {
|
|
669
|
-
const state = await readSession();
|
|
489
|
+
const state = sessionView(await readSession());
|
|
670
490
|
const machine = await getMachineId();
|
|
671
491
|
const goals = state.current.goals.map(g => `- ${g.text}`).join('\n') || '(none)';
|
|
672
492
|
const nexts = state.current.next_actions.map(n => `- [ ] ${n.text}`).join('\n') || '(none)';
|
|
673
493
|
const questions = state.current.open_questions.map(q => `- ${q.text}`).join('\n') || '(none)';
|
|
674
|
-
const decisions = state.current.decisions.slice(0, 5).map(d => {
|
|
494
|
+
const decisions = state.current.decisions.filter(d => visibleMemory(d)).slice(0, 5).map(d => {
|
|
675
495
|
let line = `- ${d.text}`;
|
|
676
496
|
if (d.why) line += ` — *${d.why}*`;
|
|
677
497
|
return line;
|
|
@@ -739,6 +559,54 @@ server.tool(
|
|
|
739
559
|
}
|
|
740
560
|
);
|
|
741
561
|
|
|
562
|
+
server.tool(
|
|
563
|
+
'memoir_forget',
|
|
564
|
+
'Forget a recorded decision — permanently hides it from the pinned block, memoir_why, and every synced machine (an absolute tombstone; there is no un-forget). Use when the user says a decision is wrong, obsolete, or was captured by mistake, or when a secret leaked into a decision. Refuses to act if the text matches more than one decision — call again with a more specific string. Pass purge=true to also redact the text in place (for secrets).',
|
|
565
|
+
{
|
|
566
|
+
text: z.string().describe('A canonical memory ID, or decision text/substr uniquely identifying a decision'),
|
|
567
|
+
purge: z.boolean().optional().describe('Also redact the text/why/rejected in place, keeping only a hash. For leaked secrets. Default false.'),
|
|
568
|
+
},
|
|
569
|
+
async ({ text, purge }) => {
|
|
570
|
+
if (/^[a-f0-9]{64}$/.test(text)) {
|
|
571
|
+
try {
|
|
572
|
+
const result = await forgetStoredMemory(text, { purge: !!purge });
|
|
573
|
+
return { content: [{ type: 'text', text: 'Forgotten memory ' + result.id + '. The deletion will sync on push. ' + (purge ? 'Local record and revision history purged; older remote backups and Git history are not erased.' : '') }] };
|
|
574
|
+
} catch (err) { return { isError: true, content: [{ type: 'text', text: err.message }] }; }
|
|
575
|
+
}
|
|
576
|
+
const state = await readSession();
|
|
577
|
+
const matches = matchDecisions(state, text);
|
|
578
|
+
if (matches.length === 0) {
|
|
579
|
+
return { content: [{ type: 'text', text: `No visible decision matches "${text}". Nothing forgotten.` }] };
|
|
580
|
+
}
|
|
581
|
+
if (matches.length > 1) {
|
|
582
|
+
const list = matches.map(d => `● ${d.text}`).join('\n');
|
|
583
|
+
return { content: [{ type: 'text', text: `"${text}" matches ${matches.length} decisions — forgetting is permanent, so nothing was changed. Call again with a string unique to one of:\n\n${list}` }] };
|
|
584
|
+
}
|
|
585
|
+
const res = await hideDecision(matches[0].text, { purge: !!purge });
|
|
586
|
+
if (!res.hidden) {
|
|
587
|
+
return { content: [{ type: 'text', text: `Nothing changed — the decision may already have been forgotten.` }] };
|
|
588
|
+
}
|
|
589
|
+
// Re-render the pinned block so the next session no longer loads it.
|
|
590
|
+
try {
|
|
591
|
+
const rendered = renderSession(res.state);
|
|
592
|
+
for (const target of Object.values(detectAvailableTargets())) {
|
|
593
|
+
try { await injectInto(target, rendered); } catch {}
|
|
594
|
+
}
|
|
595
|
+
} catch {}
|
|
596
|
+
return { content: [{ type: 'text', text: res.purged ? 'Forgotten and purged locally. The tombstone propagates on the next push; historical backups still require removal.' : 'Forgotten. The tombstone propagates on the next push.' }] };
|
|
597
|
+
}
|
|
598
|
+
);
|
|
599
|
+
|
|
600
|
+
server.tool(
|
|
601
|
+
'memoir_resume',
|
|
602
|
+
'Build an actionable handoff for the current project: goal, next actions, decisions with sources, open questions, and checkout drift. Saved observations never imply that current tests pass.',
|
|
603
|
+
{ project: z.string().optional().describe('Project directory; defaults to the configured working project.') },
|
|
604
|
+
async ({ project }) => {
|
|
605
|
+
const brief = await buildResumeBrief(project);
|
|
606
|
+
return { content: [{ type: 'text', text: formatResumeBrief(brief) }] };
|
|
607
|
+
}
|
|
608
|
+
);
|
|
609
|
+
|
|
742
610
|
// ── Resources ────────────────────────────────────────────────────────────────
|
|
743
611
|
|
|
744
612
|
// Expose detected tools as browsable resources
|