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/scripts/smoke-pure.ts
CHANGED
|
@@ -85,7 +85,8 @@ const builtinSamples: Array<[string, string]> = [
|
|
|
85
85
|
["npm-token", "t=npm_12345678901234567890123456789012"],
|
|
86
86
|
["gitlab-pat", "t=glpat-12345678901234567890ab"],
|
|
87
87
|
["stripe-webhook-secret", "s=whsec_12345678901234567890"],
|
|
88
|
-
["private-key-block", "-----BEGIN RSA PRIVATE KEY-----"],
|
|
88
|
+
["private-key-block", "-----BEGIN RSA PRIVATE KEY-----\nQUJD\n-----END RSA PRIVATE KEY-----"],
|
|
89
|
+
["private-key-truncated", "-----BEGIN RSA PRIVATE KEY-----"],
|
|
89
90
|
["generic-secret-assignment", 'password = "hunter2hunter2hunter2"'],
|
|
90
91
|
];
|
|
91
92
|
for (const [id, sample] of builtinSamples) {
|
|
@@ -142,6 +143,46 @@ ok("wrapped secret does not trigger", r4.hadSecret === false);
|
|
|
142
143
|
const r5 = redact("foo CUSTOM123 bar", ["CUSTOM\\d+"]);
|
|
143
144
|
ok("user pattern detected", r5.hadSecret === true && r5.matchedPattern === "CUSTOM\\d+", r5.matchedPattern);
|
|
144
145
|
|
|
146
|
+
console.log("== redact masking (D14) ==");
|
|
147
|
+
// Secrets are masked in place (first 4 chars kept, rest → x), never refused.
|
|
148
|
+
const m1 = redact("deploy uses key=sk-1234567890abcdefghij1234 in prod", []);
|
|
149
|
+
ok("mask: detected", m1.hadSecret === true && m1.matchedPattern === "openai-key", m1.matchedPattern);
|
|
150
|
+
ok("mask: prefix kept", m1.content.includes("sk-1"), m1.content);
|
|
151
|
+
ok("mask: rest x'd", m1.content.includes("sk-1xxxxxxxxxxxxxxxxxxxxx"), m1.content);
|
|
152
|
+
ok("mask: full secret gone", !m1.content.includes("1234567890abcdefghij"));
|
|
153
|
+
ok("mask: surrounding text kept", m1.content.startsWith("deploy uses key=") && m1.content.includes(" in prod"));
|
|
154
|
+
// User patterns mask too.
|
|
155
|
+
ok("mask: user pattern", redact("foo CUSTOM123 bar", ["CUSTOM\\d+"]).content.includes("CUSTxxxxx"));
|
|
156
|
+
// High-entropy: value masked, name kept.
|
|
157
|
+
const r6 = redact('deploy_key = "aB3dE5fG7hJ9kL2mN4pQ6rS8tU0vW2xY4zA6bC8dE0"', []);
|
|
158
|
+
ok("mask: entropy value masked", r6.content.includes("aB3dxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"), r6.content);
|
|
159
|
+
ok("mask: entropy name kept", r6.content.includes("deploy_key ="));
|
|
160
|
+
// URLs are never masked, even when another secret is present in the same text.
|
|
161
|
+
const r7 = redact("see https://api.example.com/v1/users/list and key=sk-1234567890abcdefghij1234", []);
|
|
162
|
+
ok("mask: mixed url+secret detected", r7.hadSecret === true, r7.matchedPattern);
|
|
163
|
+
ok("mask: url intact", r7.content.includes("https://api.example.com/v1/users/list"), r7.content);
|
|
164
|
+
ok("mask: second secret also masked", !r7.content.includes("sk-1234567890"), r7.content);
|
|
165
|
+
// Bare high-entropy URL: the scheme colon must not turn the URL into a secret.
|
|
166
|
+
const r8 = redact("see https://aB3dEfGhIjKlMnOpQrStUvWx0123456789abcdef for details", []);
|
|
167
|
+
ok("mask: bare url untouched", r8.hadSecret === false && r8.content.includes("https://aB3dEfGhIjKlMnOpQrStUvWx0123456789abcdef"), r8.content);
|
|
168
|
+
// Private key: the WHOLE block is masked, body must not survive in readable form.
|
|
169
|
+
const pemBody = "MIIEpAIBAAKCAQEA7bXprGBcW2l5K3R8vN0mQw0F3xY2vBn5T6uI7oP8a9S0dF1gH";
|
|
170
|
+
const r9 = redact(`note: server key\n-----BEGIN RSA PRIVATE KEY-----\n${pemBody}\n-----END RSA PRIVATE KEY-----\nafter`, []);
|
|
171
|
+
ok("mask: pem detected", r9.hadSecret === true && r9.matchedPattern === "private-key-block", r9.matchedPattern);
|
|
172
|
+
ok("mask: pem body gone", !r9.content.includes(pemBody), r9.content);
|
|
173
|
+
ok("mask: pem surrounding kept", r9.content.includes("note: server key") && r9.content.includes("after"));
|
|
174
|
+
// Truncated key (no END marker): masked to end of text.
|
|
175
|
+
const r10 = redact(`note\n-----BEGIN RSA PRIVATE KEY-----\n${pemBody}\ntrailing prose`, []);
|
|
176
|
+
ok("mask: truncated pem detected", r10.hadSecret === true, r10.matchedPattern);
|
|
177
|
+
ok("mask: truncated pem body gone", !r10.content.includes(pemBody) && !r10.content.includes("trailing prose"), r10.content);
|
|
178
|
+
// Name-including patterns mask the value only; the name stays readable (D14).
|
|
179
|
+
const r11 = redact('api_key = "supersecretvalue123456"', []);
|
|
180
|
+
ok("mask: generic name kept", r11.content.includes("api_key ="), r11.content);
|
|
181
|
+
ok("mask: generic value masked", !r11.content.includes("supersecretvalue123456") && r11.content.includes("supexxxxxxxxxxxxxxxx"), r11.content);
|
|
182
|
+
const r12 = redact('aws_secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCY1234567890"', []);
|
|
183
|
+
ok("mask: aws name kept", r12.content.includes("aws_secret_access_key ="), r12.content);
|
|
184
|
+
ok("mask: aws value masked", !r12.content.includes("wJalrXUtnFEMI"), r12.content);
|
|
185
|
+
|
|
145
186
|
console.log("== keywords ==");
|
|
146
187
|
const hits1 = detectKeywords("remember that this repo uses Bun, not Node", DEFAULT_CONFIG);
|
|
147
188
|
ok("basic remember matched", hits1.length === 1);
|
package/src/capture/keywords.ts
CHANGED
|
@@ -3,32 +3,45 @@ import type { MyOMemoryConfig } from "../config.ts";
|
|
|
3
3
|
export interface KeywordHit {
|
|
4
4
|
content: string;
|
|
5
5
|
pattern: string;
|
|
6
|
+
/** True when the hit came from a personal-scope pattern: force personal scope. */
|
|
7
|
+
personal: boolean;
|
|
6
8
|
}
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* Scan user text for memory-capture keyword triggers.
|
|
10
12
|
* Each pattern must have a single capture group whose value becomes the memory body.
|
|
13
|
+
* Personal patterns (cfg.keywordPersonalPatterns) force the personal scope.
|
|
14
|
+
*
|
|
15
|
+
* Personal patterns run first and claim their line: an explicit scope signal
|
|
16
|
+
* (e.g. "记住我对花粉过敏") must not also fire the generic 记住 into the project scope.
|
|
11
17
|
*/
|
|
12
18
|
export function detectKeywords(userText: string, cfg: MyOMemoryConfig): KeywordHit[] {
|
|
13
19
|
if (!cfg.keywordCaptureEnabled) return [];
|
|
14
20
|
const hits: KeywordHit[] = [];
|
|
15
21
|
const lines = userText.split(/\r?\n/);
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
re
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
22
|
+
const claimed = new Set<number>();
|
|
23
|
+
const scan = (patterns: string[], personal: boolean, skipClaimed: boolean) => {
|
|
24
|
+
for (const src of patterns) {
|
|
25
|
+
let re: RegExp;
|
|
26
|
+
try {
|
|
27
|
+
re = new RegExp(src, "i");
|
|
28
|
+
} catch {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
for (let i = 0; i < lines.length; i++) {
|
|
32
|
+
if (skipClaimed && claimed.has(i)) continue;
|
|
33
|
+
const m = lines[i].match(re);
|
|
34
|
+
if (!m || !m[1]) continue;
|
|
35
|
+
const content = m[1].trim().replace(/[.!?]$/, "").trim();
|
|
36
|
+
if (content.length >= 3 && content.length <= 2000) {
|
|
37
|
+
hits.push({ content, pattern: src, personal });
|
|
38
|
+
if (personal) claimed.add(i);
|
|
39
|
+
break; // one hit per pattern per message
|
|
40
|
+
}
|
|
30
41
|
}
|
|
31
42
|
}
|
|
32
|
-
}
|
|
43
|
+
};
|
|
44
|
+
scan(cfg.keywordPersonalPatterns, true, false);
|
|
45
|
+
scan(cfg.keywordPatterns, false, true);
|
|
33
46
|
return hits;
|
|
34
47
|
}
|
package/src/cli.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import { loadConfig } from "./config.ts";
|
|
19
19
|
import { paths } from "./paths.ts";
|
|
20
20
|
import { redact } from "./redact.ts";
|
|
21
|
+
import { resolveMcpCommand } from "./init.ts";
|
|
21
22
|
import fs from "node:fs";
|
|
22
23
|
|
|
23
24
|
function usage(): never {
|
|
@@ -36,6 +37,25 @@ Usage:
|
|
|
36
37
|
node --experimental-strip-types src/cli.ts migrate [--from <key>] [--to <key>]
|
|
37
38
|
[--dry-run] [--on-conflict newer|overwrite|skip]
|
|
38
39
|
node --experimental-strip-types src/cli.ts migrate --to-v2 [--dry-run]
|
|
40
|
+
node --experimental-strip-types src/cli.ts mcp [--print-config vscode|cursor|claude|opencode|visualstudio]
|
|
41
|
+
node --experimental-strip-types src/cli.ts init [--client vscode|cursor|opencode|visualstudio] [--force] [--yes]
|
|
42
|
+
node --experimental-strip-types src/cli.ts config [set <key> <value>]
|
|
43
|
+
node --experimental-strip-types src/cli.ts capture --dry-run "text"
|
|
44
|
+
node --experimental-strip-types src/cli.ts doctor
|
|
45
|
+
|
|
46
|
+
One-command project setup: \`open-memex init\` (or \`npx open-memex@alpha init\`) writes
|
|
47
|
+
the MCP config for your editor (\`.vscode/mcp.json\`, \`.cursor/mcp.json\`,
|
|
48
|
+
\`opencode.jsonc\`, or Visual Studio's solution-level \`.mcp.json\`) plus
|
|
49
|
+
\`.github/copilot-instructions.md\` — no copy-paste needed.
|
|
50
|
+
Existing files are merged, never clobbered; re-running is safe. On a terminal it
|
|
51
|
+
asks which editor to set up and a couple of settings (keyword capture, first-turn
|
|
52
|
+
injection); \`--yes\` accepts all defaults, and non-terminal runs never prompt.
|
|
53
|
+
\`open-memex config set <key> <value>\` changes those settings after install.
|
|
54
|
+
|
|
55
|
+
Once installed globally (\`npm i -g open-memex@alpha\`) the \`open-memex\` command is
|
|
56
|
+
available directly: \`open-memex mcp\` starts the stdio MCP server (same five
|
|
57
|
+
memory_* tools as the opencode plugin); \`open-memex mcp --print-config <client>\`
|
|
58
|
+
prints a copy-paste MCP client config snippet.
|
|
39
59
|
|
|
40
60
|
Scope defaults to \`project\` (derived from cwd's git remote or path).
|
|
41
61
|
\`user\` is accepted as a deprecated alias of \`personal\`.
|
|
@@ -74,6 +94,89 @@ function resolveCliScope(flags: Record<string, string>, project: Scope): Scope {
|
|
|
74
94
|
: project;
|
|
75
95
|
}
|
|
76
96
|
|
|
97
|
+
/** Print a copy-paste MCP client config snippet. Requires a global install
|
|
98
|
+
* (`npm i -g open-memex@alpha`) so the `open-memex` command is on PATH. */
|
|
99
|
+
function printMcpConfig(client: string): never {
|
|
100
|
+
const c = client.toLowerCase();
|
|
101
|
+
// D17: resolve the server command the same way `init` does.
|
|
102
|
+
const mc = resolveMcpCommand();
|
|
103
|
+
if (c === "vscode") {
|
|
104
|
+
console.log(
|
|
105
|
+
JSON.stringify(
|
|
106
|
+
{
|
|
107
|
+
servers: {
|
|
108
|
+
"open-memex": {
|
|
109
|
+
type: "stdio",
|
|
110
|
+
command: mc.command,
|
|
111
|
+
args: mc.args,
|
|
112
|
+
cwd: "${workspaceFolder}",
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
null,
|
|
117
|
+
2,
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
} else if (c === "cursor") {
|
|
121
|
+
console.log(
|
|
122
|
+
JSON.stringify(
|
|
123
|
+
{
|
|
124
|
+
mcpServers: {
|
|
125
|
+
"open-memex": { command: mc.command, args: mc.args },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
null,
|
|
129
|
+
2,
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
} else if (c === "claude") {
|
|
133
|
+
console.log(`claude mcp add open-memex -- ${mc.command} ${mc.args.join(" ")}`);
|
|
134
|
+
} else if (c === "opencode") {
|
|
135
|
+
// D16: opencode as a plain MCP consumer (alternative to the native plugin).
|
|
136
|
+
console.log(
|
|
137
|
+
JSON.stringify(
|
|
138
|
+
{
|
|
139
|
+
mcp: {
|
|
140
|
+
"open-memex": {
|
|
141
|
+
type: "local",
|
|
142
|
+
command: [mc.command, ...mc.args],
|
|
143
|
+
enabled: true,
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
null,
|
|
148
|
+
2,
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
} else if (c === "visualstudio" || c === "visual-studio") {
|
|
152
|
+
// D20: Visual Studio (Windows-only) reads solution-level `.mcp.json`.
|
|
153
|
+
console.log(
|
|
154
|
+
JSON.stringify(
|
|
155
|
+
{
|
|
156
|
+
servers: {
|
|
157
|
+
"open-memex": {
|
|
158
|
+
type: "stdio",
|
|
159
|
+
command: mc.command,
|
|
160
|
+
args: mc.args,
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
null,
|
|
165
|
+
2,
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
} else {
|
|
169
|
+
console.error(`unknown client "${client}" (vscode|cursor|claude|opencode|visualstudio)`);
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
if (!mc.durable) {
|
|
173
|
+
console.error(
|
|
174
|
+
`\n# note: no durable \`open-memex\` on PATH — snippet uses npx. \`npm i -g open-memex@alpha\` for faster startup.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
process.exit(0);
|
|
178
|
+
}
|
|
179
|
+
|
|
77
180
|
async function main() {
|
|
78
181
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
79
182
|
if (!cmd) usage();
|
|
@@ -113,6 +216,105 @@ async function main() {
|
|
|
113
216
|
return;
|
|
114
217
|
}
|
|
115
218
|
|
|
219
|
+
// `init` is a pure file operation (§17 adoption path) — no DB needed.
|
|
220
|
+
// Interactive when on a TTY (asks editor + settings); --yes skips prompts.
|
|
221
|
+
if (cmd === "init") {
|
|
222
|
+
const flags = parseFlags(rest);
|
|
223
|
+
const { initProject } = await import("./init.ts");
|
|
224
|
+
await initProject({
|
|
225
|
+
client: flags["client"],
|
|
226
|
+
force: flags["force"] === "true",
|
|
227
|
+
yes: flags["yes"] === "true",
|
|
228
|
+
});
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// `doctor` runs environment health checks — no DB needed (it self-contains).
|
|
233
|
+
if (cmd === "doctor") {
|
|
234
|
+
const { runDoctor } = await import("./doctor.ts");
|
|
235
|
+
const ok = await runDoctor();
|
|
236
|
+
if (!ok) process.exitCode = 1;
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// `config` prints the effective configuration (defaults + file). No DB needed.
|
|
241
|
+
// `config set <key> <value>` persists a setting to the config file.
|
|
242
|
+
if (cmd === "config") {
|
|
243
|
+
if (rest[0] === "set") {
|
|
244
|
+
const [, key, ...valueParts] = rest;
|
|
245
|
+
const { SETTABLE_KEYS, saveConfig, configFilePath } = await import("./config.ts");
|
|
246
|
+
const validate = key ? SETTABLE_KEYS[key] : undefined;
|
|
247
|
+
if (!validate) {
|
|
248
|
+
console.error(
|
|
249
|
+
`unknown or unsettable key "${key ?? ""}". Settable keys: ${Object.keys(SETTABLE_KEYS).join(", ")}`,
|
|
250
|
+
);
|
|
251
|
+
process.exit(1);
|
|
252
|
+
}
|
|
253
|
+
const raw = valueParts.join(" ");
|
|
254
|
+
if (!raw) {
|
|
255
|
+
console.error(`usage: open-memex config set <key> <value>`);
|
|
256
|
+
process.exit(1);
|
|
257
|
+
}
|
|
258
|
+
let value: unknown = raw;
|
|
259
|
+
try {
|
|
260
|
+
value = JSON.parse(raw);
|
|
261
|
+
} catch {
|
|
262
|
+
/* keep as string */
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
const saved = validate(value);
|
|
266
|
+
const file = saveConfig({ [key!]: saved });
|
|
267
|
+
console.log(`set ${key} = ${JSON.stringify(saved)} (${file})`);
|
|
268
|
+
} catch (err) {
|
|
269
|
+
console.error(`invalid value for ${key}: ${(err as Error).message}`);
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const cfg = loadConfig();
|
|
275
|
+
console.log(JSON.stringify(cfg, null, 2));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// `capture --dry-run "text"` previews keyword capture without writing. No DB needed.
|
|
280
|
+
if (cmd === "capture") {
|
|
281
|
+
if (rest[0] !== "--dry-run") {
|
|
282
|
+
console.error(`usage: open-memex capture --dry-run "text"`);
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
const cfg = loadConfig();
|
|
286
|
+
const { detectKeywords } = await import("./capture/keywords.ts");
|
|
287
|
+
const text = rest.slice(1).join(" ");
|
|
288
|
+
const hits = detectKeywords(text, cfg);
|
|
289
|
+
if (hits.length === 0) {
|
|
290
|
+
console.log("no keyword triggers — nothing would be captured.");
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
for (const h of hits) {
|
|
294
|
+
const r = redact(h.content, cfg.redactPatterns);
|
|
295
|
+
console.log(`- pattern: ${h.pattern}`);
|
|
296
|
+
console.log(` scope: ${h.personal ? "personal (forced by pattern)" : "current scope"}`);
|
|
297
|
+
console.log(` secret hit: ${r.hadSecret ? `yes (${r.matchedPattern}) — will be masked` : "no"}`);
|
|
298
|
+
console.log(
|
|
299
|
+
` body: ${r.content.slice(0, 160)}${r.content.length > 160 ? "…" : ""}`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// `mcp` starts the stdio MCP server (same tools as the opencode plugin).
|
|
306
|
+
// Branched before db() — runMcpServer() does its own init, and stdout must
|
|
307
|
+
// stay clean for the MCP protocol.
|
|
308
|
+
if (cmd === "mcp") {
|
|
309
|
+
const flags = parseFlags(rest);
|
|
310
|
+
if (flags["print-config"]) {
|
|
311
|
+
printMcpConfig(flags["print-config"] === "true" ? "vscode" : flags["print-config"]);
|
|
312
|
+
}
|
|
313
|
+
const { runMcpServer } = await import("./mcp.ts");
|
|
314
|
+
await runMcpServer();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
116
318
|
db();
|
|
117
319
|
|
|
118
320
|
if (cmd === "where") {
|
|
@@ -188,12 +390,9 @@ async function main() {
|
|
|
188
390
|
const flags = parseFlags(rest.slice(1));
|
|
189
391
|
const s = resolveCliScope(flags, project);
|
|
190
392
|
const { content: red, hadSecret, matchedPattern } = redact(content, cfg.redactPatterns);
|
|
191
|
-
if (hadSecret) {
|
|
192
|
-
console.error(`refused: content matched secret pattern (${matchedPattern}).`);
|
|
193
|
-
process.exit(2);
|
|
194
|
-
}
|
|
195
393
|
// Dedup on write (§3.4): identical content is idempotent; near-duplicates
|
|
196
394
|
// warn but still save (use `supersede` when this replaces the old one).
|
|
395
|
+
// Secrets are masked (first 4 chars kept) and the write proceeds.
|
|
197
396
|
const dups = findDuplicates(s.key, red);
|
|
198
397
|
if (dups.exact) {
|
|
199
398
|
console.log(`already exists: id=${dups.exact.id} (identical content — not duplicated)`);
|
|
@@ -227,7 +426,12 @@ async function main() {
|
|
|
227
426
|
const { filePath } = writeMemoryFile(fm, red);
|
|
228
427
|
const mf = readMemoryFile(filePath);
|
|
229
428
|
if (mf) upsertFromFile(mf);
|
|
230
|
-
console.log(`saved ${fm.id} -> ${filePath}`);
|
|
429
|
+
console.log(`saved ${fm.id} [${s.kind}] -> ${filePath}`);
|
|
430
|
+
if (hadSecret) {
|
|
431
|
+
console.log(
|
|
432
|
+
`warning: content matched secret pattern (${matchedPattern}); saved with the secret masked (first 4 chars kept).`,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
231
435
|
for (const n of dups.near) {
|
|
232
436
|
console.log(
|
|
233
437
|
`warning: similar memory exists (score ${n.score.toFixed(2)}): id=${n.id}\n ${n.snippet}\n use \`open-memex supersede ${n.id} "new content"\` if this replaces it.`,
|
|
@@ -242,10 +446,6 @@ async function main() {
|
|
|
242
446
|
if (!id || !content || content.startsWith("--")) usage();
|
|
243
447
|
const flags = parseFlags(rest.slice(2));
|
|
244
448
|
const { content: red, hadSecret, matchedPattern } = redact(content, cfg.redactPatterns);
|
|
245
|
-
if (hadSecret) {
|
|
246
|
-
console.error(`refused: content matched secret pattern (${matchedPattern}).`);
|
|
247
|
-
process.exit(2);
|
|
248
|
-
}
|
|
249
449
|
try {
|
|
250
450
|
const { oldMf, newMf } = supersede(id, {
|
|
251
451
|
body: red,
|
|
@@ -258,6 +458,11 @@ async function main() {
|
|
|
258
458
|
upsertFromFile(oldMf);
|
|
259
459
|
upsertFromFile(newMf);
|
|
260
460
|
console.log(`superseded ${oldMf.fm.id} → ${newMf.fm.id}`);
|
|
461
|
+
if (hadSecret) {
|
|
462
|
+
console.log(
|
|
463
|
+
`warning: content matched secret pattern (${matchedPattern}); saved with the secret masked (first 4 chars kept).`,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
261
466
|
} catch (e) {
|
|
262
467
|
console.error(`supersede failed: ${(e as Error).message}`);
|
|
263
468
|
process.exit(2);
|
package/src/config.ts
CHANGED
|
@@ -7,7 +7,10 @@ export interface MyOMemoryConfig {
|
|
|
7
7
|
maxProfileItems: number;
|
|
8
8
|
injectOnFirstTurn: boolean;
|
|
9
9
|
keywordCaptureEnabled: boolean;
|
|
10
|
+
/** Patterns whose capture group 1 becomes the memory body; saved to the current scope. */
|
|
10
11
|
keywordPatterns: string[];
|
|
12
|
+
/** Same shape, but hits are forced into the personal scope (e.g. "remember for me"). */
|
|
13
|
+
keywordPersonalPatterns: string[];
|
|
11
14
|
redactPatterns: string[];
|
|
12
15
|
logLevel: "debug" | "info" | "warn" | "error";
|
|
13
16
|
}
|
|
@@ -18,10 +21,27 @@ export const DEFAULT_CONFIG: MyOMemoryConfig = {
|
|
|
18
21
|
injectOnFirstTurn: true,
|
|
19
22
|
keywordCaptureEnabled: true,
|
|
20
23
|
keywordPatterns: [
|
|
21
|
-
"^\\s*remember(?:\\s+that)?[:,]?\\s+(.+)$",
|
|
24
|
+
"^\\s*remember(?!\\s+for\\s+me)(?:\\s+that)?[:,]?\\s+(.+)$",
|
|
22
25
|
"^\\s*(?:please\\s+)?(?:note|don'?t\\s+forget)(?:\\s+that)?[:,]?\\s+(.+)$",
|
|
23
26
|
"^\\s*TIL[:,]?\\s+(.+)$",
|
|
24
27
|
"^\\s*save\\s+(?:this|to\\s+memory)[:,]?\\s+(.+)$",
|
|
28
|
+
// Chinese equivalents
|
|
29
|
+
"^\\s*记住(?!(个人))[::,,]?\\s*(.+)$",
|
|
30
|
+
"^\\s*(?:请)?(?:记一下|记录一下)[::,,]?\\s*(.+)$",
|
|
31
|
+
"^\\s*别忘了[::,,]?\\s*(.+)$",
|
|
32
|
+
// First-person plural: team/project context, NOT personal
|
|
33
|
+
"^\\s*我们认为[::,,]?\\s*(.+)$",
|
|
34
|
+
"^\\s*我们决定[::,,]?\\s*(.+)$",
|
|
35
|
+
"^\\s*帮我们记(?:住|一下)?[::,,]?\\s*(.+)$",
|
|
36
|
+
],
|
|
37
|
+
keywordPersonalPatterns: [
|
|
38
|
+
"^\\s*remember\\s+for\\s+me(?:\\s+that)?[:,]?\\s+(.+)$",
|
|
39
|
+
"^\\s*记住(个人)[::,,]?\\s*(.+)$",
|
|
40
|
+
// First-person singular: personal scope ("我" → 个人, "我们" → 项目)
|
|
41
|
+
"^\\s*记住我(?!们)[::,,]?\\s*(.+)$",
|
|
42
|
+
"^\\s*替我记(?:住|一下)?[::,,]?\\s*(.+)$",
|
|
43
|
+
"^\\s*我觉得[::,,]?\\s*(.+)$",
|
|
44
|
+
"^\\s*我喜欢[::,,]?\\s*(.+)$",
|
|
25
45
|
],
|
|
26
46
|
// Built-in provider patterns now live in src/redact.ts (always on).
|
|
27
47
|
// Add only your own extra patterns here.
|
|
@@ -39,15 +59,80 @@ function stripJsonComments(raw: string): string {
|
|
|
39
59
|
return out;
|
|
40
60
|
}
|
|
41
61
|
|
|
42
|
-
|
|
62
|
+
/** Where `config set` / interactive `init` persist. Respects MY_O_MEMORY_CONFIG. */
|
|
63
|
+
export function configFilePath(): string {
|
|
64
|
+
return (
|
|
65
|
+
process.env.MY_O_MEMORY_CONFIG ??
|
|
66
|
+
path.join(os.homedir(), ".config", "opencode", "open-memex.jsonc")
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function toBool(v: unknown): boolean {
|
|
71
|
+
if (typeof v === "boolean") return v;
|
|
72
|
+
if (v === "true" || v === "1") return true;
|
|
73
|
+
if (v === "false" || v === "0") return false;
|
|
74
|
+
throw new Error("must be true/false");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function toNonNegInt(v: unknown): number {
|
|
78
|
+
const n = Number(v);
|
|
79
|
+
if (!Number.isInteger(n) || n < 0) throw new Error("must be a non-negative integer");
|
|
80
|
+
return n;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Keys users may change via `open-memex config set <key> <value>`, with validators. */
|
|
84
|
+
export const SETTABLE_KEYS: Record<string, (v: unknown) => unknown> = {
|
|
85
|
+
maxProjectMemories: toNonNegInt,
|
|
86
|
+
maxProfileItems: toNonNegInt,
|
|
87
|
+
injectOnFirstTurn: toBool,
|
|
88
|
+
keywordCaptureEnabled: toBool,
|
|
89
|
+
logLevel: (v) => {
|
|
90
|
+
if (v !== "info" && v !== "debug") throw new Error('must be "info" or "debug"');
|
|
91
|
+
return v;
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Merge a patch into the config file (creates it if missing). Returns the file path. */
|
|
96
|
+
export function saveConfig(patch: Record<string, unknown>): string {
|
|
97
|
+
const file = configFilePath();
|
|
98
|
+
let cur: Record<string, unknown> = {};
|
|
99
|
+
if (fs.existsSync(file)) {
|
|
100
|
+
try {
|
|
101
|
+
cur = JSON.parse(stripJsonComments(fs.readFileSync(file, "utf8"))) as Record<
|
|
102
|
+
string,
|
|
103
|
+
unknown
|
|
104
|
+
>;
|
|
105
|
+
} catch {
|
|
106
|
+
console.error(`[open-memex] ${file} is not valid JSONC — it will be replaced`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const next = { ...cur, ...patch };
|
|
110
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
111
|
+
fs.writeFileSync(
|
|
112
|
+
file,
|
|
113
|
+
"// Managed by `open-memex config set` / `open-memex init` — edit freely (JSONC).\n" +
|
|
114
|
+
JSON.stringify(next, null, 2) +
|
|
115
|
+
"\n",
|
|
116
|
+
);
|
|
117
|
+
return file;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Path of the config file in effect, or null when using built-in defaults. */
|
|
121
|
+
export function configSource(): string | null {
|
|
43
122
|
const candidates = [
|
|
44
123
|
process.env.MY_O_MEMORY_CONFIG,
|
|
45
124
|
path.join(os.homedir(), ".config", "opencode", "open-memex.jsonc"),
|
|
46
125
|
path.join(os.homedir(), ".config", "opencode", "open-memex.json"),
|
|
47
126
|
].filter(Boolean) as string[];
|
|
48
|
-
|
|
49
127
|
for (const p of candidates) {
|
|
50
|
-
if (
|
|
128
|
+
if (fs.existsSync(p)) return p;
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function loadConfig(): MyOMemoryConfig {
|
|
134
|
+
const p = configSource();
|
|
135
|
+
if (p) {
|
|
51
136
|
try {
|
|
52
137
|
const raw = fs.readFileSync(p, "utf8");
|
|
53
138
|
const parsed = JSON.parse(stripJsonComments(raw)) as Partial<MyOMemoryConfig>;
|
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// `open-memex doctor` — environment health checks.
|
|
2
|
+
//
|
|
3
|
+
// Read-only except that paths() and the MCP handshake may create the (empty)
|
|
4
|
+
// data directories, exactly like a normal `open-memex mcp` start would.
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { loadConfig, configSource, DEFAULT_CONFIG } from "./config.ts";
|
|
10
|
+
import { paths } from "./paths.ts";
|
|
11
|
+
import { resolveCwdScope } from "./scope.ts";
|
|
12
|
+
|
|
13
|
+
interface Check {
|
|
14
|
+
name: string;
|
|
15
|
+
ok: boolean;
|
|
16
|
+
detail: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const EXPECTED_TOOLS = [
|
|
20
|
+
"memory_add",
|
|
21
|
+
"memory_search",
|
|
22
|
+
"memory_list",
|
|
23
|
+
"memory_supersede",
|
|
24
|
+
"memory_forget",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function nodeCheck(): Check {
|
|
28
|
+
const [major, minor] = process.versions.node.split(".").map(Number);
|
|
29
|
+
const ok = major > 22 || (major === 22 && minor >= 6);
|
|
30
|
+
return {
|
|
31
|
+
name: "node",
|
|
32
|
+
ok,
|
|
33
|
+
detail: `v${process.versions.node} (need >= 22.6 for --experimental-strip-types)`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function configCheck(): Check {
|
|
38
|
+
try {
|
|
39
|
+
const cfg = loadConfig();
|
|
40
|
+
const src = configSource();
|
|
41
|
+
const customized = (Object.keys(DEFAULT_CONFIG) as (keyof typeof DEFAULT_CONFIG)[]).filter(
|
|
42
|
+
(k) => JSON.stringify(cfg[k]) !== JSON.stringify(DEFAULT_CONFIG[k]),
|
|
43
|
+
);
|
|
44
|
+
const detail =
|
|
45
|
+
(src ?? "built-in defaults") +
|
|
46
|
+
(customized.length > 0 ? ` — customized: ${customized.join(", ")}` : "");
|
|
47
|
+
return { name: "config", ok: true, detail };
|
|
48
|
+
} catch (err) {
|
|
49
|
+
return { name: "config", ok: false, detail: String(err) };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function scopeCheck(): Check {
|
|
54
|
+
try {
|
|
55
|
+
const s = resolveCwdScope(process.cwd());
|
|
56
|
+
return { name: "scope", ok: true, detail: `cwd → ${s.kind} scope "${s.key}"` };
|
|
57
|
+
} catch (err) {
|
|
58
|
+
return { name: "scope", ok: false, detail: String(err) };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function storageCheck(): Check {
|
|
63
|
+
try {
|
|
64
|
+
const p = paths();
|
|
65
|
+
fs.accessSync(p.memories, fs.constants.W_OK);
|
|
66
|
+
return { name: "storage", ok: true, detail: `${p.root} (writable)` };
|
|
67
|
+
} catch (err) {
|
|
68
|
+
return { name: "storage", ok: false, detail: String(err) };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Spawn the real MCP server, handshake, and verify the five tools list. */
|
|
73
|
+
function mcpCheck(): Promise<Check> {
|
|
74
|
+
const name = "mcp";
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
77
|
+
const server = path.join(root, "src", "mcp.ts");
|
|
78
|
+
if (!fs.existsSync(server)) {
|
|
79
|
+
resolve({ name, ok: false, detail: `server entry not found: ${server}` });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const child = spawn(process.execPath, ["--experimental-strip-types", server], {
|
|
83
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
84
|
+
});
|
|
85
|
+
const done = (c: Check) => {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
try {
|
|
88
|
+
child.kill();
|
|
89
|
+
} catch {
|
|
90
|
+
/* already exited */
|
|
91
|
+
}
|
|
92
|
+
resolve(c);
|
|
93
|
+
};
|
|
94
|
+
const timer = setTimeout(
|
|
95
|
+
() => done({ name, ok: false, detail: "handshake timed out after 20s" }),
|
|
96
|
+
20000,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
let buf = "";
|
|
100
|
+
const send = (msg: object) => child.stdin.write(JSON.stringify(msg) + "\n");
|
|
101
|
+
|
|
102
|
+
child.stdout.on("data", (d: Buffer) => {
|
|
103
|
+
buf += d.toString();
|
|
104
|
+
let idx: number;
|
|
105
|
+
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
106
|
+
const line = buf.slice(0, idx).trim();
|
|
107
|
+
buf = buf.slice(idx + 1);
|
|
108
|
+
if (!line) continue;
|
|
109
|
+
let msg: { id?: number; result?: { tools?: { name: string }[] } };
|
|
110
|
+
try {
|
|
111
|
+
msg = JSON.parse(line);
|
|
112
|
+
} catch {
|
|
113
|
+
continue; // ignore non-JSON lines on stdout
|
|
114
|
+
}
|
|
115
|
+
if (msg.id === 1 && msg.result) {
|
|
116
|
+
send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
117
|
+
send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
|
|
118
|
+
} else if (msg.id === 2) {
|
|
119
|
+
const names = (msg.result?.tools ?? []).map((t) => t.name);
|
|
120
|
+
const missing = EXPECTED_TOOLS.filter((t) => !names.includes(t));
|
|
121
|
+
done({
|
|
122
|
+
name,
|
|
123
|
+
ok: missing.length === 0,
|
|
124
|
+
detail:
|
|
125
|
+
missing.length === 0
|
|
126
|
+
? `handshake OK, 5 tools listed (${names.join(", ")})`
|
|
127
|
+
: `missing tools: ${missing.join(", ")}`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
child.on("error", (err) =>
|
|
133
|
+
done({ name, ok: false, detail: `spawn failed: ${(err as Error).message}` }),
|
|
134
|
+
);
|
|
135
|
+
// Server stderr is its own log; not a doctor failure signal.
|
|
136
|
+
|
|
137
|
+
send({
|
|
138
|
+
jsonrpc: "2.0",
|
|
139
|
+
id: 1,
|
|
140
|
+
method: "initialize",
|
|
141
|
+
params: {
|
|
142
|
+
protocolVersion: "2024-11-05",
|
|
143
|
+
capabilities: {},
|
|
144
|
+
clientInfo: { name: "open-memex-doctor", version: "0.0.0" },
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function runDoctor(): Promise<boolean> {
|
|
151
|
+
console.log("open-memex doctor");
|
|
152
|
+
const checks: Check[] = [nodeCheck(), configCheck(), scopeCheck(), storageCheck()];
|
|
153
|
+
checks.push(await mcpCheck());
|
|
154
|
+
let allOk = true;
|
|
155
|
+
for (const c of checks) {
|
|
156
|
+
console.log(` ${c.ok ? "ok " : "FAIL"} ${c.name}: ${c.detail}`);
|
|
157
|
+
if (!c.ok) allOk = false;
|
|
158
|
+
}
|
|
159
|
+
console.log(allOk ? "All checks passed." : "Some checks failed — see above.");
|
|
160
|
+
return allOk;
|
|
161
|
+
}
|