open-memex 0.3.0-alpha → 0.3.0-alpha.3
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 +12 -6
- package/README.md +16 -7
- package/README.zh-CN.md +15 -6
- package/dist/capture/keywords.js +43 -0
- package/dist/cli.js +531 -0
- package/dist/config.js +123 -0
- package/dist/doctor.js +151 -0
- package/dist/index.js +121 -0
- package/dist/init.js +323 -0
- package/dist/mcp.js +85 -0
- package/dist/paths.js +36 -0
- package/dist/redact.js +249 -0
- package/dist/retrieve/cjk.js +58 -0
- package/dist/retrieve/inject.js +25 -0
- package/dist/retrieve/search.js +147 -0
- package/dist/scope.js +70 -0
- package/dist/store/db.js +132 -0
- package/dist/store/lifecycle.js +214 -0
- package/dist/store/markdown.js +197 -0
- package/dist/store/migrate.js +109 -0
- package/dist/store/sync.js +88 -0
- package/dist/store/v2migrate.js +158 -0
- package/dist/tools/memory.js +40 -0
- package/dist/tools/ops.js +186 -0
- package/docs/V2-DESIGN.md +19 -0
- package/package.json +5 -3
- package/src/cli.ts +37 -21
- package/src/doctor.ts +16 -5
- package/src/init.ts +55 -6
- package/tsconfig.build.json +1 -0
- package/bin/open-memex.js +0 -28
package/dist/cli.js
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Run with: node --experimental-strip-types src/cli.ts <command>
|
|
3
|
+
import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE } from "./scope.js";
|
|
4
|
+
import { db, closeDb } from "./store/db.js";
|
|
5
|
+
import { syncScope, upsertFromFile, deleteFromIndex } from "./store/sync.js";
|
|
6
|
+
import { migrateScope, scopeHasFiles } from "./store/migrate.js";
|
|
7
|
+
import { migrateV2 } from "./store/v2migrate.js";
|
|
8
|
+
import { findDuplicates, supersede, setStatus } from "./store/lifecycle.js";
|
|
9
|
+
import { search, list } from "./retrieve/search.js";
|
|
10
|
+
import { writeMemoryFile, readMemoryFile, deleteMemoryFile, ulid, msToRfc3339, } from "./store/markdown.js";
|
|
11
|
+
import { loadConfig } from "./config.js";
|
|
12
|
+
import { paths } from "./paths.js";
|
|
13
|
+
import { redact } from "./redact.js";
|
|
14
|
+
import { resolveMcpCommand } from "./init.js";
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
function usage(exitCode = 1) {
|
|
19
|
+
console.log(`open-memex CLI
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
open-memex where
|
|
23
|
+
open-memex list [--scope project|personal] [--type T] [--limit N]
|
|
24
|
+
open-memex search "query" [--scope project|personal|both] [--type T] [--limit N]
|
|
25
|
+
open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2]
|
|
26
|
+
open-memex supersede <id> "new content" [--type T] [--tag t1,t2]
|
|
27
|
+
open-memex status <id> active|deprecated|retracted|archived
|
|
28
|
+
open-memex forget <id>
|
|
29
|
+
open-memex reindex
|
|
30
|
+
open-memex scopes
|
|
31
|
+
open-memex migrate [--from <key>] [--to <key>]
|
|
32
|
+
[--dry-run] [--on-conflict newer|overwrite|skip]
|
|
33
|
+
open-memex migrate --to-v2 [--dry-run]
|
|
34
|
+
open-memex mcp [--print-config vscode|cursor|claude|opencode|visualstudio]
|
|
35
|
+
open-memex init [--client vscode|cursor|opencode|visualstudio]
|
|
36
|
+
[--instructions personal|project] [--force] [--yes]
|
|
37
|
+
open-memex config [set <key> <value>]
|
|
38
|
+
open-memex capture --dry-run "text"
|
|
39
|
+
open-memex doctor
|
|
40
|
+
|
|
41
|
+
One-command project setup: \`open-memex init\` (or \`npx open-memex@alpha init\`) writes
|
|
42
|
+
the MCP config for your editor (\`.vscode/mcp.json\`, \`.cursor/mcp.json\`,
|
|
43
|
+
\`opencode.jsonc\`, or Visual Studio's solution-level \`.mcp.json\`) — no copy-paste
|
|
44
|
+
needed. The Copilot memory instructions default to your user-level
|
|
45
|
+
\`~/.copilot/copilot-instructions.md\` (all projects, never checked into a repo);
|
|
46
|
+
\`--instructions project\` writes \`.github/copilot-instructions.md\` instead for
|
|
47
|
+
teams where everyone uses open-memex.
|
|
48
|
+
Existing files are merged, never clobbered; re-running is safe. On a terminal it
|
|
49
|
+
asks which editor to set up and a couple of settings (keyword capture, first-turn
|
|
50
|
+
injection); \`--yes\` accepts all defaults, and non-terminal runs never prompt.
|
|
51
|
+
\`open-memex config set <key> <value>\` changes those settings after install.
|
|
52
|
+
|
|
53
|
+
Once installed globally (\`npm i -g open-memex@alpha\`) the \`open-memex\` command is
|
|
54
|
+
available directly: \`open-memex mcp\` starts the stdio MCP server (same five
|
|
55
|
+
memory_* tools as the opencode plugin); \`open-memex mcp --print-config <client>\`
|
|
56
|
+
prints a copy-paste MCP client config snippet.
|
|
57
|
+
|
|
58
|
+
Scope defaults to \`project\` (derived from cwd's git remote or path).
|
|
59
|
+
\`user\` is accepted as a deprecated alias of \`personal\`.
|
|
60
|
+
\`scopes\` lists every project scope dir with its file count — use it to find
|
|
61
|
+
the \`--from\` key when migrating.
|
|
62
|
+
\`migrate\` moves memories between scope keys — useful when a repo gains a
|
|
63
|
+
git remote after memories were already stored under the cwd-based key.
|
|
64
|
+
\`migrate --to-v2\` converts v1 memory files to the v2 format (§19):
|
|
65
|
+
user→personal scope rename, epoch→RFC 3339 times, priority→importance,
|
|
66
|
+
type: instruction→role split. Always preview with --dry-run first.`);
|
|
67
|
+
process.exit(exitCode);
|
|
68
|
+
}
|
|
69
|
+
function parseFlags(argv) {
|
|
70
|
+
const out = {};
|
|
71
|
+
for (let i = 0; i < argv.length; i++) {
|
|
72
|
+
const a = argv[i];
|
|
73
|
+
if (a.startsWith("--")) {
|
|
74
|
+
const key = a.slice(2);
|
|
75
|
+
const val = argv[i + 1];
|
|
76
|
+
if (val !== undefined && !val.startsWith("--")) {
|
|
77
|
+
out[key] = val;
|
|
78
|
+
i++;
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
out[key] = "true";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
/** CLI `--scope` flag → Scope. `user` is a deprecated alias of `personal`. */
|
|
88
|
+
function resolveCliScope(flags, project) {
|
|
89
|
+
return flags.scope === "personal" || flags.scope === "user"
|
|
90
|
+
? PERSONAL_SCOPE
|
|
91
|
+
: project;
|
|
92
|
+
}
|
|
93
|
+
/** Print a copy-paste MCP client config snippet. Requires a global install
|
|
94
|
+
* (`npm i -g open-memex@alpha`) so the `open-memex` command is on PATH. */
|
|
95
|
+
function printMcpConfig(client) {
|
|
96
|
+
const c = client.toLowerCase();
|
|
97
|
+
// D17: resolve the server command the same way `init` does.
|
|
98
|
+
const mc = resolveMcpCommand();
|
|
99
|
+
if (c === "vscode") {
|
|
100
|
+
console.log(JSON.stringify({
|
|
101
|
+
servers: {
|
|
102
|
+
"open-memex": {
|
|
103
|
+
type: "stdio",
|
|
104
|
+
command: mc.command,
|
|
105
|
+
args: mc.args,
|
|
106
|
+
cwd: "${workspaceFolder}",
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
}, null, 2));
|
|
110
|
+
}
|
|
111
|
+
else if (c === "cursor") {
|
|
112
|
+
console.log(JSON.stringify({
|
|
113
|
+
mcpServers: {
|
|
114
|
+
"open-memex": { command: mc.command, args: mc.args },
|
|
115
|
+
},
|
|
116
|
+
}, null, 2));
|
|
117
|
+
}
|
|
118
|
+
else if (c === "claude") {
|
|
119
|
+
console.log(`claude mcp add open-memex -- ${mc.command} ${mc.args.join(" ")}`);
|
|
120
|
+
}
|
|
121
|
+
else if (c === "opencode") {
|
|
122
|
+
// D16: opencode as a plain MCP consumer (alternative to the native plugin).
|
|
123
|
+
console.log(JSON.stringify({
|
|
124
|
+
mcp: {
|
|
125
|
+
"open-memex": {
|
|
126
|
+
type: "local",
|
|
127
|
+
command: [mc.command, ...mc.args],
|
|
128
|
+
enabled: true,
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
}, null, 2));
|
|
132
|
+
}
|
|
133
|
+
else if (c === "visualstudio" || c === "visual-studio") {
|
|
134
|
+
// D20: Visual Studio (Windows-only) reads solution-level `.mcp.json`.
|
|
135
|
+
console.log(JSON.stringify({
|
|
136
|
+
servers: {
|
|
137
|
+
"open-memex": {
|
|
138
|
+
type: "stdio",
|
|
139
|
+
command: mc.command,
|
|
140
|
+
args: mc.args,
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
}, null, 2));
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
console.error(`unknown client "${client}" (vscode|cursor|claude|opencode|visualstudio)`);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
if (!mc.durable) {
|
|
150
|
+
console.error(`\n# note: no durable \`open-memex\` on PATH — snippet uses npx. \`npm i -g open-memex@alpha\` for faster startup.`);
|
|
151
|
+
}
|
|
152
|
+
process.exit(0);
|
|
153
|
+
}
|
|
154
|
+
async function main() {
|
|
155
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
156
|
+
if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help")
|
|
157
|
+
usage(0);
|
|
158
|
+
if (cmd === "--version" || cmd === "-v") {
|
|
159
|
+
// package.json sits two levels above this file in both layouts
|
|
160
|
+
// (src/cli.ts and dist/cli.js).
|
|
161
|
+
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
162
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
|
163
|
+
console.log(`open-memex ${pkg.version}`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const cfg = loadConfig();
|
|
167
|
+
const project = resolveProjectScope(process.cwd());
|
|
168
|
+
// `migrate --to-v2` is a pure file operation (V2-DESIGN §19) — it runs
|
|
169
|
+
// before the index is opened so it also works where the DB driver
|
|
170
|
+
// isn't available.
|
|
171
|
+
if (cmd === "migrate" && rest.includes("--to-v2")) {
|
|
172
|
+
const flags = parseFlags(rest);
|
|
173
|
+
const dryRun = flags["dry-run"] === "true";
|
|
174
|
+
const stats = migrateV2({ dryRun });
|
|
175
|
+
console.log(`${dryRun ? "DRY RUN: " : ""}scanned ${stats.scanned} files: ` +
|
|
176
|
+
`${stats.converted} to convert, ${stats.skippedV2} already v2`);
|
|
177
|
+
for (const p of stats.plans) {
|
|
178
|
+
console.log(`\n${p.fm.id}`);
|
|
179
|
+
console.log(` ${p.fromPath}${p.toPath !== p.fromPath ? `\n → ${p.toPath}` : ""}`);
|
|
180
|
+
for (const c of p.changes)
|
|
181
|
+
console.log(` - ${c}`);
|
|
182
|
+
}
|
|
183
|
+
if (stats.legacyBackup) {
|
|
184
|
+
console.log(`\nlegacy my-o-memory data dir merged; backup kept at:\n ${stats.legacyBackup}`);
|
|
185
|
+
}
|
|
186
|
+
if (!dryRun && stats.converted > 0) {
|
|
187
|
+
console.log(`\nindex schema will rebuild automatically on next run; ` +
|
|
188
|
+
`run \`open-memex reindex\` to verify.`);
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
// `init` is a pure file operation (§17 adoption path) — no DB needed.
|
|
193
|
+
// Interactive when on a TTY (asks editor + settings); --yes skips prompts.
|
|
194
|
+
if (cmd === "init") {
|
|
195
|
+
const flags = parseFlags(rest);
|
|
196
|
+
const { initProject } = await import("./init.js");
|
|
197
|
+
await initProject({
|
|
198
|
+
client: flags["client"],
|
|
199
|
+
force: flags["force"] === "true",
|
|
200
|
+
yes: flags["yes"] === "true",
|
|
201
|
+
instructions: flags["instructions"],
|
|
202
|
+
});
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
// `doctor` runs environment health checks — no DB needed (it self-contains).
|
|
206
|
+
if (cmd === "doctor") {
|
|
207
|
+
const { runDoctor } = await import("./doctor.js");
|
|
208
|
+
const ok = await runDoctor();
|
|
209
|
+
if (!ok)
|
|
210
|
+
process.exitCode = 1;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
// `config` prints the effective configuration (defaults + file). No DB needed.
|
|
214
|
+
// `config set <key> <value>` persists a setting to the config file.
|
|
215
|
+
if (cmd === "config") {
|
|
216
|
+
if (rest[0] === "set") {
|
|
217
|
+
const [, key, ...valueParts] = rest;
|
|
218
|
+
const { SETTABLE_KEYS, saveConfig, configFilePath } = await import("./config.js");
|
|
219
|
+
const validate = key ? SETTABLE_KEYS[key] : undefined;
|
|
220
|
+
if (!validate) {
|
|
221
|
+
console.error(`unknown or unsettable key "${key ?? ""}". Settable keys: ${Object.keys(SETTABLE_KEYS).join(", ")}`);
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
const raw = valueParts.join(" ");
|
|
225
|
+
if (!raw) {
|
|
226
|
+
console.error(`usage: open-memex config set <key> <value>`);
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
let value = raw;
|
|
230
|
+
try {
|
|
231
|
+
value = JSON.parse(raw);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
/* keep as string */
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
const saved = validate(value);
|
|
238
|
+
const file = saveConfig({ [key]: saved });
|
|
239
|
+
console.log(`set ${key} = ${JSON.stringify(saved)} (${file})`);
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
console.error(`invalid value for ${key}: ${err.message}`);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const cfg = loadConfig();
|
|
248
|
+
console.log(JSON.stringify(cfg, null, 2));
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
// `capture --dry-run "text"` previews keyword capture without writing. No DB needed.
|
|
252
|
+
if (cmd === "capture") {
|
|
253
|
+
if (rest[0] !== "--dry-run") {
|
|
254
|
+
console.error(`usage: open-memex capture --dry-run "text"`);
|
|
255
|
+
process.exit(1);
|
|
256
|
+
}
|
|
257
|
+
const cfg = loadConfig();
|
|
258
|
+
const { detectKeywords } = await import("./capture/keywords.js");
|
|
259
|
+
const text = rest.slice(1).join(" ");
|
|
260
|
+
const hits = detectKeywords(text, cfg);
|
|
261
|
+
if (hits.length === 0) {
|
|
262
|
+
console.log("no keyword triggers — nothing would be captured.");
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
for (const h of hits) {
|
|
266
|
+
const r = redact(h.content, cfg.redactPatterns);
|
|
267
|
+
console.log(`- pattern: ${h.pattern}`);
|
|
268
|
+
console.log(` scope: ${h.personal ? "personal (forced by pattern)" : "current scope"}`);
|
|
269
|
+
console.log(` secret hit: ${r.hadSecret ? `yes (${r.matchedPattern}) — will be masked` : "no"}`);
|
|
270
|
+
console.log(` body: ${r.content.slice(0, 160)}${r.content.length > 160 ? "…" : ""}`);
|
|
271
|
+
}
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
// `mcp` starts the stdio MCP server (same tools as the opencode plugin).
|
|
275
|
+
// Branched before db() — runMcpServer() does its own init, and stdout must
|
|
276
|
+
// stay clean for the MCP protocol.
|
|
277
|
+
if (cmd === "mcp") {
|
|
278
|
+
const flags = parseFlags(rest);
|
|
279
|
+
if (flags["print-config"]) {
|
|
280
|
+
printMcpConfig(flags["print-config"] === "true" ? "vscode" : flags["print-config"]);
|
|
281
|
+
}
|
|
282
|
+
const { runMcpServer } = await import("./mcp.js");
|
|
283
|
+
await runMcpServer();
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
db();
|
|
287
|
+
if (cmd === "where") {
|
|
288
|
+
const p = paths();
|
|
289
|
+
console.log(`root: ${p.root}`);
|
|
290
|
+
console.log(`memories: ${p.memories}`);
|
|
291
|
+
console.log(`index: ${p.indexDb}`);
|
|
292
|
+
console.log(`project: ${project.key}`);
|
|
293
|
+
console.log(`personal: ${PERSONAL_SCOPE.key}`);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (cmd === "reindex") {
|
|
297
|
+
const a = syncScope(project.key);
|
|
298
|
+
const b = syncScope(PERSONAL_SCOPE.key);
|
|
299
|
+
console.log(`reindexed. project: +${a.added} ~${a.updated} -${a.removed} (scanned ${a.scanned}), personal: +${b.added} ~${b.updated} -${b.removed} (scanned ${b.scanned})`);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (cmd === "list") {
|
|
303
|
+
const flags = parseFlags(rest);
|
|
304
|
+
const s = resolveCliScope(flags, project);
|
|
305
|
+
syncScope(s.key);
|
|
306
|
+
const hits = list(s.key, {
|
|
307
|
+
type: flags.type,
|
|
308
|
+
limit: flags.limit ? Number(flags.limit) : undefined,
|
|
309
|
+
});
|
|
310
|
+
if (hits.length === 0) {
|
|
311
|
+
console.log(`(no memories in ${s.key})`);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
for (const h of hits) {
|
|
315
|
+
const dep = h.status === "deprecated" ? " [deprecated]" : "";
|
|
316
|
+
console.log(`[${h.type}]${dep} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
|
|
317
|
+
}
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (cmd === "search") {
|
|
321
|
+
const query = rest[0];
|
|
322
|
+
if (!query || query.startsWith("--"))
|
|
323
|
+
usage();
|
|
324
|
+
const flags = parseFlags(rest.slice(1));
|
|
325
|
+
syncScope(project.key);
|
|
326
|
+
syncScope(PERSONAL_SCOPE.key);
|
|
327
|
+
const keys = flags.scope === "personal" || flags.scope === "user"
|
|
328
|
+
? [PERSONAL_SCOPE.key]
|
|
329
|
+
: flags.scope === "project"
|
|
330
|
+
? [project.key]
|
|
331
|
+
: [project.key, PERSONAL_SCOPE.key];
|
|
332
|
+
const hits = search(query, {
|
|
333
|
+
scopeKeys: keys,
|
|
334
|
+
limit: flags.limit ? Number(flags.limit) : undefined,
|
|
335
|
+
type: flags.type,
|
|
336
|
+
});
|
|
337
|
+
if (hits.length === 0) {
|
|
338
|
+
console.log("(no matches)");
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
for (const h of hits) {
|
|
342
|
+
const tag = h.scope_key === PERSONAL_SCOPE.key ? "personal" : "project";
|
|
343
|
+
const dep = h.status === "deprecated" ? " [deprecated]" : "";
|
|
344
|
+
console.log(`[${tag}/${h.type}]${dep} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
|
|
345
|
+
}
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (cmd === "add") {
|
|
349
|
+
const content = rest[0];
|
|
350
|
+
if (!content || content.startsWith("--"))
|
|
351
|
+
usage();
|
|
352
|
+
const flags = parseFlags(rest.slice(1));
|
|
353
|
+
const s = resolveCliScope(flags, project);
|
|
354
|
+
const { content: red, hadSecret, matchedPattern } = redact(content, cfg.redactPatterns);
|
|
355
|
+
// Dedup on write (§3.4): identical content is idempotent; near-duplicates
|
|
356
|
+
// warn but still save (use `supersede` when this replaces the old one).
|
|
357
|
+
// Secrets are masked (first 4 chars kept) and the write proceeds.
|
|
358
|
+
const dups = findDuplicates(s.key, red);
|
|
359
|
+
if (dups.exact) {
|
|
360
|
+
console.log(`already exists: id=${dups.exact.id} (identical content — not duplicated)`);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const now = Date.now();
|
|
364
|
+
const rfc = msToRfc3339(now);
|
|
365
|
+
const fm = {
|
|
366
|
+
id: ulid(),
|
|
367
|
+
schema_version: 2,
|
|
368
|
+
scope_key: s.key,
|
|
369
|
+
scope: s.kind === "project" ? "project" : "personal",
|
|
370
|
+
visibility: s.kind === "project" ? "internal" : "private",
|
|
371
|
+
project_name: s.projectName,
|
|
372
|
+
type: flags.type ?? "fact",
|
|
373
|
+
role: "knowledge",
|
|
374
|
+
importance: "normal",
|
|
375
|
+
status: "active",
|
|
376
|
+
tags: flags.tag
|
|
377
|
+
? flags.tag
|
|
378
|
+
.split(",")
|
|
379
|
+
.map((t) => t.trim())
|
|
380
|
+
.filter(Boolean)
|
|
381
|
+
: [],
|
|
382
|
+
source: "cli",
|
|
383
|
+
created_at: rfc,
|
|
384
|
+
updated_at: rfc,
|
|
385
|
+
supersedes: null,
|
|
386
|
+
superseded_by: null,
|
|
387
|
+
};
|
|
388
|
+
const { filePath } = writeMemoryFile(fm, red);
|
|
389
|
+
const mf = readMemoryFile(filePath);
|
|
390
|
+
if (mf)
|
|
391
|
+
upsertFromFile(mf);
|
|
392
|
+
console.log(`saved ${fm.id} [${s.kind}] -> ${filePath}`);
|
|
393
|
+
if (hadSecret) {
|
|
394
|
+
console.log(`warning: content matched secret pattern (${matchedPattern}); saved with the secret masked (first 4 chars kept).`);
|
|
395
|
+
}
|
|
396
|
+
for (const n of dups.near) {
|
|
397
|
+
console.log(`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.`);
|
|
398
|
+
}
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (cmd === "supersede") {
|
|
402
|
+
const id = rest[0];
|
|
403
|
+
const content = rest[1];
|
|
404
|
+
if (!id || !content || content.startsWith("--"))
|
|
405
|
+
usage();
|
|
406
|
+
const flags = parseFlags(rest.slice(2));
|
|
407
|
+
const { content: red, hadSecret, matchedPattern } = redact(content, cfg.redactPatterns);
|
|
408
|
+
try {
|
|
409
|
+
const { oldMf, newMf } = supersede(id, {
|
|
410
|
+
body: red,
|
|
411
|
+
type: flags.type,
|
|
412
|
+
tags: flags.tag
|
|
413
|
+
? flags.tag.split(",").map((t) => t.trim()).filter(Boolean)
|
|
414
|
+
: undefined,
|
|
415
|
+
source: "cli",
|
|
416
|
+
});
|
|
417
|
+
upsertFromFile(oldMf);
|
|
418
|
+
upsertFromFile(newMf);
|
|
419
|
+
console.log(`superseded ${oldMf.fm.id} → ${newMf.fm.id}`);
|
|
420
|
+
if (hadSecret) {
|
|
421
|
+
console.log(`warning: content matched secret pattern (${matchedPattern}); saved with the secret masked (first 4 chars kept).`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
catch (e) {
|
|
425
|
+
console.error(`supersede failed: ${e.message}`);
|
|
426
|
+
process.exit(2);
|
|
427
|
+
}
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (cmd === "status") {
|
|
431
|
+
const id = rest[0];
|
|
432
|
+
const st = rest[1];
|
|
433
|
+
if (!id || !st)
|
|
434
|
+
usage();
|
|
435
|
+
try {
|
|
436
|
+
const mf = setStatus(id, st);
|
|
437
|
+
upsertFromFile(mf);
|
|
438
|
+
console.log(`status ${id} → ${mf.fm.status}`);
|
|
439
|
+
}
|
|
440
|
+
catch (e) {
|
|
441
|
+
console.error(`status failed: ${e.message}`);
|
|
442
|
+
process.exit(2);
|
|
443
|
+
}
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (cmd === "forget") {
|
|
447
|
+
const id = rest[0];
|
|
448
|
+
if (!id)
|
|
449
|
+
usage();
|
|
450
|
+
const row = db().prepare(`SELECT scope_key FROM memories WHERE id = ?`).get(id);
|
|
451
|
+
if (!row) {
|
|
452
|
+
console.error("not found");
|
|
453
|
+
process.exit(1);
|
|
454
|
+
}
|
|
455
|
+
deleteMemoryFile(row.scope_key, id);
|
|
456
|
+
deleteFromIndex(id);
|
|
457
|
+
console.log(`deleted ${id}`);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (cmd === "scopes") {
|
|
461
|
+
const { memories } = paths();
|
|
462
|
+
if (!fs.existsSync(memories)) {
|
|
463
|
+
console.log("(no memories dir yet)");
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const entries = [];
|
|
467
|
+
for (const name of fs.readdirSync(memories)) {
|
|
468
|
+
const dir = `${memories}/${name}`;
|
|
469
|
+
if (!fs.statSync(dir).isDirectory())
|
|
470
|
+
continue;
|
|
471
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".md")).length;
|
|
472
|
+
const marker = name === project.key ? " <- current project"
|
|
473
|
+
: name === PERSONAL_SCOPE.key ? " <- personal"
|
|
474
|
+
: "";
|
|
475
|
+
entries.push({ key: name, files, marker });
|
|
476
|
+
}
|
|
477
|
+
entries.sort((a, b) => b.files - a.files);
|
|
478
|
+
for (const e of entries) {
|
|
479
|
+
console.log(` ${e.files.toString().padStart(4)} ${e.key}${e.marker}`);
|
|
480
|
+
}
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (cmd === "migrate") {
|
|
484
|
+
const flags = parseFlags(rest);
|
|
485
|
+
const toKey = flags.to ?? project.key;
|
|
486
|
+
let fromKey = flags.from;
|
|
487
|
+
if (!fromKey) {
|
|
488
|
+
// Auto-detect: the "legacy" scope is what the key WOULD be if we
|
|
489
|
+
// ignored the git remote (pure cwd hash). Only offer it if it differs
|
|
490
|
+
// from the current project scope AND has files on disk.
|
|
491
|
+
const cwd = resolveCwdScope(process.cwd());
|
|
492
|
+
if (cwd.key !== project.key && scopeHasFiles(cwd.key)) {
|
|
493
|
+
fromKey = cwd.key;
|
|
494
|
+
console.log(`(auto-detected legacy scope: ${fromKey})`);
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
console.error(`no --from given and no legacy cwd-based scope with files detected.\n` +
|
|
498
|
+
`current project scope: ${project.key}\n` +
|
|
499
|
+
`cwd-only scope: ${cwd.key}`);
|
|
500
|
+
process.exit(2);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (fromKey === toKey) {
|
|
504
|
+
console.error(`--from and --to are identical (${fromKey}); nothing to do.`);
|
|
505
|
+
process.exit(2);
|
|
506
|
+
}
|
|
507
|
+
const dryRun = flags["dry-run"] === "true";
|
|
508
|
+
const onConflict = flags["on-conflict"];
|
|
509
|
+
if (onConflict && !["newer", "overwrite", "skip"].includes(onConflict)) {
|
|
510
|
+
console.error(`invalid --on-conflict: ${onConflict}`);
|
|
511
|
+
process.exit(2);
|
|
512
|
+
}
|
|
513
|
+
const stats = migrateScope(fromKey, toKey, {
|
|
514
|
+
toProjectName: project.projectName,
|
|
515
|
+
dryRun,
|
|
516
|
+
onConflict,
|
|
517
|
+
logger: (m) => console.log(m),
|
|
518
|
+
});
|
|
519
|
+
console.log(`${dryRun ? "DRY RUN: " : ""}moved ${stats.moved}, skipped ${stats.skipped}, conflicts ${stats.conflicts}`);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
usage();
|
|
523
|
+
}
|
|
524
|
+
main()
|
|
525
|
+
.catch((err) => {
|
|
526
|
+
console.error(err);
|
|
527
|
+
process.exit(1);
|
|
528
|
+
})
|
|
529
|
+
.finally(() => {
|
|
530
|
+
closeDb();
|
|
531
|
+
});
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
export const DEFAULT_CONFIG = {
|
|
5
|
+
maxProjectMemories: 8,
|
|
6
|
+
maxProfileItems: 5,
|
|
7
|
+
injectOnFirstTurn: true,
|
|
8
|
+
keywordCaptureEnabled: true,
|
|
9
|
+
keywordPatterns: [
|
|
10
|
+
"^\\s*remember(?!\\s+for\\s+me)(?:\\s+that)?[:,]?\\s+(.+)$",
|
|
11
|
+
"^\\s*(?:please\\s+)?(?:note|don'?t\\s+forget)(?:\\s+that)?[:,]?\\s+(.+)$",
|
|
12
|
+
"^\\s*TIL[:,]?\\s+(.+)$",
|
|
13
|
+
"^\\s*save\\s+(?:this|to\\s+memory)[:,]?\\s+(.+)$",
|
|
14
|
+
// Chinese equivalents
|
|
15
|
+
"^\\s*记住(?!(个人))[::,,]?\\s*(.+)$",
|
|
16
|
+
"^\\s*(?:请)?(?:记一下|记录一下)[::,,]?\\s*(.+)$",
|
|
17
|
+
"^\\s*别忘了[::,,]?\\s*(.+)$",
|
|
18
|
+
// First-person plural: team/project context, NOT personal
|
|
19
|
+
"^\\s*我们认为[::,,]?\\s*(.+)$",
|
|
20
|
+
"^\\s*我们决定[::,,]?\\s*(.+)$",
|
|
21
|
+
"^\\s*帮我们记(?:住|一下)?[::,,]?\\s*(.+)$",
|
|
22
|
+
],
|
|
23
|
+
keywordPersonalPatterns: [
|
|
24
|
+
"^\\s*remember\\s+for\\s+me(?:\\s+that)?[:,]?\\s+(.+)$",
|
|
25
|
+
"^\\s*记住(个人)[::,,]?\\s*(.+)$",
|
|
26
|
+
// First-person singular: personal scope ("我" → 个人, "我们" → 项目)
|
|
27
|
+
"^\\s*记住我(?!们)[::,,]?\\s*(.+)$",
|
|
28
|
+
"^\\s*替我记(?:住|一下)?[::,,]?\\s*(.+)$",
|
|
29
|
+
"^\\s*我觉得[::,,]?\\s*(.+)$",
|
|
30
|
+
"^\\s*我喜欢[::,,]?\\s*(.+)$",
|
|
31
|
+
],
|
|
32
|
+
// Built-in provider patterns now live in src/redact.ts (always on).
|
|
33
|
+
// Add only your own extra patterns here.
|
|
34
|
+
redactPatterns: [],
|
|
35
|
+
logLevel: "info",
|
|
36
|
+
};
|
|
37
|
+
function stripJsonComments(raw) {
|
|
38
|
+
// Line comments
|
|
39
|
+
let out = raw.replace(/^\s*\/\/.*$/gm, "");
|
|
40
|
+
// Block comments (non-greedy)
|
|
41
|
+
out = out.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
42
|
+
// Trailing commas before closing brackets
|
|
43
|
+
out = out.replace(/,(\s*[}\]])/g, "$1");
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
/** Where `config set` / interactive `init` persist. Respects MY_O_MEMORY_CONFIG. */
|
|
47
|
+
export function configFilePath() {
|
|
48
|
+
return (process.env.MY_O_MEMORY_CONFIG ??
|
|
49
|
+
path.join(os.homedir(), ".config", "opencode", "open-memex.jsonc"));
|
|
50
|
+
}
|
|
51
|
+
function toBool(v) {
|
|
52
|
+
if (typeof v === "boolean")
|
|
53
|
+
return v;
|
|
54
|
+
if (v === "true" || v === "1")
|
|
55
|
+
return true;
|
|
56
|
+
if (v === "false" || v === "0")
|
|
57
|
+
return false;
|
|
58
|
+
throw new Error("must be true/false");
|
|
59
|
+
}
|
|
60
|
+
function toNonNegInt(v) {
|
|
61
|
+
const n = Number(v);
|
|
62
|
+
if (!Number.isInteger(n) || n < 0)
|
|
63
|
+
throw new Error("must be a non-negative integer");
|
|
64
|
+
return n;
|
|
65
|
+
}
|
|
66
|
+
/** Keys users may change via `open-memex config set <key> <value>`, with validators. */
|
|
67
|
+
export const SETTABLE_KEYS = {
|
|
68
|
+
maxProjectMemories: toNonNegInt,
|
|
69
|
+
maxProfileItems: toNonNegInt,
|
|
70
|
+
injectOnFirstTurn: toBool,
|
|
71
|
+
keywordCaptureEnabled: toBool,
|
|
72
|
+
logLevel: (v) => {
|
|
73
|
+
if (v !== "info" && v !== "debug")
|
|
74
|
+
throw new Error('must be "info" or "debug"');
|
|
75
|
+
return v;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
/** Merge a patch into the config file (creates it if missing). Returns the file path. */
|
|
79
|
+
export function saveConfig(patch) {
|
|
80
|
+
const file = configFilePath();
|
|
81
|
+
let cur = {};
|
|
82
|
+
if (fs.existsSync(file)) {
|
|
83
|
+
try {
|
|
84
|
+
cur = JSON.parse(stripJsonComments(fs.readFileSync(file, "utf8")));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
console.error(`[open-memex] ${file} is not valid JSONC — it will be replaced`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const next = { ...cur, ...patch };
|
|
91
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
92
|
+
fs.writeFileSync(file, "// Managed by `open-memex config set` / `open-memex init` — edit freely (JSONC).\n" +
|
|
93
|
+
JSON.stringify(next, null, 2) +
|
|
94
|
+
"\n");
|
|
95
|
+
return file;
|
|
96
|
+
}
|
|
97
|
+
/** Path of the config file in effect, or null when using built-in defaults. */
|
|
98
|
+
export function configSource() {
|
|
99
|
+
const candidates = [
|
|
100
|
+
process.env.MY_O_MEMORY_CONFIG,
|
|
101
|
+
path.join(os.homedir(), ".config", "opencode", "open-memex.jsonc"),
|
|
102
|
+
path.join(os.homedir(), ".config", "opencode", "open-memex.json"),
|
|
103
|
+
].filter(Boolean);
|
|
104
|
+
for (const p of candidates) {
|
|
105
|
+
if (fs.existsSync(p))
|
|
106
|
+
return p;
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
export function loadConfig() {
|
|
111
|
+
const p = configSource();
|
|
112
|
+
if (p) {
|
|
113
|
+
try {
|
|
114
|
+
const raw = fs.readFileSync(p, "utf8");
|
|
115
|
+
const parsed = JSON.parse(stripJsonComments(raw));
|
|
116
|
+
return { ...DEFAULT_CONFIG, ...parsed };
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
console.error(`[open-memex] failed to parse ${p}:`, err);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return DEFAULT_CONFIG;
|
|
123
|
+
}
|