memoir-cli 3.12.0 → 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 +128 -137
- package/bin/memoir-work.js +9 -0
- package/bin/memoir.js +50 -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/storage.js +130 -93
- package/src/commands/activate.js +18 -7
- package/src/commands/cloud.js +55 -4
- package/src/commands/consolidate.js +49 -10
- package/src/commands/diff.js +2 -2
- package/src/commands/doctor.js +3 -3
- package/src/commands/push.js +156 -161
- package/src/commands/recall.js +1 -1
- 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 +11 -9
- package/src/commands/validate.js +3 -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 +126 -32
- 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 +105 -152
- 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 +165 -70
- 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 +200 -33
- 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
package/src/mcp.js
CHANGED
|
@@ -24,11 +24,17 @@ 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';
|
|
30
31
|
import { matchDecisions, hideDecision } from './session/state.js';
|
|
31
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';
|
|
32
38
|
import { capture as track } from './telemetry.js';
|
|
33
39
|
import { createRequire } from 'module';
|
|
34
40
|
|
|
@@ -81,9 +87,17 @@ const _registerTool = server.tool.bind(server);
|
|
|
81
87
|
server.tool = (name, ...rest) => {
|
|
82
88
|
const handler = rest[rest.length - 1];
|
|
83
89
|
if (typeof handler === 'function') {
|
|
84
|
-
rest[rest.length - 1] = (...args) => {
|
|
85
|
-
|
|
86
|
-
|
|
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
|
+
}
|
|
87
101
|
};
|
|
88
102
|
}
|
|
89
103
|
return _registerTool(name, ...rest);
|
|
@@ -135,97 +149,39 @@ server.tool(
|
|
|
135
149
|
|
|
136
150
|
server.tool(
|
|
137
151
|
'memoir_recall',
|
|
138
|
-
'Search
|
|
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.',
|
|
139
153
|
{
|
|
140
154
|
query: z.string().describe('Search query — keywords or topic to find in memories. Multi-word queries rank files that match every word highest.'),
|
|
141
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).'),
|
|
142
158
|
},
|
|
143
|
-
async ({ query, limit }) => {
|
|
144
|
-
const res = await searchMemories(query, { limit: limit || 10 });
|
|
159
|
+
async ({ query, limit, project, budget }) => {
|
|
160
|
+
const res = await searchMemories(query, { limit: limit || 10, project, budget });
|
|
145
161
|
return { content: [{ type: 'text', text: formatRecallResults(query, res) }] };
|
|
146
162
|
}
|
|
147
163
|
);
|
|
148
164
|
|
|
149
165
|
server.tool(
|
|
150
166
|
'memoir_remember',
|
|
151
|
-
'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.',
|
|
152
168
|
{
|
|
153
169
|
content: z.string().describe('The memory content to save (markdown, ideally with --- frontmatter: type, name, description)'),
|
|
154
170
|
filename: z.string().describe('Filename for the memory (e.g. "auth-setup.md", "project-goals.md")'),
|
|
155
171
|
aliases: z.array(z.string()).optional().describe('Other names/phrasings this memory should be findable under. Written into frontmatter `aliases:`. Strongly recommended.'),
|
|
156
172
|
tags: z.array(z.string()).optional().describe('Topic tags. Written into frontmatter `tags:`.'),
|
|
157
|
-
tool: z.string().optional().describe('
|
|
158
|
-
|
|
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.'),
|
|
159
176
|
},
|
|
160
|
-
async ({ content, filename, aliases, tags, tool, project }) => {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Default to CLAUDE.md for project-level memories.
|
|
170
|
-
// Guards mirror the global branch below and memoir_read above:
|
|
171
|
-
// model-supplied filename must be a bare markdown name — no
|
|
172
|
-
// separators, no traversal — and must resolve inside the project
|
|
173
|
-
// dir. Without this, filename:".zshrc" appends to a shell rc
|
|
174
|
-
// (code execution on next shell) and "package.json" corrupts
|
|
175
|
-
// real files.
|
|
176
|
-
let targetFile = filename || 'CLAUDE.md';
|
|
177
|
-
if (!targetFile.endsWith('.md')) targetFile += '.md';
|
|
178
|
-
if (targetFile.includes('/') || targetFile.includes('\\') || targetFile.includes('..')) {
|
|
179
|
-
return { content: [{ type: 'text', text: `Invalid filename: ${filename} (must be a bare .md name)` }] };
|
|
180
|
-
}
|
|
181
|
-
const projBase = path.resolve(projectDir);
|
|
182
|
-
const targetPath = path.resolve(projBase, targetFile);
|
|
183
|
-
if (!targetPath.startsWith(projBase + path.sep)) {
|
|
184
|
-
return { content: [{ type: 'text', text: `Invalid filename: ${filename}` }] };
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Append to existing file or create new
|
|
188
|
-
if (await fs.pathExists(targetPath)) {
|
|
189
|
-
const existing = await fs.readFile(targetPath, 'utf8');
|
|
190
|
-
await fs.writeFile(targetPath, existing + '\n\n' + content);
|
|
191
|
-
} else {
|
|
192
|
-
await fs.writeFile(targetPath, content);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
return {
|
|
196
|
-
content: [{ type: 'text', text: `Saved to ${targetPath}` }]
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// Global tool memory
|
|
201
|
-
const toolKey = (tool || 'claude').toLowerCase();
|
|
202
|
-
|
|
203
|
-
// Find the right directory for the tool
|
|
204
|
-
let targetDir;
|
|
205
|
-
if (toolKey === 'claude') {
|
|
206
|
-
// Save to Claude's memory system
|
|
207
|
-
const claudeMemDir = path.join(home, '.claude', 'projects', '-Users-' + path.basename(home), 'memory');
|
|
208
|
-
await fs.ensureDir(claudeMemDir);
|
|
209
|
-
targetDir = claudeMemDir;
|
|
210
|
-
} else if (toolKey === 'gemini') {
|
|
211
|
-
targetDir = path.join(home, '.gemini');
|
|
212
|
-
} else if (toolKey === 'cursor') {
|
|
213
|
-
const cursorDir = process.platform === 'win32'
|
|
214
|
-
? path.join(process.env.APPDATA || '', 'Cursor', 'User', 'rules')
|
|
215
|
-
: path.join(home, 'Library', 'Application Support', 'Cursor', 'User', 'rules');
|
|
216
|
-
await fs.ensureDir(cursorDir);
|
|
217
|
-
targetDir = cursorDir;
|
|
218
|
-
} else {
|
|
219
|
-
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 }] };
|
|
220
184
|
}
|
|
221
|
-
|
|
222
|
-
if (!filename.endsWith('.md')) filename += '.md';
|
|
223
|
-
const targetPath = path.join(targetDir, filename);
|
|
224
|
-
await fs.writeFile(targetPath, content);
|
|
225
|
-
|
|
226
|
-
return {
|
|
227
|
-
content: [{ type: 'text', text: `Memory saved to ${targetPath}` }]
|
|
228
|
-
};
|
|
229
185
|
}
|
|
230
186
|
);
|
|
231
187
|
|
|
@@ -237,6 +193,11 @@ server.tool(
|
|
|
237
193
|
},
|
|
238
194
|
async ({ tool }) => {
|
|
239
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
|
+
}
|
|
240
201
|
|
|
241
202
|
for (const adapter of adapters) {
|
|
242
203
|
if (tool) {
|
|
@@ -246,6 +207,7 @@ server.tool(
|
|
|
246
207
|
|
|
247
208
|
const files = await readMemoryFiles(adapter);
|
|
248
209
|
for (const f of files) {
|
|
210
|
+
if (!visibleMemory(f)) continue;
|
|
249
211
|
allFiles.push({ tool: adapter.name, icon: adapter.icon, path: f.path, size: f.content.length });
|
|
250
212
|
}
|
|
251
213
|
}
|
|
@@ -282,35 +244,35 @@ server.tool(
|
|
|
282
244
|
{
|
|
283
245
|
tool: z.string().describe('Tool name: "claude", "gemini", "cursor", etc.'),
|
|
284
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.'),
|
|
285
248
|
},
|
|
286
|
-
async ({ tool, filepath }) => {
|
|
287
|
-
const toolKey = tool.toLowerCase();
|
|
288
|
-
const adapter = adapters.find(a => a.name.toLowerCase().includes(toolKey));
|
|
289
|
-
|
|
290
|
-
if (!adapter) {
|
|
291
|
-
return { content: [{ type: 'text', text: `Unknown tool: ${tool}. Available: ${adapters.map(a => a.name).join(', ')}` }] };
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// Containment: filepath comes from the model, and the model reads
|
|
295
|
-
// attacker-influenceable text all day. Without this, "../.ssh/id_rsa"
|
|
296
|
-
// resolves outside the adapter dir and the file is returned verbatim.
|
|
297
|
-
const base = path.resolve(adapter.source);
|
|
298
|
-
const fullPath = path.resolve(base, filepath);
|
|
299
|
-
if (fullPath !== base && !fullPath.startsWith(base + path.sep)) {
|
|
300
|
-
return { content: [{ type: 'text', text: `Invalid path: ${filepath} (must stay inside ${adapter.name}'s directory)` }] };
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
if (!(await fs.pathExists(fullPath))) {
|
|
304
|
-
return { content: [{ type: 'text', text: `File not found: ${filepath} in ${adapter.name}` }] };
|
|
305
|
-
}
|
|
306
|
-
|
|
249
|
+
async ({ tool, filepath, project }) => {
|
|
307
250
|
try {
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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 }] };
|
|
312
274
|
} catch (err) {
|
|
313
|
-
return { content: [{ type: 'text', text:
|
|
275
|
+
return { isError: true, content: [{ type: 'text', text: err.message }] };
|
|
314
276
|
}
|
|
315
277
|
}
|
|
316
278
|
);
|
|
@@ -342,47 +304,19 @@ server.tool(
|
|
|
342
304
|
'memoir_consolidate',
|
|
343
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.',
|
|
344
306
|
{
|
|
345
|
-
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.'),
|
|
346
308
|
},
|
|
347
309
|
async ({ smart }) => {
|
|
348
|
-
// Collect all memory files
|
|
349
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
|
+
}
|
|
350
316
|
for (const adapter of adapters) {
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
for (const file of adapter.files) {
|
|
354
|
-
const filePath = path.join(adapter.source, file);
|
|
355
|
-
if (await fs.pathExists(filePath)) {
|
|
356
|
-
try {
|
|
357
|
-
const content = await fs.readFile(filePath, 'utf8');
|
|
358
|
-
const stat = await fs.stat(filePath);
|
|
359
|
-
files.push({ path: file, fullPath: filePath, content, tool: adapter.name, icon: adapter.icon, mtime: stat.mtimeMs, size: content.length });
|
|
360
|
-
} catch {}
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
} else if (await fs.pathExists(adapter.source)) {
|
|
364
|
-
const walk = async (dir, prefix = '') => {
|
|
365
|
-
let entries;
|
|
366
|
-
try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
367
|
-
for (const entry of entries) {
|
|
368
|
-
const fullPath = path.join(dir, entry.name);
|
|
369
|
-
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
370
|
-
if (entry.isDirectory()) {
|
|
371
|
-
if (adapter.filter(fullPath)) await walk(fullPath, relPath);
|
|
372
|
-
} else if (/\.(md|json|yml|yaml)$/.test(entry.name)) {
|
|
373
|
-
if (adapter.filter(fullPath)) {
|
|
374
|
-
try {
|
|
375
|
-
const content = await fs.readFile(fullPath, 'utf8');
|
|
376
|
-
const stat = await fs.stat(fullPath);
|
|
377
|
-
files.push({ path: relPath, fullPath, content, tool: adapter.name, icon: adapter.icon, mtime: stat.mtimeMs, size: content.length });
|
|
378
|
-
} catch {}
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
};
|
|
383
|
-
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 });
|
|
384
319
|
}
|
|
385
|
-
allFiles.push(...files);
|
|
386
320
|
}
|
|
387
321
|
|
|
388
322
|
if (allFiles.length === 0) {
|
|
@@ -473,36 +407,39 @@ async function refreshPinnedBlock() {
|
|
|
473
407
|
|
|
474
408
|
server.tool(
|
|
475
409
|
'memoir_set_goal',
|
|
476
|
-
'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.',
|
|
477
411
|
{ text: z.string().describe('The goal, one short sentence') },
|
|
478
412
|
async ({ text }) => {
|
|
479
|
-
await addGoal(text);
|
|
413
|
+
const state = await addGoal(text);
|
|
480
414
|
await refreshPinnedBlock();
|
|
481
|
-
|
|
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.` : ''}` }] };
|
|
482
417
|
}
|
|
483
418
|
);
|
|
484
419
|
|
|
485
420
|
server.tool(
|
|
486
421
|
'memoir_add_next',
|
|
487
|
-
'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.',
|
|
488
423
|
{ text: z.string().describe('The action, one short imperative sentence') },
|
|
489
424
|
async ({ text }) => {
|
|
490
|
-
await addNext(text);
|
|
425
|
+
const state = await addNext(text);
|
|
491
426
|
await refreshPinnedBlock();
|
|
492
|
-
|
|
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('; ')}` : ''}` }] };
|
|
493
429
|
}
|
|
494
430
|
);
|
|
495
431
|
|
|
496
432
|
server.tool(
|
|
497
433
|
'memoir_complete_next',
|
|
498
|
-
'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.',
|
|
499
435
|
{ match: z.string().describe('Substring to match against existing next actions') },
|
|
500
436
|
async ({ match }) => {
|
|
437
|
+
const count = (st) => st.current.next_actions.length + (st.current.parked_actions || []).length;
|
|
501
438
|
const before = await readSession();
|
|
502
|
-
const beforeCount = before
|
|
439
|
+
const beforeCount = count(before);
|
|
503
440
|
await completeNext(match);
|
|
504
441
|
const after = await readSession();
|
|
505
|
-
const removed = beforeCount - after
|
|
442
|
+
const removed = beforeCount - count(after);
|
|
506
443
|
await refreshPinnedBlock();
|
|
507
444
|
return {
|
|
508
445
|
content: [{
|
|
@@ -549,12 +486,12 @@ server.tool(
|
|
|
549
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.',
|
|
550
487
|
{},
|
|
551
488
|
async () => {
|
|
552
|
-
const state = await readSession();
|
|
489
|
+
const state = sessionView(await readSession());
|
|
553
490
|
const machine = await getMachineId();
|
|
554
491
|
const goals = state.current.goals.map(g => `- ${g.text}`).join('\n') || '(none)';
|
|
555
492
|
const nexts = state.current.next_actions.map(n => `- [ ] ${n.text}`).join('\n') || '(none)';
|
|
556
493
|
const questions = state.current.open_questions.map(q => `- ${q.text}`).join('\n') || '(none)';
|
|
557
|
-
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 => {
|
|
558
495
|
let line = `- ${d.text}`;
|
|
559
496
|
if (d.why) line += ` — *${d.why}*`;
|
|
560
497
|
return line;
|
|
@@ -626,10 +563,16 @@ server.tool(
|
|
|
626
563
|
'memoir_forget',
|
|
627
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).',
|
|
628
565
|
{
|
|
629
|
-
text: z.string().describe('
|
|
566
|
+
text: z.string().describe('A canonical memory ID, or decision text/substr uniquely identifying a decision'),
|
|
630
567
|
purge: z.boolean().optional().describe('Also redact the text/why/rejected in place, keeping only a hash. For leaked secrets. Default false.'),
|
|
631
568
|
},
|
|
632
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
|
+
}
|
|
633
576
|
const state = await readSession();
|
|
634
577
|
const matches = matchDecisions(state, text);
|
|
635
578
|
if (matches.length === 0) {
|
|
@@ -650,7 +593,17 @@ server.tool(
|
|
|
650
593
|
try { await injectInto(target, rendered); } catch {}
|
|
651
594
|
}
|
|
652
595
|
} catch {}
|
|
653
|
-
return { content: [{ type: 'text', text:
|
|
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) }] };
|
|
654
607
|
}
|
|
655
608
|
);
|
|
656
609
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Rebuildable, process-local postings. Canonical files remain authoritative;
|
|
2
|
+
// nothing is persisted or trusted across a filesystem refresh or scope change.
|
|
3
|
+
export class LexicalIndex {
|
|
4
|
+
constructor() { this.clear(); }
|
|
5
|
+
|
|
6
|
+
clear() {
|
|
7
|
+
this.documents = new Set();
|
|
8
|
+
this.postings = new Map();
|
|
9
|
+
this.tokensByDocument = new Map();
|
|
10
|
+
this.vocabulary = null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
sync(documents) {
|
|
14
|
+
const current = new Set(documents);
|
|
15
|
+
for (const doc of this.documents) {
|
|
16
|
+
if (current.has(doc)) continue;
|
|
17
|
+
for (const token of this.tokensByDocument.get(doc)) {
|
|
18
|
+
const posting = this.postings.get(token);
|
|
19
|
+
posting.delete(doc);
|
|
20
|
+
if (!posting.size) { this.postings.delete(token); this.vocabulary = null; }
|
|
21
|
+
}
|
|
22
|
+
this.tokensByDocument.delete(doc);
|
|
23
|
+
}
|
|
24
|
+
for (const doc of current) {
|
|
25
|
+
if (this.documents.has(doc)) continue;
|
|
26
|
+
const tokens = new Set(Object.values(doc.tf).flatMap(field => [...field.keys()]));
|
|
27
|
+
this.tokensByDocument.set(doc, tokens);
|
|
28
|
+
for (const token of tokens) {
|
|
29
|
+
if (!this.postings.has(token)) { this.postings.set(token, new Set()); this.vocabulary = null; }
|
|
30
|
+
this.postings.get(token).add(doc);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
this.documents = current;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
lookup(terms) {
|
|
37
|
+
if (!this.vocabulary) this.vocabulary = [...this.postings.keys()].sort();
|
|
38
|
+
const documents = new Set();
|
|
39
|
+
const matches = new Map();
|
|
40
|
+
for (const term of terms) {
|
|
41
|
+
const tokens = new Map();
|
|
42
|
+
if (this.postings.has(term)) tokens.set(term, 1);
|
|
43
|
+
if (term.length >= 4) {
|
|
44
|
+
// Binary seek to query-prefix matches; no full vocabulary scan.
|
|
45
|
+
let lo = 0, hi = this.vocabulary.length;
|
|
46
|
+
while (lo < hi) {
|
|
47
|
+
const mid = (lo + hi) >>> 1;
|
|
48
|
+
if (this.vocabulary[mid] < term) lo = mid + 1; else hi = mid;
|
|
49
|
+
}
|
|
50
|
+
for (let i = lo; i < this.vocabulary.length && this.vocabulary[i].startsWith(term); i++) {
|
|
51
|
+
const token = this.vocabulary[i];
|
|
52
|
+
if (token !== term) tokens.set(token, .6);
|
|
53
|
+
}
|
|
54
|
+
// The reference scorer also allows a document token to prefix the query.
|
|
55
|
+
for (let n = 4; n < term.length; n++) {
|
|
56
|
+
const token = term.slice(0, n);
|
|
57
|
+
if (this.postings.has(token)) tokens.set(token, .6);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
matches.set(term, tokens);
|
|
61
|
+
for (const token of tokens.keys()) for (const doc of this.postings.get(token)) documents.add(doc);
|
|
62
|
+
}
|
|
63
|
+
return { documents, matches };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
export function repositoryState(project) {
|
|
4
|
+
const run = args => execFileSync('git', args, { cwd: project, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000 }).trim();
|
|
5
|
+
try {
|
|
6
|
+
return {
|
|
7
|
+
root: run(['rev-parse', '--show-toplevel']),
|
|
8
|
+
head: run(['rev-parse', 'HEAD']),
|
|
9
|
+
branch: run(['branch', '--show-current']) || '(detached)',
|
|
10
|
+
// git status can execute fsmonitor hooks and clean filters from project
|
|
11
|
+
// configuration. Reading a memory record must not run that code. File
|
|
12
|
+
// hashes provide scoped check freshness; report overall dirtiness unknown.
|
|
13
|
+
dirty: null,
|
|
14
|
+
};
|
|
15
|
+
} catch { return { root: project, head: null, branch: null, dirty: null }; }
|
|
16
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
import { execFileSync } from 'child_process';
|
|
6
|
+
|
|
7
|
+
const cache = new Map();
|
|
8
|
+
export function projectIdentity(project = process.env.MEMOIR_PROJECT_ROOT || process.cwd()) {
|
|
9
|
+
if (project === 'shared') return 'shared';
|
|
10
|
+
if (/^(git|local):[a-f0-9]{32}$/.test(project)) return project;
|
|
11
|
+
let absolute = path.resolve(project.replace(/^~/, os.homedir()));
|
|
12
|
+
try { absolute = fs.realpathSync(absolute); } catch {}
|
|
13
|
+
const old = cache.get(absolute);
|
|
14
|
+
if (old && Date.now() - old.at < 60_000) return old.id;
|
|
15
|
+
let home = os.homedir();
|
|
16
|
+
try { home = fs.realpathSync(home); } catch {}
|
|
17
|
+
let key = path.relative(home, absolute).replace(/\\/g, '/');
|
|
18
|
+
let kind = 'local';
|
|
19
|
+
try {
|
|
20
|
+
const remote = execFileSync('git', ['config', '--get', 'remote.origin.url'], { cwd: absolute, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 }).trim();
|
|
21
|
+
if (remote) {
|
|
22
|
+
// Normalize common SSH/HTTPS spellings; never persist embedded credentials.
|
|
23
|
+
key = remote.replace(/^git@([^:]+):/, '$1/').replace(/^https?:\/\/(?:[^/@]+@)?/, '').replace(/^ssh:\/\/git@/, '').replace(/\.git\/?$/, '').replace(/\/$/, '');
|
|
24
|
+
kind = 'git';
|
|
25
|
+
}
|
|
26
|
+
} catch {}
|
|
27
|
+
const id = kind + ':' + crypto.createHash('sha256').update(key).digest('hex').slice(0, 32);
|
|
28
|
+
cache.set(absolute, { id, at: Date.now() });
|
|
29
|
+
return id;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Resolve project paths once per query, not once for every stored record.
|
|
33
|
+
// A new predicate is created on each query so time-based validity is current.
|
|
34
|
+
export function memoryVisibility({ project = process.env.MEMOIR_PROJECT_ROOT || process.cwd(), allProjects = false, now = Date.now() } = {}) {
|
|
35
|
+
let activeId, currentKey, sharedKey;
|
|
36
|
+
const identities = new Map();
|
|
37
|
+
return item => {
|
|
38
|
+
if (!item || item.hidden === true || item.hidden === 'true' || item.deleted === true || ['deleted', 'hidden', 'superseded'].includes(item.status) || item.superseded_by) return false;
|
|
39
|
+
if (item.valid_from && Date.parse(item.valid_from) > now) return false;
|
|
40
|
+
if (item.valid_until && Date.parse(item.valid_until) <= now) return false;
|
|
41
|
+
if (!allProjects && item.claudeProjectKey) {
|
|
42
|
+
currentKey ??= path.resolve(project.replace(/^~/, os.homedir())).replace(/[\\/:]/g, '-');
|
|
43
|
+
sharedKey ??= os.homedir().replace(/[\\/:]/g, '-');
|
|
44
|
+
if (item.claudeProjectKey !== currentKey && item.claudeProjectKey !== sharedKey) return false;
|
|
45
|
+
}
|
|
46
|
+
if (allProjects || !item.project || item.project === 'shared') return true;
|
|
47
|
+
activeId ??= projectIdentity(project);
|
|
48
|
+
const key = String(item.project);
|
|
49
|
+
if (!identities.has(key)) identities.set(key, projectIdentity(key));
|
|
50
|
+
return identities.get(key) === activeId;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function visibleMemory(item, options = {}) {
|
|
55
|
+
return memoryVisibility(options)(item);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function sessionView(state, options = {}) {
|
|
59
|
+
const current = { ...(state?.current || {}) };
|
|
60
|
+
for (const key of ['goals', 'next_actions', 'parked_actions', 'open_questions', 'decisions']) {
|
|
61
|
+
const archived = { goals: 'archived_goals', decisions: 'archived_decisions', open_questions: 'archived_questions' }[key];
|
|
62
|
+
current[key] = [...(current[key] || []), ...(archived ? current[archived] || [] : [])].filter(item => visibleMemory(item, options));
|
|
63
|
+
}
|
|
64
|
+
return { ...state, current, history: (state?.history || []).filter(item => visibleMemory(item, options)) };
|
|
65
|
+
}
|