memoir-cli 3.11.2 → 3.12.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 +20 -6
- package/bin/memoir.js +22 -0
- package/package.json +1 -1
- package/src/cloud/auth.js +12 -15
- package/src/cloud/constants.js +6 -2
- package/src/commands/activate.js +25 -2
- package/src/commands/cloud.js +1 -1
- package/src/commands/forget.js +100 -0
- package/src/commands/push.js +31 -12
- package/src/commands/recall.js +42 -0
- package/src/commands/restore.js +12 -4
- package/src/commands/session.js +8 -3
- package/src/commands/upgrade.js +2 -2
- package/src/commands/validate.js +13 -0
- package/src/context/capture.js +14 -2
- package/src/mcp.js +54 -139
- package/src/memory/search.js +503 -0
- package/src/providers/index.js +21 -0
- package/src/security/scanner.js +12 -4
- package/src/session/lock.js +36 -2
- package/src/session/state.js +122 -11
package/src/mcp.js
CHANGED
|
@@ -27,119 +27,18 @@ import {
|
|
|
27
27
|
import { renderSession } from './session/render.js';
|
|
28
28
|
import { injectInto, detectAvailableTargets } from './session/inject.js';
|
|
29
29
|
import { findDecisions } from './commands/why.js';
|
|
30
|
+
import { matchDecisions, hideDecision } from './session/state.js';
|
|
31
|
+
import { readMemoryFiles, searchMemories, formatRecallResults, withFrontmatterLists } from './memory/search.js';
|
|
30
32
|
import { capture as track } from './telemetry.js';
|
|
33
|
+
import { createRequire } from 'module';
|
|
31
34
|
|
|
32
35
|
const home = os.homedir();
|
|
36
|
+
const { version: VERSION } = createRequire(import.meta.url)('../package.json');
|
|
33
37
|
|
|
34
38
|
// ── 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
|
-
}
|
|
39
|
+
// readMemoryFiles / searchMemories live in ./memory/search.js (cached,
|
|
40
|
+
// field-weighted, passage-returning) so the CLI's `memoir recall` and tests
|
|
41
|
+
// share one implementation with this server.
|
|
143
42
|
|
|
144
43
|
/**
|
|
145
44
|
* Get list of detected tools with status
|
|
@@ -164,7 +63,7 @@ async function getDetectedTools() {
|
|
|
164
63
|
|
|
165
64
|
const server = new McpServer({
|
|
166
65
|
name: 'memoir',
|
|
167
|
-
version:
|
|
66
|
+
version: VERSION,
|
|
168
67
|
}, {
|
|
169
68
|
capabilities: {
|
|
170
69
|
tools: {},
|
|
@@ -236,46 +135,30 @@ server.tool(
|
|
|
236
135
|
|
|
237
136
|
server.tool(
|
|
238
137
|
'memoir_recall',
|
|
239
|
-
'Search across all AI tool memories, project configs, and session context for relevant information. Use this
|
|
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
|
-
};
|
|
138
|
+
'Search across all AI tool memories, project configs, and session context for relevant information. 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
|
+
{
|
|
140
|
+
query: z.string().describe('Search query — keywords or topic to find in memories. Multi-word queries rank files that match every word highest.'),
|
|
141
|
+
limit: z.number().int().min(1).max(30).optional().describe('Max results to return (default 10)'),
|
|
142
|
+
},
|
|
143
|
+
async ({ query, limit }) => {
|
|
144
|
+
const res = await searchMemories(query, { limit: limit || 10 });
|
|
145
|
+
return { content: [{ type: 'text', text: formatRecallResults(query, res) }] };
|
|
266
146
|
}
|
|
267
147
|
);
|
|
268
148
|
|
|
269
149
|
server.tool(
|
|
270
150
|
'memoir_remember',
|
|
271
|
-
'Save a memory to a specific AI tool\'s memory files. Use this to persist important context, decisions, or facts for future sessions.',
|
|
151
|
+
'Save a memory to a specific AI tool\'s memory files. 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
152
|
{
|
|
273
|
-
content: z.string().describe('The memory content to save (markdown
|
|
153
|
+
content: z.string().describe('The memory content to save (markdown, ideally with --- frontmatter: type, name, description)'),
|
|
274
154
|
filename: z.string().describe('Filename for the memory (e.g. "auth-setup.md", "project-goals.md")'),
|
|
155
|
+
aliases: z.array(z.string()).optional().describe('Other names/phrasings this memory should be findable under. Written into frontmatter `aliases:`. Strongly recommended.'),
|
|
156
|
+
tags: z.array(z.string()).optional().describe('Topic tags. Written into frontmatter `tags:`.'),
|
|
275
157
|
tool: z.string().optional().describe('Which AI tool to save to: "claude", "gemini", "cursor", etc. Defaults to claude.'),
|
|
276
158
|
project: z.string().optional().describe('Project directory path to save a project-level memory (e.g. CLAUDE.md). If provided, saves to that project directory instead of global tool config.'),
|
|
277
159
|
},
|
|
278
|
-
async ({ content, filename, tool, project }) => {
|
|
160
|
+
async ({ content, filename, aliases, tags, tool, project }) => {
|
|
161
|
+
content = withFrontmatterLists(content, { aliases, tags });
|
|
279
162
|
// Project-level memory
|
|
280
163
|
if (project) {
|
|
281
164
|
const projectDir = project.startsWith('/') ? project : path.join(home, project);
|
|
@@ -739,6 +622,38 @@ server.tool(
|
|
|
739
622
|
}
|
|
740
623
|
);
|
|
741
624
|
|
|
625
|
+
server.tool(
|
|
626
|
+
'memoir_forget',
|
|
627
|
+
'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
|
+
{
|
|
629
|
+
text: z.string().describe('The decision text, or a substring unique to it'),
|
|
630
|
+
purge: z.boolean().optional().describe('Also redact the text/why/rejected in place, keeping only a hash. For leaked secrets. Default false.'),
|
|
631
|
+
},
|
|
632
|
+
async ({ text, purge }) => {
|
|
633
|
+
const state = await readSession();
|
|
634
|
+
const matches = matchDecisions(state, text);
|
|
635
|
+
if (matches.length === 0) {
|
|
636
|
+
return { content: [{ type: 'text', text: `No visible decision matches "${text}". Nothing forgotten.` }] };
|
|
637
|
+
}
|
|
638
|
+
if (matches.length > 1) {
|
|
639
|
+
const list = matches.map(d => `● ${d.text}`).join('\n');
|
|
640
|
+
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}` }] };
|
|
641
|
+
}
|
|
642
|
+
const res = await hideDecision(matches[0].text, { purge: !!purge });
|
|
643
|
+
if (!res.hidden) {
|
|
644
|
+
return { content: [{ type: 'text', text: `Nothing changed — the decision may already have been forgotten.` }] };
|
|
645
|
+
}
|
|
646
|
+
// Re-render the pinned block so the next session no longer loads it.
|
|
647
|
+
try {
|
|
648
|
+
const rendered = renderSession(res.state);
|
|
649
|
+
for (const target of Object.values(detectAvailableTargets())) {
|
|
650
|
+
try { await injectInto(target, rendered); } catch {}
|
|
651
|
+
}
|
|
652
|
+
} catch {}
|
|
653
|
+
return { content: [{ type: 'text', text: `${res.purged ? 'Forgotten and purged' : 'Forgotten'}: "${matches[0].text}". The tombstone propagates on the next push.` }] };
|
|
654
|
+
}
|
|
655
|
+
);
|
|
656
|
+
|
|
742
657
|
// ── Resources ────────────────────────────────────────────────────────────────
|
|
743
658
|
|
|
744
659
|
// Expose detected tools as browsable resources
|