open-memex 0.2.0-alpha → 0.3.0-alpha
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/AGENTS.md +32 -6
- package/README.md +239 -37
- package/README.zh-CN.md +307 -0
- package/bin/open-memex.js +28 -0
- package/docs/V2-DESIGN.md +107 -3
- package/package.json +12 -3
- package/scripts/smoke-mcp.ts +135 -0
- package/scripts/smoke-pure.ts +42 -1
- package/src/capture/keywords.ts +28 -15
- package/src/cli.ts +214 -9
- package/src/config.ts +89 -4
- package/src/doctor.ts +161 -0
- package/src/index.ts +19 -10
- package/src/init.ts +304 -0
- package/src/mcp.ts +133 -0
- package/src/redact.ts +121 -17
- package/src/tools/memory.ts +27 -202
- package/src/tools/ops.ts +259 -0
- package/PLAN.md +0 -168
package/src/index.ts
CHANGED
|
@@ -63,19 +63,26 @@ const plugin: Plugin = async ({ worktree, directory }) => {
|
|
|
63
63
|
|
|
64
64
|
const hits = detectKeywords(text, cfg);
|
|
65
65
|
for (const h of hits) {
|
|
66
|
-
const { content, hadSecret } = redact(h.content, cfg.redactPatterns);
|
|
67
|
-
|
|
66
|
+
const { content, hadSecret, matchedPattern } = redact(h.content, cfg.redactPatterns);
|
|
67
|
+
// Secrets are masked (first 4 chars kept) and the capture proceeds;
|
|
68
|
+
// skip only when nothing usable remains.
|
|
69
|
+
if (content.length === 0) continue;
|
|
70
|
+
if (hadSecret && cfg.logLevel === "debug") {
|
|
71
|
+
console.log(`[open-memex] keyword capture masked secret (${matchedPattern})`);
|
|
72
|
+
}
|
|
73
|
+
// Personal patterns ("remember for me" / "记住(个人)") force the personal scope.
|
|
74
|
+
const target = h.personal ? PERSONAL_SCOPE : scope;
|
|
68
75
|
// Dedup (§3.4): skip exact duplicates captured before.
|
|
69
|
-
if (findDuplicates(
|
|
76
|
+
if (findDuplicates(target.key, content).exact) continue;
|
|
70
77
|
const now = Date.now();
|
|
71
78
|
const rfc = msToRfc3339(now);
|
|
72
79
|
const fm: Frontmatter = {
|
|
73
80
|
id: ulid(),
|
|
74
81
|
schema_version: 2,
|
|
75
|
-
scope_key:
|
|
76
|
-
scope:
|
|
77
|
-
visibility:
|
|
78
|
-
project_name:
|
|
82
|
+
scope_key: target.key,
|
|
83
|
+
scope: target.kind === "project" ? "project" : "personal",
|
|
84
|
+
visibility: target.kind === "project" ? "internal" : "private",
|
|
85
|
+
project_name: target.projectName,
|
|
79
86
|
type: "fact",
|
|
80
87
|
role: "knowledge",
|
|
81
88
|
importance: "normal",
|
|
@@ -91,9 +98,11 @@ const plugin: Plugin = async ({ worktree, directory }) => {
|
|
|
91
98
|
const { filePath } = writeMemoryFile(fm, content);
|
|
92
99
|
const mf = readMemoryFile(filePath);
|
|
93
100
|
if (mf) upsertFromFile(mf);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
101
|
+
// Capture feedback: always visible (not debug-only) — the user said
|
|
102
|
+
// "记住…", they should see that it landed. The opencode plugin API
|
|
103
|
+
// offers no toast channel, so the plugin log is the feedback surface.
|
|
104
|
+
const preview = content.length > 60 ? content.slice(0, 60) + "…" : content;
|
|
105
|
+
console.log(`[open-memex] remembered → ${target.kind} scope: "${preview}"`);
|
|
97
106
|
} catch (err) {
|
|
98
107
|
console.error("[open-memex] keyword capture failed:", err);
|
|
99
108
|
}
|
package/src/init.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// `open-memex init` — one-command project setup (§17 adoption path).
|
|
2
|
+
// Pure file operation: no DB, no network. Safe to run in any directory.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { createInterface } from "node:readline/promises";
|
|
7
|
+
import { DEFAULT_CONFIG, saveConfig } from "./config.ts";
|
|
8
|
+
|
|
9
|
+
const MARKER = "<!-- open-memex -->";
|
|
10
|
+
|
|
11
|
+
/** npm dist-tag carrying the 0.3.x preview line. */
|
|
12
|
+
const ALPHA_TAG = "open-memex@alpha";
|
|
13
|
+
|
|
14
|
+
export interface McpCommand {
|
|
15
|
+
command: string;
|
|
16
|
+
args: string[];
|
|
17
|
+
/** false when no durable bin exists (e.g. one-shot npx) and we fell back to npx. */
|
|
18
|
+
durable: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the MCP server command to write into client configs.
|
|
23
|
+
*
|
|
24
|
+
* A one-shot `npx open-memex@alpha init` runs from npm's ephemeral `_npx` cache,
|
|
25
|
+
* so `open-memex` resolving on PATH *inside that process* does not mean it will be
|
|
26
|
+
* there tomorrow. Only a bin found on PATH outside `_npx` cache dirs counts as
|
|
27
|
+
* durable; otherwise fall back to an npx-based command (slower startup, zero install).
|
|
28
|
+
*/
|
|
29
|
+
export function resolveMcpCommand(): McpCommand {
|
|
30
|
+
const pathEnv = process.env.PATH ?? "";
|
|
31
|
+
const dirs = pathEnv.split(path.delimiter).filter((d) => d && !d.includes("_npx"));
|
|
32
|
+
const names = process.platform === "win32" ? ["open-memex.cmd", "open-memex"] : ["open-memex"];
|
|
33
|
+
const durable = dirs.some((d) =>
|
|
34
|
+
names.some((n) => {
|
|
35
|
+
try {
|
|
36
|
+
return fs.existsSync(path.join(d, n));
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
if (durable) return { command: "open-memex", args: ["mcp"], durable: true };
|
|
43
|
+
return { command: "npx", args: ["-y", ALPHA_TAG, "mcp"], durable: false };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const INSTRUCTIONS = `${MARKER}
|
|
47
|
+
# OpenMemex memory
|
|
48
|
+
|
|
49
|
+
You have a local memory MCP server (\`open-memex\`) with five tools:
|
|
50
|
+
\`memory_add\`, \`memory_search\`, \`memory_list\`, \`memory_supersede\`, \`memory_forget\`.
|
|
51
|
+
|
|
52
|
+
- BE PROACTIVE. When the user shares something worth remembering across sessions
|
|
53
|
+
(a decision, a preference, a project convention, a fix and its cause), call
|
|
54
|
+
\`memory_add\` without being asked. Keep each memory to one self-contained statement.
|
|
55
|
+
- Before asking the user about past decisions, conventions, or preferences they may
|
|
56
|
+
have told you before, call \`memory_search\` first — try a few keyword variants
|
|
57
|
+
(including the user's own language) when the first search comes up empty.
|
|
58
|
+
- Memories default to this project's scope; use the \`personal\` scope for facts about
|
|
59
|
+
the user that hold across all projects. When a saved fact becomes outdated, call
|
|
60
|
+
\`memory_supersede\` instead of adding a duplicate.
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
/** Project root: git top-level, falling back to cwd. */
|
|
64
|
+
function projectRoot(): string {
|
|
65
|
+
try {
|
|
66
|
+
const top = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
67
|
+
encoding: "utf8",
|
|
68
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
69
|
+
}).trim();
|
|
70
|
+
if (top) return top;
|
|
71
|
+
} catch {
|
|
72
|
+
/* not a git repo — use cwd */
|
|
73
|
+
}
|
|
74
|
+
return process.cwd();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function writeMcpJson(root: string, client: string, force: boolean): string | null {
|
|
78
|
+
if (client === "opencode") return writeOpencodeMcpJson(root, force);
|
|
79
|
+
if (client === "visualstudio") return writeVisualStudioMcpJson(root, force);
|
|
80
|
+
const dir = client === "cursor" ? path.join(root, ".cursor") : path.join(root, ".vscode");
|
|
81
|
+
const file = path.join(dir, "mcp.json");
|
|
82
|
+
const sectionKey = client === "cursor" ? "mcpServers" : "servers";
|
|
83
|
+
|
|
84
|
+
let doc: Record<string, unknown> = {};
|
|
85
|
+
if (fs.existsSync(file)) {
|
|
86
|
+
try {
|
|
87
|
+
doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
|
|
88
|
+
} catch {
|
|
89
|
+
console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const section = ((doc[sectionKey] ??= {}) as Record<string, unknown>);
|
|
95
|
+
if (section["open-memex"] && !force) {
|
|
96
|
+
console.log(` = ${file} already configures open-memex — left as is (use --force to overwrite)`);
|
|
97
|
+
return file;
|
|
98
|
+
}
|
|
99
|
+
// D17: resolve the server command at init time — a one-shot npx leaves no bin behind.
|
|
100
|
+
const mc = resolveMcpCommand();
|
|
101
|
+
section["open-memex"] =
|
|
102
|
+
client === "cursor"
|
|
103
|
+
? { command: mc.command, args: mc.args }
|
|
104
|
+
: {
|
|
105
|
+
type: "stdio",
|
|
106
|
+
command: mc.command,
|
|
107
|
+
args: mc.args,
|
|
108
|
+
cwd: "${workspaceFolder}",
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
112
|
+
fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
|
|
113
|
+
console.log(` + ${file}`);
|
|
114
|
+
if (!mc.durable) {
|
|
115
|
+
console.log(` ! no durable \`open-memex\` on PATH (one-shot npx?) — wrote an npx-based command.`);
|
|
116
|
+
console.log(` For faster startup: \`npm i -g ${ALPHA_TAG}\`, then re-run \`open-memex init --force\`.`);
|
|
117
|
+
}
|
|
118
|
+
return file;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** opencode MCP config: project-level opencode.jsonc, `type: "local"` + command array (v1 format). */
|
|
122
|
+
function writeOpencodeMcpJson(root: string, force: boolean): string | null {
|
|
123
|
+
const file = path.join(root, "opencode.jsonc");
|
|
124
|
+
let doc: Record<string, unknown> = {};
|
|
125
|
+
if (fs.existsSync(file)) {
|
|
126
|
+
try {
|
|
127
|
+
doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
|
|
128
|
+
} catch {
|
|
129
|
+
console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const section = ((doc["mcp"] ??= {}) as Record<string, unknown>);
|
|
134
|
+
if (section["open-memex"] && !force) {
|
|
135
|
+
console.log(` = ${file} already configures open-memex — left as is (use --force to overwrite)`);
|
|
136
|
+
return file;
|
|
137
|
+
}
|
|
138
|
+
// D17: resolve the server command at init time — a one-shot npx leaves no bin behind.
|
|
139
|
+
const mc = resolveMcpCommand();
|
|
140
|
+
section["open-memex"] = {
|
|
141
|
+
type: "local",
|
|
142
|
+
command: [mc.command, ...mc.args],
|
|
143
|
+
enabled: true,
|
|
144
|
+
};
|
|
145
|
+
fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
|
|
146
|
+
console.log(` + ${file}`);
|
|
147
|
+
if (!mc.durable) {
|
|
148
|
+
console.log(` ! no durable \`open-memex\` on PATH (one-shot npx?) — wrote an npx-based command.`);
|
|
149
|
+
console.log(` For faster startup: \`npm i -g ${ALPHA_TAG}\`, then re-run \`open-memex init --force\`.`);
|
|
150
|
+
}
|
|
151
|
+
return file;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Visual Studio (Windows-only, 2022 17.14+ / 2026): solution-level `.mcp.json`
|
|
155
|
+
* with the `"servers"` section, per Microsoft Learn. Source-controllable.
|
|
156
|
+
* (VS also auto-discovers `.vscode/mcp.json` and `.cursor/mcp.json`.) */
|
|
157
|
+
function writeVisualStudioMcpJson(root: string, force: boolean): string | null {
|
|
158
|
+
const file = path.join(root, ".mcp.json");
|
|
159
|
+
let doc: Record<string, unknown> = {};
|
|
160
|
+
if (fs.existsSync(file)) {
|
|
161
|
+
try {
|
|
162
|
+
doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
|
|
163
|
+
} catch {
|
|
164
|
+
console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const section = ((doc["servers"] ??= {}) as Record<string, unknown>);
|
|
169
|
+
if (section["open-memex"] && !force) {
|
|
170
|
+
console.log(` = ${file} already configures open-memex — left as is (use --force to overwrite)`);
|
|
171
|
+
return file;
|
|
172
|
+
}
|
|
173
|
+
// D17: resolve the server command at init time — a one-shot npx leaves no bin behind.
|
|
174
|
+
const mc = resolveMcpCommand();
|
|
175
|
+
section["open-memex"] = {
|
|
176
|
+
type: "stdio",
|
|
177
|
+
command: mc.command,
|
|
178
|
+
args: mc.args,
|
|
179
|
+
};
|
|
180
|
+
fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
|
|
181
|
+
console.log(` + ${file}`);
|
|
182
|
+
if (!mc.durable) {
|
|
183
|
+
console.log(` ! no durable \`open-memex\` on PATH (one-shot npx?) — wrote an npx-based command.`);
|
|
184
|
+
console.log(` For faster startup: \`npm i -g ${ALPHA_TAG}\`, then re-run \`open-memex init --force\`.`);
|
|
185
|
+
}
|
|
186
|
+
return file;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function writeInstructions(root: string): string {
|
|
190
|
+
const dir = path.join(root, ".github");
|
|
191
|
+
const file = path.join(dir, "copilot-instructions.md");
|
|
192
|
+
if (fs.existsSync(file)) {
|
|
193
|
+
const cur = fs.readFileSync(file, "utf8");
|
|
194
|
+
if (cur.includes(MARKER)) {
|
|
195
|
+
console.log(` = ${file} already has open-memex instructions — left as is`);
|
|
196
|
+
return file;
|
|
197
|
+
}
|
|
198
|
+
fs.writeFileSync(file, cur.replace(/\s+$/, "") + "\n\n" + INSTRUCTIONS);
|
|
199
|
+
} else {
|
|
200
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
201
|
+
fs.writeFileSync(file, INSTRUCTIONS);
|
|
202
|
+
}
|
|
203
|
+
console.log(` + ${file}`);
|
|
204
|
+
return file;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export const INIT_CLIENTS = ["vscode", "cursor", "opencode", "visualstudio"] as const;
|
|
208
|
+
|
|
209
|
+
/** Normalize --client values; accepts "visual-studio" as an alias. */
|
|
210
|
+
export function normalizeClient(c: string): string {
|
|
211
|
+
const lower = c.toLowerCase();
|
|
212
|
+
return lower === "visual-studio" ? "visualstudio" : lower;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Ask a yes/no question. Only called on a TTY when --yes was not passed. */
|
|
216
|
+
async function askBool(q: string, def: boolean): Promise<boolean> {
|
|
217
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
218
|
+
try {
|
|
219
|
+
const hint = def ? "Y/n" : "y/N";
|
|
220
|
+
const ans = (await rl.question(`${q} [${hint}]: `)).trim().toLowerCase();
|
|
221
|
+
if (!ans) return def;
|
|
222
|
+
return ans === "y" || ans === "yes";
|
|
223
|
+
} finally {
|
|
224
|
+
rl.close();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function promptClient(): Promise<string | null> {
|
|
229
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
230
|
+
try {
|
|
231
|
+
const ans = (
|
|
232
|
+
await rl.question(
|
|
233
|
+
"Which editor? (1) VS Code (2) Cursor (3) opencode (4) Visual Studio (5) skip [1]: ",
|
|
234
|
+
)
|
|
235
|
+
).trim();
|
|
236
|
+
switch (ans) {
|
|
237
|
+
case "":
|
|
238
|
+
case "1":
|
|
239
|
+
return "vscode";
|
|
240
|
+
case "2":
|
|
241
|
+
return "cursor";
|
|
242
|
+
case "3":
|
|
243
|
+
return "opencode";
|
|
244
|
+
case "4":
|
|
245
|
+
return "visualstudio";
|
|
246
|
+
case "5":
|
|
247
|
+
return null;
|
|
248
|
+
default:
|
|
249
|
+
console.log(` ? unknown choice "${ans}" — editor setup skipped`);
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
} finally {
|
|
253
|
+
rl.close();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function initProject(opts: {
|
|
258
|
+
client?: string;
|
|
259
|
+
force: boolean;
|
|
260
|
+
yes: boolean;
|
|
261
|
+
}): Promise<void> {
|
|
262
|
+
const interactive = !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY;
|
|
263
|
+
let client = normalizeClient(opts.client ?? "");
|
|
264
|
+
if (client && !(INIT_CLIENTS as readonly string[]).includes(client)) {
|
|
265
|
+
console.error(`unknown client "${opts.client}" (${INIT_CLIENTS.join("|")})`);
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
if (!client && interactive) client = (await promptClient()) ?? "";
|
|
269
|
+
if (!client && !interactive) client = "vscode"; // historical default for scripts / one-shot npx
|
|
270
|
+
if (interactive) {
|
|
271
|
+
// Install-time settings (D19). Non-default answers persist to the JSONC
|
|
272
|
+
// config file; `open-memex config set` changes them later.
|
|
273
|
+
const patch: Record<string, unknown> = {};
|
|
274
|
+
const keywordCaptureEnabled = await askBool(
|
|
275
|
+
"Auto-capture keywords like 记住… / remember… into memory?",
|
|
276
|
+
DEFAULT_CONFIG.keywordCaptureEnabled,
|
|
277
|
+
);
|
|
278
|
+
if (keywordCaptureEnabled !== DEFAULT_CONFIG.keywordCaptureEnabled)
|
|
279
|
+
patch.keywordCaptureEnabled = keywordCaptureEnabled;
|
|
280
|
+
const injectOnFirstTurn = await askBool(
|
|
281
|
+
"Inject relevant memories when a session starts?",
|
|
282
|
+
DEFAULT_CONFIG.injectOnFirstTurn,
|
|
283
|
+
);
|
|
284
|
+
if (injectOnFirstTurn !== DEFAULT_CONFIG.injectOnFirstTurn)
|
|
285
|
+
patch.injectOnFirstTurn = injectOnFirstTurn;
|
|
286
|
+
if (Object.keys(patch).length > 0) {
|
|
287
|
+
const file = saveConfig(patch);
|
|
288
|
+
console.log(
|
|
289
|
+
` + settings saved to ${file} (change later with \`open-memex config set <key> <value>\`)`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const root = projectRoot();
|
|
294
|
+
console.log(`open-memex init — project root: ${root}`);
|
|
295
|
+
if (client) {
|
|
296
|
+
writeMcpJson(root, client, opts.force);
|
|
297
|
+
// copilot-instructions.md is VS Code/Cursor-shaped; opencode as a plain MCP
|
|
298
|
+
// consumer already gets the guidance from the tool descriptions (D16).
|
|
299
|
+
if (client !== "opencode") writeInstructions(root);
|
|
300
|
+
} else {
|
|
301
|
+
console.log(" - editor setup skipped");
|
|
302
|
+
}
|
|
303
|
+
console.log(`\nDone. Reload your editor window to start the open-memex MCP server.`);
|
|
304
|
+
}
|
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* open-memex generic MCP server (stdio transport).
|
|
3
|
+
*
|
|
4
|
+
* Exposes the same five memory tools as the opencode plugin
|
|
5
|
+
* (memory_add / memory_search / memory_list / memory_supersede /
|
|
6
|
+
* memory_forget) over the Model Context Protocol, so any MCP client —
|
|
7
|
+
* VS Code Copilot Chat, Cursor, Claude Code, etc. — can use open-memex
|
|
8
|
+
* without a host-specific plugin.
|
|
9
|
+
*
|
|
10
|
+
* Run: node --experimental-strip-types src/mcp.ts
|
|
11
|
+
* (or: npm run mcp)
|
|
12
|
+
*
|
|
13
|
+
* The project scope is resolved from the process working directory, so
|
|
14
|
+
* launch the server with cwd set to the project root (VS Code, Cursor and
|
|
15
|
+
* Claude Code all do this for workspace-configured MCP servers).
|
|
16
|
+
*
|
|
17
|
+
* IMPORTANT: stdout is the MCP protocol channel. Never log to stdout here;
|
|
18
|
+
* diagnostics go to stderr.
|
|
19
|
+
*/
|
|
20
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
21
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
import { loadConfig } from "./config.ts";
|
|
24
|
+
import { resolveProjectScope, PERSONAL_SCOPE, type Scope } from "./scope.ts";
|
|
25
|
+
import { db } from "./store/db.ts";
|
|
26
|
+
import { syncScope } from "./store/sync.ts";
|
|
27
|
+
import {
|
|
28
|
+
addMemory,
|
|
29
|
+
searchMemories,
|
|
30
|
+
listMemories,
|
|
31
|
+
supersedeMemory,
|
|
32
|
+
forgetMemory,
|
|
33
|
+
memoryAddArgs,
|
|
34
|
+
memorySearchArgs,
|
|
35
|
+
memoryListArgs,
|
|
36
|
+
memorySupersedeArgs,
|
|
37
|
+
memoryForgetArgs,
|
|
38
|
+
TOOL_DESCRIPTIONS,
|
|
39
|
+
type ToolResult,
|
|
40
|
+
} from "./tools/ops.ts";
|
|
41
|
+
|
|
42
|
+
const SERVER_VERSION = "0.2.0-alpha";
|
|
43
|
+
|
|
44
|
+
/** Adapt a framework-agnostic op result to an MCP tool response. */
|
|
45
|
+
function toMcp(p: Promise<ToolResult>) {
|
|
46
|
+
return p.then(
|
|
47
|
+
(r) => ({ content: [{ type: "text" as const, text: r.output }] }),
|
|
48
|
+
(e: unknown) => ({
|
|
49
|
+
content: [
|
|
50
|
+
{
|
|
51
|
+
type: "text" as const,
|
|
52
|
+
text: `open-memex error: ${(e as Error)?.message ?? String(e)}`,
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
isError: true as const,
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function runMcpServer() {
|
|
61
|
+
const cfg = loadConfig();
|
|
62
|
+
const scope: Scope = resolveProjectScope(process.cwd());
|
|
63
|
+
const getScope = () => scope;
|
|
64
|
+
|
|
65
|
+
// Init DB and one-shot sync of markdown -> index, mirroring the plugin.
|
|
66
|
+
db();
|
|
67
|
+
syncScope(scope.key);
|
|
68
|
+
syncScope(PERSONAL_SCOPE.key);
|
|
69
|
+
console.error(`[open-memex] MCP server up. scope=${scope.key}`);
|
|
70
|
+
|
|
71
|
+
const server = new McpServer({ name: "open-memex", version: SERVER_VERSION });
|
|
72
|
+
|
|
73
|
+
server.registerTool(
|
|
74
|
+
"memory_add",
|
|
75
|
+
{
|
|
76
|
+
description: TOOL_DESCRIPTIONS.memory_add,
|
|
77
|
+
inputSchema: z.object(memoryAddArgs),
|
|
78
|
+
},
|
|
79
|
+
(args) => toMcp(addMemory(getScope, cfg, args)),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
server.registerTool(
|
|
83
|
+
"memory_search",
|
|
84
|
+
{
|
|
85
|
+
description: TOOL_DESCRIPTIONS.memory_search,
|
|
86
|
+
inputSchema: z.object(memorySearchArgs),
|
|
87
|
+
annotations: { readOnlyHint: true },
|
|
88
|
+
},
|
|
89
|
+
(args) => toMcp(searchMemories(getScope, args)),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
server.registerTool(
|
|
93
|
+
"memory_list",
|
|
94
|
+
{
|
|
95
|
+
description: TOOL_DESCRIPTIONS.memory_list,
|
|
96
|
+
inputSchema: z.object(memoryListArgs),
|
|
97
|
+
annotations: { readOnlyHint: true },
|
|
98
|
+
},
|
|
99
|
+
(args) => toMcp(listMemories(getScope, args)),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
server.registerTool(
|
|
103
|
+
"memory_supersede",
|
|
104
|
+
{
|
|
105
|
+
description: TOOL_DESCRIPTIONS.memory_supersede,
|
|
106
|
+
inputSchema: z.object(memorySupersedeArgs),
|
|
107
|
+
},
|
|
108
|
+
(args) => toMcp(supersedeMemory(cfg, args)),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
server.registerTool(
|
|
112
|
+
"memory_forget",
|
|
113
|
+
{
|
|
114
|
+
description: TOOL_DESCRIPTIONS.memory_forget,
|
|
115
|
+
inputSchema: z.object(memoryForgetArgs),
|
|
116
|
+
annotations: { destructiveHint: true },
|
|
117
|
+
},
|
|
118
|
+
(args) => toMcp(forgetMemory(args)),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const transport = new StdioServerTransport();
|
|
122
|
+
await server.connect(transport);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Standalone entry: `node --experimental-strip-types src/mcp.ts`.
|
|
126
|
+
// The CLI (`open-memex mcp`) imports runMcpServer() instead.
|
|
127
|
+
import { pathToFileURL } from "node:url";
|
|
128
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
129
|
+
runMcpServer().catch((err) => {
|
|
130
|
+
console.error("[open-memex] MCP server failed:", err);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
});
|
|
133
|
+
}
|
package/src/redact.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Redaction: secrets must never land in memory files or the index
|
|
2
|
+
* Redaction: secrets must never land in memory files or the index in
|
|
3
|
+
* readable form.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
+
* Layers, in order:
|
|
5
6
|
* 1. <private>...</private> regions are stripped first (explicit opt-out —
|
|
6
7
|
* the author marked this span as sensitive, so it is replaced with
|
|
7
8
|
* [REDACTED] before any detection runs).
|
|
@@ -10,8 +11,11 @@
|
|
|
10
11
|
* 4. High-entropy assignment heuristic (catches secrets whose provider we
|
|
11
12
|
* don't have a pattern for, e.g. `deploy_key = "aB3d..."`).
|
|
12
13
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
14
|
+
* A detected secret does NOT refuse the write: the matched string is masked
|
|
15
|
+
* in place — first 4 characters kept, the rest replaced with 'x' — and the
|
|
16
|
+
* write proceeds with the masked content. hadSecret reports that masking
|
|
17
|
+
* happened so callers can surface a notice. A partially-masked memory still
|
|
18
|
+
* identifies which credential it referred to without storing the secret.
|
|
15
19
|
*/
|
|
16
20
|
|
|
17
21
|
export interface SecretPattern {
|
|
@@ -21,6 +25,13 @@ export interface SecretPattern {
|
|
|
21
25
|
source: string;
|
|
22
26
|
/** Optional RegExp flags, e.g. "i". */
|
|
23
27
|
flags?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Capture-group index holding the secret value. When set, masking replaces
|
|
30
|
+
* only that group — the credential name stays readable (D14: a masked
|
|
31
|
+
* memory should still identify which key it referred to). Default: mask
|
|
32
|
+
* the whole match.
|
|
33
|
+
*/
|
|
34
|
+
valueGroup?: number;
|
|
24
35
|
}
|
|
25
36
|
|
|
26
37
|
/**
|
|
@@ -56,8 +67,9 @@ export const BUILTIN_SECRET_PATTERNS: SecretPattern[] = [
|
|
|
56
67
|
{
|
|
57
68
|
id: "aws-secret-access-key",
|
|
58
69
|
source:
|
|
59
|
-
"aws[_-]?secret[_-]?access[_-]?key[\"']?\\s*[:=]\\s*[\"']?[A-Za-z0-9/+=]{40}",
|
|
70
|
+
"(aws[_-]?secret[_-]?access[_-]?key)([\"']?\\s*[:=]\\s*[\"']?)([A-Za-z0-9/+=]{40})",
|
|
60
71
|
flags: "i",
|
|
72
|
+
valueGroup: 3,
|
|
61
73
|
},
|
|
62
74
|
{ id: "slack-token", source: `${TOKEN_BOUNDARY}xox[baprs]-[A-Za-z0-9-]{10,}` },
|
|
63
75
|
{ id: "google-api-key", source: `${TOKEN_BOUNDARY}AIza[0-9A-Za-z_-]{30,}` },
|
|
@@ -71,7 +83,19 @@ export const BUILTIN_SECRET_PATTERNS: SecretPattern[] = [
|
|
|
71
83
|
id: "stripe-webhook-secret",
|
|
72
84
|
source: `${TOKEN_BOUNDARY}whsec_[A-Za-z0-9]{20,}`,
|
|
73
85
|
},
|
|
74
|
-
{
|
|
86
|
+
{
|
|
87
|
+
id: "private-key-block",
|
|
88
|
+
// Whole block: masking only the BEGIN header would leave the base64 body
|
|
89
|
+
// readable in the memory file. Non-greedy so two blocks mask separately.
|
|
90
|
+
source:
|
|
91
|
+
"-----BEGIN [A-Z ]*PRIVATE KEY-----[\\s\\S]*?-----END [A-Z ]*PRIVATE KEY-----",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: "private-key-truncated",
|
|
95
|
+
// No END marker: mask from the header to end of text. A header without a
|
|
96
|
+
// body is still key material; over-masking is the safe direction.
|
|
97
|
+
source: "-----BEGIN [A-Z ]*PRIVATE KEY-----[\\s\\S]*$",
|
|
98
|
+
},
|
|
75
99
|
{
|
|
76
100
|
id: "jwt",
|
|
77
101
|
source: `${TOKEN_BOUNDARY}eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}`,
|
|
@@ -79,8 +103,9 @@ export const BUILTIN_SECRET_PATTERNS: SecretPattern[] = [
|
|
|
79
103
|
{
|
|
80
104
|
id: "generic-secret-assignment",
|
|
81
105
|
source:
|
|
82
|
-
"(api[_-]?key|secret|passwd|password|auth[_-]?token|access[_-]?token)[\"']?\\s*[:=]\\s*[\"']?[A-Za-z0-9_\\-./+=]{16,}[\"']?",
|
|
106
|
+
"(api[_-]?key|secret|passwd|password|auth[_-]?token|access[_-]?token)([\"']?\\s*[:=]\\s*[\"']?)([A-Za-z0-9_\\-./+=]{16,})([\"']?)",
|
|
83
107
|
flags: "i",
|
|
108
|
+
valueGroup: 3,
|
|
84
109
|
},
|
|
85
110
|
];
|
|
86
111
|
|
|
@@ -139,12 +164,31 @@ export function findHighEntropySecret(text: string): string | null {
|
|
|
139
164
|
for (const m of text.matchAll(ASSIGNMENT_RE)) {
|
|
140
165
|
const name = m[1];
|
|
141
166
|
const value = m[2];
|
|
142
|
-
if (value.
|
|
143
|
-
|
|
167
|
+
if (!isSuspectAssignmentValue(value, m[0], m.index ?? 0, text)) continue;
|
|
168
|
+
return `high-entropy-secret:${name}`;
|
|
144
169
|
}
|
|
145
170
|
return null;
|
|
146
171
|
}
|
|
147
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Shared benign-value rule for detection AND masking: a URL (e.g. the value
|
|
175
|
+
* after "https:") or a low-entropy value must never be touched. Note the
|
|
176
|
+
* "://" check: ASSIGNMENT_RE consumes the colon of "https:" as the separator,
|
|
177
|
+
* so the captured value starts with "//..." — check for "://" in the
|
|
178
|
+
* original text around the match, not just inside the value.
|
|
179
|
+
*/
|
|
180
|
+
function isSuspectAssignmentValue(value: string, fullMatch: string, offset: number, text: string): boolean {
|
|
181
|
+
// Bare URL: ASSIGNMENT_RE consumed the scheme colon ("https:") as the
|
|
182
|
+
// separator, so the match itself starts with "scheme://".
|
|
183
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(fullMatch)) return false;
|
|
184
|
+
if (value.includes("://")) return false;
|
|
185
|
+
// Reconstruct what preceded the value inside the match (name + separator);
|
|
186
|
+
// if the text right before the value looks like scheme://, it is a URL.
|
|
187
|
+
const before = text.slice(Math.max(0, offset - 12), offset);
|
|
188
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:(\/\/)?$/.test(before.trim()) || before.includes("://")) return false;
|
|
189
|
+
return shannonEntropy(value) >= 4.5;
|
|
190
|
+
}
|
|
191
|
+
|
|
148
192
|
export function stripPrivate(text: string): string {
|
|
149
193
|
// Closed pairs first...
|
|
150
194
|
let out = text.replace(/<private>[\s\S]*?<\/private>/gi, "[REDACTED]");
|
|
@@ -154,18 +198,78 @@ export function stripPrivate(text: string): string {
|
|
|
154
198
|
return out;
|
|
155
199
|
}
|
|
156
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Mask a matched secret: keep the first 4 characters, replace the rest
|
|
203
|
+
* with 'x' (length-preserving). "sk-1234567890abcdefghij" → "sk-1xxxxxxxxxxxxx".
|
|
204
|
+
*/
|
|
205
|
+
function maskMatch(m: string): string {
|
|
206
|
+
return m.slice(0, 4) + "x".repeat(Math.max(0, m.length - 4));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Apply the builtin patterns as masking (global, all matches). */
|
|
210
|
+
function maskBuiltin(content: string): string {
|
|
211
|
+
let out = content;
|
|
212
|
+
for (const p of BUILTIN_SECRET_PATTERNS) {
|
|
213
|
+
const re = compile(p);
|
|
214
|
+
if (!re) continue;
|
|
215
|
+
const g = new RegExp(re.source, re.flags + "g");
|
|
216
|
+
if (p.valueGroup == null) {
|
|
217
|
+
out = out.replace(g, (m) => maskMatch(m));
|
|
218
|
+
} else {
|
|
219
|
+
// Mask only the secret-value group; the credential name stays readable.
|
|
220
|
+
out = out.replace(g, (...args: unknown[]) => {
|
|
221
|
+
const m = args[0] as string;
|
|
222
|
+
const val = args[p.valueGroup as number] as string | undefined;
|
|
223
|
+
if (!val) return m;
|
|
224
|
+
const idx = m.lastIndexOf(val);
|
|
225
|
+
if (idx < 0) return m;
|
|
226
|
+
return m.slice(0, idx) + maskMatch(val) + m.slice(idx + val.length);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Apply user patterns as masking. Invalid regexes are ignored. */
|
|
234
|
+
function maskUser(content: string, patterns: string[]): string {
|
|
235
|
+
let out = content;
|
|
236
|
+
for (const p of patterns) {
|
|
237
|
+
try {
|
|
238
|
+
out = out.replace(new RegExp(p, "g"), (m) => maskMatch(m));
|
|
239
|
+
} catch {
|
|
240
|
+
// ignore malformed regex
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Mask the value side of high-entropy assignments (name stays readable).
|
|
247
|
+
* Uses the exact same benign-value rule as detection, so URLs and
|
|
248
|
+
* low-entropy values are never touched even when another secret triggers. */
|
|
249
|
+
function maskEntropy(content: string): string {
|
|
250
|
+
return content.replace(
|
|
251
|
+
ASSIGNMENT_RE,
|
|
252
|
+
(m, name: string, value: string, offset: number) => {
|
|
253
|
+
if (!isSuspectAssignmentValue(value, m, offset, content)) return m;
|
|
254
|
+
return m.split(value).join(maskMatch(value));
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
157
259
|
export function redact(
|
|
158
260
|
text: string,
|
|
159
261
|
patterns: string[],
|
|
160
262
|
): { content: string; hadSecret: boolean; matchedPattern: string | null } {
|
|
161
263
|
const stripped = stripPrivate(text);
|
|
264
|
+
// Detection (for reporting) runs in priority order: builtin → user → entropy.
|
|
162
265
|
const builtin = findBuiltinSecret(stripped);
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
266
|
+
const user = builtin ? null : findSecret(stripped, patterns);
|
|
267
|
+
const entropic =
|
|
268
|
+
builtin || user ? null : findHighEntropySecret(stripped);
|
|
269
|
+
const matched = builtin ?? user ?? entropic;
|
|
270
|
+
if (!matched) return { content: stripped, hadSecret: false, matchedPattern: null };
|
|
271
|
+
// Masking runs every family over the text (not just the reported one) so
|
|
272
|
+
// multiple credentials in one memory are all masked.
|
|
273
|
+
const masked = maskEntropy(maskUser(maskBuiltin(stripped), patterns));
|
|
274
|
+
return { content: masked, hadSecret: true, matchedPattern: matched };
|
|
171
275
|
}
|