open-memex 0.3.0-alpha → 0.3.0-alpha.1
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 +7 -3
- package/README.md +4 -3
- package/README.zh-CN.md +3 -2
- package/dist/capture/keywords.js +43 -0
- package/dist/cli.js +516 -0
- package/dist/config.js +123 -0
- package/dist/doctor.js +151 -0
- package/dist/index.js +121 -0
- package/dist/init.js +279 -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 +9 -0
- package/package.json +5 -3
- package/src/doctor.ts +16 -5
- package/tsconfig.build.json +1 -0
- package/bin/open-memex.js +0 -28
package/AGENTS.md
CHANGED
|
@@ -5,7 +5,10 @@ for user-facing docs; `docs/V2-DESIGN.md` §18 for the roadmap. This file lists
|
|
|
5
5
|
|
|
6
6
|
## Runtime model — read before touching anything
|
|
7
7
|
|
|
8
|
-
Three entry points execute the same TypeScript source
|
|
8
|
+
Three entry points execute the same TypeScript source. **Development is build-free**;
|
|
9
|
+
the published npm package ships pre-compiled JS (`npm run build` → `dist/`,
|
|
10
|
+
via `prepublishOnly` — Node refuses `--experimental-strip-types` for files under
|
|
11
|
+
`node_modules`, so a global install cannot run `src/` directly):
|
|
9
12
|
|
|
10
13
|
- **opencode host** loads `src/index.ts` under embedded **Bun**. SQLite here is `bun:sqlite` (built-in).
|
|
11
14
|
- **CLI** (`src/cli.ts`) and smoke tests run under **Node 22+** with `--experimental-strip-types`. SQLite here is `better-sqlite3` (native module).
|
|
@@ -42,8 +45,9 @@ previews keyword capture without writing, `open-memex doctor` runs health checks
|
|
|
42
45
|
(node version, config, scope resolution, storage writability, MCP handshake).
|
|
43
46
|
`open-memex init` asks editor + two settings on a TTY (`--yes` skips, scripts never
|
|
44
47
|
prompt); `open-memex config set <key> <value>` edits settings after install.
|
|
45
|
-
The
|
|
46
|
-
|
|
48
|
+
The published `open-memex` bin points at `dist/cli.js` (compiled at publish time).
|
|
49
|
+
From a source checkout, `npm run cli` / `npm run mcp` still run `src/` directly
|
|
50
|
+
with type-stripping — no build step needed for development.
|
|
47
51
|
|
|
48
52
|
There is **no `npm test`** and no CI. Verification loop is: `npm run typecheck` + `smoke-pure.ts` + (if touching sqlite) `npm run cli -- reindex` against a scratch `MY_O_MEMORY_HOME`.
|
|
49
53
|
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ plus a generic MCP server (VS Code Copilot, Cursor, Claude Code, Visual Studio,
|
|
|
8
8
|
- **Markdown files** as the source of truth (human-editable, git-friendly)
|
|
9
9
|
- **SQLite FTS5** as a rebuildable index (BM25 keyword search, via `better-sqlite3`)
|
|
10
10
|
- **Zero cloud**, zero account, zero third-party API
|
|
11
|
-
- Loads directly under opencode's embedded Bun runtime; CLI and MCP server run under Node — no build step, no Bun install
|
|
11
|
+
- Loads directly under opencode's embedded Bun runtime; CLI and MCP server run under Node — no build step in development (the published npm package ships pre-compiled JS), no Bun install
|
|
12
12
|
|
|
13
13
|
## Installation
|
|
14
14
|
|
|
@@ -259,8 +259,9 @@ open-memex reindex # rebuild the SQLite index from markdown
|
|
|
259
259
|
open-memex migrate --to-v2 [--dry-run] # v1 data → v2
|
|
260
260
|
```
|
|
261
261
|
|
|
262
|
-
The CLI runs under Node 22
|
|
263
|
-
step)
|
|
262
|
+
The CLI runs under Node 22. From a source checkout it uses the built-in experimental
|
|
263
|
+
TypeScript loader (no build step); the published npm package ships pre-compiled JS
|
|
264
|
+
(`npm run build` at publish time). From a source checkout, prefix every command with
|
|
264
265
|
`node --experimental-strip-types src/cli.ts` (or `npm run cli -- <command>` for
|
|
265
266
|
simple cases — npm swallows unknown `--flag` args, so prefer direct `node`).
|
|
266
267
|
|
package/README.zh-CN.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
- **Markdown 文件**是 source of truth(人类可读、git 友好)
|
|
9
9
|
- **SQLite FTS5** 做可重建索引(BM25 关键词检索,`better-sqlite3`)
|
|
10
10
|
- **零云端**、零账号、零第三方 API
|
|
11
|
-
- 直接跑在 opencode 内嵌的 Bun 运行时里;CLI 和 MCP server 跑在 Node
|
|
11
|
+
- 直接跑在 opencode 内嵌的 Bun 运行时里;CLI 和 MCP server 跑在 Node 下——开发时无需构建(发布的 npm 包带预编译好的 JS)、无需安装 Bun
|
|
12
12
|
|
|
13
13
|
## 安装
|
|
14
14
|
|
|
@@ -258,7 +258,8 @@ open-memex reindex # 从 markdown 重建 SQLite 索引
|
|
|
258
258
|
open-memex migrate --to-v2 [--dry-run] # v1 数据 → v2
|
|
259
259
|
```
|
|
260
260
|
|
|
261
|
-
CLI 跑在 Node 22
|
|
261
|
+
CLI 跑在 Node 22 下。从源码 checkout 使用时走内置的实验性 TypeScript loader(无需构建);
|
|
262
|
+
发布的 npm 包带预编译好的 JS(发布时间执行 `npm run build`)。
|
|
262
263
|
从源码 checkout 使用时,每条命令前加
|
|
263
264
|
`node --experimental-strip-types src/cli.ts`(简单场景也可用
|
|
264
265
|
`npm run cli -- <命令>`——但 npm 会吞掉未知的 `--flag` 参数,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scan user text for memory-capture keyword triggers.
|
|
3
|
+
* Each pattern must have a single capture group whose value becomes the memory body.
|
|
4
|
+
* Personal patterns (cfg.keywordPersonalPatterns) force the personal scope.
|
|
5
|
+
*
|
|
6
|
+
* Personal patterns run first and claim their line: an explicit scope signal
|
|
7
|
+
* (e.g. "记住我对花粉过敏") must not also fire the generic 记住 into the project scope.
|
|
8
|
+
*/
|
|
9
|
+
export function detectKeywords(userText, cfg) {
|
|
10
|
+
if (!cfg.keywordCaptureEnabled)
|
|
11
|
+
return [];
|
|
12
|
+
const hits = [];
|
|
13
|
+
const lines = userText.split(/\r?\n/);
|
|
14
|
+
const claimed = new Set();
|
|
15
|
+
const scan = (patterns, personal, skipClaimed) => {
|
|
16
|
+
for (const src of patterns) {
|
|
17
|
+
let re;
|
|
18
|
+
try {
|
|
19
|
+
re = new RegExp(src, "i");
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
for (let i = 0; i < lines.length; i++) {
|
|
25
|
+
if (skipClaimed && claimed.has(i))
|
|
26
|
+
continue;
|
|
27
|
+
const m = lines[i].match(re);
|
|
28
|
+
if (!m || !m[1])
|
|
29
|
+
continue;
|
|
30
|
+
const content = m[1].trim().replace(/[.!?]$/, "").trim();
|
|
31
|
+
if (content.length >= 3 && content.length <= 2000) {
|
|
32
|
+
hits.push({ content, pattern: src, personal });
|
|
33
|
+
if (personal)
|
|
34
|
+
claimed.add(i);
|
|
35
|
+
break; // one hit per pattern per message
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
scan(cfg.keywordPersonalPatterns, true, false);
|
|
41
|
+
scan(cfg.keywordPatterns, false, true);
|
|
42
|
+
return hits;
|
|
43
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
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
|
+
function usage() {
|
|
17
|
+
console.log(`open-memex CLI
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
node --experimental-strip-types src/cli.ts where
|
|
21
|
+
node --experimental-strip-types src/cli.ts list [--scope project|personal] [--type T] [--limit N]
|
|
22
|
+
node --experimental-strip-types src/cli.ts search "query" [--scope project|personal|both] [--type T] [--limit N]
|
|
23
|
+
node --experimental-strip-types src/cli.ts add "content" [--scope project|personal] [--type T] [--tag t1,t2]
|
|
24
|
+
node --experimental-strip-types src/cli.ts supersede <id> "new content" [--type T] [--tag t1,t2]
|
|
25
|
+
node --experimental-strip-types src/cli.ts status <id> active|deprecated|retracted|archived
|
|
26
|
+
node --experimental-strip-types src/cli.ts forget <id>
|
|
27
|
+
node --experimental-strip-types src/cli.ts reindex
|
|
28
|
+
node --experimental-strip-types src/cli.ts scopes
|
|
29
|
+
node --experimental-strip-types src/cli.ts migrate [--from <key>] [--to <key>]
|
|
30
|
+
[--dry-run] [--on-conflict newer|overwrite|skip]
|
|
31
|
+
node --experimental-strip-types src/cli.ts migrate --to-v2 [--dry-run]
|
|
32
|
+
node --experimental-strip-types src/cli.ts mcp [--print-config vscode|cursor|claude|opencode|visualstudio]
|
|
33
|
+
node --experimental-strip-types src/cli.ts init [--client vscode|cursor|opencode|visualstudio] [--force] [--yes]
|
|
34
|
+
node --experimental-strip-types src/cli.ts config [set <key> <value>]
|
|
35
|
+
node --experimental-strip-types src/cli.ts capture --dry-run "text"
|
|
36
|
+
node --experimental-strip-types src/cli.ts doctor
|
|
37
|
+
|
|
38
|
+
One-command project setup: \`open-memex init\` (or \`npx open-memex@alpha init\`) writes
|
|
39
|
+
the MCP config for your editor (\`.vscode/mcp.json\`, \`.cursor/mcp.json\`,
|
|
40
|
+
\`opencode.jsonc\`, or Visual Studio's solution-level \`.mcp.json\`) plus
|
|
41
|
+
\`.github/copilot-instructions.md\` — no copy-paste needed.
|
|
42
|
+
Existing files are merged, never clobbered; re-running is safe. On a terminal it
|
|
43
|
+
asks which editor to set up and a couple of settings (keyword capture, first-turn
|
|
44
|
+
injection); \`--yes\` accepts all defaults, and non-terminal runs never prompt.
|
|
45
|
+
\`open-memex config set <key> <value>\` changes those settings after install.
|
|
46
|
+
|
|
47
|
+
Once installed globally (\`npm i -g open-memex@alpha\`) the \`open-memex\` command is
|
|
48
|
+
available directly: \`open-memex mcp\` starts the stdio MCP server (same five
|
|
49
|
+
memory_* tools as the opencode plugin); \`open-memex mcp --print-config <client>\`
|
|
50
|
+
prints a copy-paste MCP client config snippet.
|
|
51
|
+
|
|
52
|
+
Scope defaults to \`project\` (derived from cwd's git remote or path).
|
|
53
|
+
\`user\` is accepted as a deprecated alias of \`personal\`.
|
|
54
|
+
\`scopes\` lists every project scope dir with its file count — use it to find
|
|
55
|
+
the \`--from\` key when migrating.
|
|
56
|
+
\`migrate\` moves memories between scope keys — useful when a repo gains a
|
|
57
|
+
git remote after memories were already stored under the cwd-based key.
|
|
58
|
+
\`migrate --to-v2\` converts v1 memory files to the v2 format (§19):
|
|
59
|
+
user→personal scope rename, epoch→RFC 3339 times, priority→importance,
|
|
60
|
+
type: instruction→role split. Always preview with --dry-run first.`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
function parseFlags(argv) {
|
|
64
|
+
const out = {};
|
|
65
|
+
for (let i = 0; i < argv.length; i++) {
|
|
66
|
+
const a = argv[i];
|
|
67
|
+
if (a.startsWith("--")) {
|
|
68
|
+
const key = a.slice(2);
|
|
69
|
+
const val = argv[i + 1];
|
|
70
|
+
if (val !== undefined && !val.startsWith("--")) {
|
|
71
|
+
out[key] = val;
|
|
72
|
+
i++;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
out[key] = "true";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
/** CLI `--scope` flag → Scope. `user` is a deprecated alias of `personal`. */
|
|
82
|
+
function resolveCliScope(flags, project) {
|
|
83
|
+
return flags.scope === "personal" || flags.scope === "user"
|
|
84
|
+
? PERSONAL_SCOPE
|
|
85
|
+
: project;
|
|
86
|
+
}
|
|
87
|
+
/** Print a copy-paste MCP client config snippet. Requires a global install
|
|
88
|
+
* (`npm i -g open-memex@alpha`) so the `open-memex` command is on PATH. */
|
|
89
|
+
function printMcpConfig(client) {
|
|
90
|
+
const c = client.toLowerCase();
|
|
91
|
+
// D17: resolve the server command the same way `init` does.
|
|
92
|
+
const mc = resolveMcpCommand();
|
|
93
|
+
if (c === "vscode") {
|
|
94
|
+
console.log(JSON.stringify({
|
|
95
|
+
servers: {
|
|
96
|
+
"open-memex": {
|
|
97
|
+
type: "stdio",
|
|
98
|
+
command: mc.command,
|
|
99
|
+
args: mc.args,
|
|
100
|
+
cwd: "${workspaceFolder}",
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
}, null, 2));
|
|
104
|
+
}
|
|
105
|
+
else if (c === "cursor") {
|
|
106
|
+
console.log(JSON.stringify({
|
|
107
|
+
mcpServers: {
|
|
108
|
+
"open-memex": { command: mc.command, args: mc.args },
|
|
109
|
+
},
|
|
110
|
+
}, null, 2));
|
|
111
|
+
}
|
|
112
|
+
else if (c === "claude") {
|
|
113
|
+
console.log(`claude mcp add open-memex -- ${mc.command} ${mc.args.join(" ")}`);
|
|
114
|
+
}
|
|
115
|
+
else if (c === "opencode") {
|
|
116
|
+
// D16: opencode as a plain MCP consumer (alternative to the native plugin).
|
|
117
|
+
console.log(JSON.stringify({
|
|
118
|
+
mcp: {
|
|
119
|
+
"open-memex": {
|
|
120
|
+
type: "local",
|
|
121
|
+
command: [mc.command, ...mc.args],
|
|
122
|
+
enabled: true,
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
}, null, 2));
|
|
126
|
+
}
|
|
127
|
+
else if (c === "visualstudio" || c === "visual-studio") {
|
|
128
|
+
// D20: Visual Studio (Windows-only) reads solution-level `.mcp.json`.
|
|
129
|
+
console.log(JSON.stringify({
|
|
130
|
+
servers: {
|
|
131
|
+
"open-memex": {
|
|
132
|
+
type: "stdio",
|
|
133
|
+
command: mc.command,
|
|
134
|
+
args: mc.args,
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
}, null, 2));
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
console.error(`unknown client "${client}" (vscode|cursor|claude|opencode|visualstudio)`);
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
if (!mc.durable) {
|
|
144
|
+
console.error(`\n# note: no durable \`open-memex\` on PATH — snippet uses npx. \`npm i -g open-memex@alpha\` for faster startup.`);
|
|
145
|
+
}
|
|
146
|
+
process.exit(0);
|
|
147
|
+
}
|
|
148
|
+
async function main() {
|
|
149
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
150
|
+
if (!cmd)
|
|
151
|
+
usage();
|
|
152
|
+
const cfg = loadConfig();
|
|
153
|
+
const project = resolveProjectScope(process.cwd());
|
|
154
|
+
// `migrate --to-v2` is a pure file operation (V2-DESIGN §19) — it runs
|
|
155
|
+
// before the index is opened so it also works where the DB driver
|
|
156
|
+
// isn't available.
|
|
157
|
+
if (cmd === "migrate" && rest.includes("--to-v2")) {
|
|
158
|
+
const flags = parseFlags(rest);
|
|
159
|
+
const dryRun = flags["dry-run"] === "true";
|
|
160
|
+
const stats = migrateV2({ dryRun });
|
|
161
|
+
console.log(`${dryRun ? "DRY RUN: " : ""}scanned ${stats.scanned} files: ` +
|
|
162
|
+
`${stats.converted} to convert, ${stats.skippedV2} already v2`);
|
|
163
|
+
for (const p of stats.plans) {
|
|
164
|
+
console.log(`\n${p.fm.id}`);
|
|
165
|
+
console.log(` ${p.fromPath}${p.toPath !== p.fromPath ? `\n → ${p.toPath}` : ""}`);
|
|
166
|
+
for (const c of p.changes)
|
|
167
|
+
console.log(` - ${c}`);
|
|
168
|
+
}
|
|
169
|
+
if (stats.legacyBackup) {
|
|
170
|
+
console.log(`\nlegacy my-o-memory data dir merged; backup kept at:\n ${stats.legacyBackup}`);
|
|
171
|
+
}
|
|
172
|
+
if (!dryRun && stats.converted > 0) {
|
|
173
|
+
console.log(`\nindex schema will rebuild automatically on next run; ` +
|
|
174
|
+
`run \`open-memex reindex\` to verify.`);
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
// `init` is a pure file operation (§17 adoption path) — no DB needed.
|
|
179
|
+
// Interactive when on a TTY (asks editor + settings); --yes skips prompts.
|
|
180
|
+
if (cmd === "init") {
|
|
181
|
+
const flags = parseFlags(rest);
|
|
182
|
+
const { initProject } = await import("./init.js");
|
|
183
|
+
await initProject({
|
|
184
|
+
client: flags["client"],
|
|
185
|
+
force: flags["force"] === "true",
|
|
186
|
+
yes: flags["yes"] === "true",
|
|
187
|
+
});
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
// `doctor` runs environment health checks — no DB needed (it self-contains).
|
|
191
|
+
if (cmd === "doctor") {
|
|
192
|
+
const { runDoctor } = await import("./doctor.js");
|
|
193
|
+
const ok = await runDoctor();
|
|
194
|
+
if (!ok)
|
|
195
|
+
process.exitCode = 1;
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
// `config` prints the effective configuration (defaults + file). No DB needed.
|
|
199
|
+
// `config set <key> <value>` persists a setting to the config file.
|
|
200
|
+
if (cmd === "config") {
|
|
201
|
+
if (rest[0] === "set") {
|
|
202
|
+
const [, key, ...valueParts] = rest;
|
|
203
|
+
const { SETTABLE_KEYS, saveConfig, configFilePath } = await import("./config.js");
|
|
204
|
+
const validate = key ? SETTABLE_KEYS[key] : undefined;
|
|
205
|
+
if (!validate) {
|
|
206
|
+
console.error(`unknown or unsettable key "${key ?? ""}". Settable keys: ${Object.keys(SETTABLE_KEYS).join(", ")}`);
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
const raw = valueParts.join(" ");
|
|
210
|
+
if (!raw) {
|
|
211
|
+
console.error(`usage: open-memex config set <key> <value>`);
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
let value = raw;
|
|
215
|
+
try {
|
|
216
|
+
value = JSON.parse(raw);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
/* keep as string */
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const saved = validate(value);
|
|
223
|
+
const file = saveConfig({ [key]: saved });
|
|
224
|
+
console.log(`set ${key} = ${JSON.stringify(saved)} (${file})`);
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
console.error(`invalid value for ${key}: ${err.message}`);
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const cfg = loadConfig();
|
|
233
|
+
console.log(JSON.stringify(cfg, null, 2));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
// `capture --dry-run "text"` previews keyword capture without writing. No DB needed.
|
|
237
|
+
if (cmd === "capture") {
|
|
238
|
+
if (rest[0] !== "--dry-run") {
|
|
239
|
+
console.error(`usage: open-memex capture --dry-run "text"`);
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
const cfg = loadConfig();
|
|
243
|
+
const { detectKeywords } = await import("./capture/keywords.js");
|
|
244
|
+
const text = rest.slice(1).join(" ");
|
|
245
|
+
const hits = detectKeywords(text, cfg);
|
|
246
|
+
if (hits.length === 0) {
|
|
247
|
+
console.log("no keyword triggers — nothing would be captured.");
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
for (const h of hits) {
|
|
251
|
+
const r = redact(h.content, cfg.redactPatterns);
|
|
252
|
+
console.log(`- pattern: ${h.pattern}`);
|
|
253
|
+
console.log(` scope: ${h.personal ? "personal (forced by pattern)" : "current scope"}`);
|
|
254
|
+
console.log(` secret hit: ${r.hadSecret ? `yes (${r.matchedPattern}) — will be masked` : "no"}`);
|
|
255
|
+
console.log(` body: ${r.content.slice(0, 160)}${r.content.length > 160 ? "…" : ""}`);
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
// `mcp` starts the stdio MCP server (same tools as the opencode plugin).
|
|
260
|
+
// Branched before db() — runMcpServer() does its own init, and stdout must
|
|
261
|
+
// stay clean for the MCP protocol.
|
|
262
|
+
if (cmd === "mcp") {
|
|
263
|
+
const flags = parseFlags(rest);
|
|
264
|
+
if (flags["print-config"]) {
|
|
265
|
+
printMcpConfig(flags["print-config"] === "true" ? "vscode" : flags["print-config"]);
|
|
266
|
+
}
|
|
267
|
+
const { runMcpServer } = await import("./mcp.js");
|
|
268
|
+
await runMcpServer();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
db();
|
|
272
|
+
if (cmd === "where") {
|
|
273
|
+
const p = paths();
|
|
274
|
+
console.log(`root: ${p.root}`);
|
|
275
|
+
console.log(`memories: ${p.memories}`);
|
|
276
|
+
console.log(`index: ${p.indexDb}`);
|
|
277
|
+
console.log(`project: ${project.key}`);
|
|
278
|
+
console.log(`personal: ${PERSONAL_SCOPE.key}`);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (cmd === "reindex") {
|
|
282
|
+
const a = syncScope(project.key);
|
|
283
|
+
const b = syncScope(PERSONAL_SCOPE.key);
|
|
284
|
+
console.log(`reindexed. project: +${a.added} ~${a.updated} -${a.removed} (scanned ${a.scanned}), personal: +${b.added} ~${b.updated} -${b.removed} (scanned ${b.scanned})`);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (cmd === "list") {
|
|
288
|
+
const flags = parseFlags(rest);
|
|
289
|
+
const s = resolveCliScope(flags, project);
|
|
290
|
+
syncScope(s.key);
|
|
291
|
+
const hits = list(s.key, {
|
|
292
|
+
type: flags.type,
|
|
293
|
+
limit: flags.limit ? Number(flags.limit) : undefined,
|
|
294
|
+
});
|
|
295
|
+
if (hits.length === 0) {
|
|
296
|
+
console.log(`(no memories in ${s.key})`);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
for (const h of hits) {
|
|
300
|
+
const dep = h.status === "deprecated" ? " [deprecated]" : "";
|
|
301
|
+
console.log(`[${h.type}]${dep} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
|
|
302
|
+
}
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (cmd === "search") {
|
|
306
|
+
const query = rest[0];
|
|
307
|
+
if (!query || query.startsWith("--"))
|
|
308
|
+
usage();
|
|
309
|
+
const flags = parseFlags(rest.slice(1));
|
|
310
|
+
syncScope(project.key);
|
|
311
|
+
syncScope(PERSONAL_SCOPE.key);
|
|
312
|
+
const keys = flags.scope === "personal" || flags.scope === "user"
|
|
313
|
+
? [PERSONAL_SCOPE.key]
|
|
314
|
+
: flags.scope === "project"
|
|
315
|
+
? [project.key]
|
|
316
|
+
: [project.key, PERSONAL_SCOPE.key];
|
|
317
|
+
const hits = search(query, {
|
|
318
|
+
scopeKeys: keys,
|
|
319
|
+
limit: flags.limit ? Number(flags.limit) : undefined,
|
|
320
|
+
type: flags.type,
|
|
321
|
+
});
|
|
322
|
+
if (hits.length === 0) {
|
|
323
|
+
console.log("(no matches)");
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
for (const h of hits) {
|
|
327
|
+
const tag = h.scope_key === PERSONAL_SCOPE.key ? "personal" : "project";
|
|
328
|
+
const dep = h.status === "deprecated" ? " [deprecated]" : "";
|
|
329
|
+
console.log(`[${tag}/${h.type}]${dep} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
|
|
330
|
+
}
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (cmd === "add") {
|
|
334
|
+
const content = rest[0];
|
|
335
|
+
if (!content || content.startsWith("--"))
|
|
336
|
+
usage();
|
|
337
|
+
const flags = parseFlags(rest.slice(1));
|
|
338
|
+
const s = resolveCliScope(flags, project);
|
|
339
|
+
const { content: red, hadSecret, matchedPattern } = redact(content, cfg.redactPatterns);
|
|
340
|
+
// Dedup on write (§3.4): identical content is idempotent; near-duplicates
|
|
341
|
+
// warn but still save (use `supersede` when this replaces the old one).
|
|
342
|
+
// Secrets are masked (first 4 chars kept) and the write proceeds.
|
|
343
|
+
const dups = findDuplicates(s.key, red);
|
|
344
|
+
if (dups.exact) {
|
|
345
|
+
console.log(`already exists: id=${dups.exact.id} (identical content — not duplicated)`);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const now = Date.now();
|
|
349
|
+
const rfc = msToRfc3339(now);
|
|
350
|
+
const fm = {
|
|
351
|
+
id: ulid(),
|
|
352
|
+
schema_version: 2,
|
|
353
|
+
scope_key: s.key,
|
|
354
|
+
scope: s.kind === "project" ? "project" : "personal",
|
|
355
|
+
visibility: s.kind === "project" ? "internal" : "private",
|
|
356
|
+
project_name: s.projectName,
|
|
357
|
+
type: flags.type ?? "fact",
|
|
358
|
+
role: "knowledge",
|
|
359
|
+
importance: "normal",
|
|
360
|
+
status: "active",
|
|
361
|
+
tags: flags.tag
|
|
362
|
+
? flags.tag
|
|
363
|
+
.split(",")
|
|
364
|
+
.map((t) => t.trim())
|
|
365
|
+
.filter(Boolean)
|
|
366
|
+
: [],
|
|
367
|
+
source: "cli",
|
|
368
|
+
created_at: rfc,
|
|
369
|
+
updated_at: rfc,
|
|
370
|
+
supersedes: null,
|
|
371
|
+
superseded_by: null,
|
|
372
|
+
};
|
|
373
|
+
const { filePath } = writeMemoryFile(fm, red);
|
|
374
|
+
const mf = readMemoryFile(filePath);
|
|
375
|
+
if (mf)
|
|
376
|
+
upsertFromFile(mf);
|
|
377
|
+
console.log(`saved ${fm.id} [${s.kind}] -> ${filePath}`);
|
|
378
|
+
if (hadSecret) {
|
|
379
|
+
console.log(`warning: content matched secret pattern (${matchedPattern}); saved with the secret masked (first 4 chars kept).`);
|
|
380
|
+
}
|
|
381
|
+
for (const n of dups.near) {
|
|
382
|
+
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.`);
|
|
383
|
+
}
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (cmd === "supersede") {
|
|
387
|
+
const id = rest[0];
|
|
388
|
+
const content = rest[1];
|
|
389
|
+
if (!id || !content || content.startsWith("--"))
|
|
390
|
+
usage();
|
|
391
|
+
const flags = parseFlags(rest.slice(2));
|
|
392
|
+
const { content: red, hadSecret, matchedPattern } = redact(content, cfg.redactPatterns);
|
|
393
|
+
try {
|
|
394
|
+
const { oldMf, newMf } = supersede(id, {
|
|
395
|
+
body: red,
|
|
396
|
+
type: flags.type,
|
|
397
|
+
tags: flags.tag
|
|
398
|
+
? flags.tag.split(",").map((t) => t.trim()).filter(Boolean)
|
|
399
|
+
: undefined,
|
|
400
|
+
source: "cli",
|
|
401
|
+
});
|
|
402
|
+
upsertFromFile(oldMf);
|
|
403
|
+
upsertFromFile(newMf);
|
|
404
|
+
console.log(`superseded ${oldMf.fm.id} → ${newMf.fm.id}`);
|
|
405
|
+
if (hadSecret) {
|
|
406
|
+
console.log(`warning: content matched secret pattern (${matchedPattern}); saved with the secret masked (first 4 chars kept).`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
catch (e) {
|
|
410
|
+
console.error(`supersede failed: ${e.message}`);
|
|
411
|
+
process.exit(2);
|
|
412
|
+
}
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (cmd === "status") {
|
|
416
|
+
const id = rest[0];
|
|
417
|
+
const st = rest[1];
|
|
418
|
+
if (!id || !st)
|
|
419
|
+
usage();
|
|
420
|
+
try {
|
|
421
|
+
const mf = setStatus(id, st);
|
|
422
|
+
upsertFromFile(mf);
|
|
423
|
+
console.log(`status ${id} → ${mf.fm.status}`);
|
|
424
|
+
}
|
|
425
|
+
catch (e) {
|
|
426
|
+
console.error(`status failed: ${e.message}`);
|
|
427
|
+
process.exit(2);
|
|
428
|
+
}
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (cmd === "forget") {
|
|
432
|
+
const id = rest[0];
|
|
433
|
+
if (!id)
|
|
434
|
+
usage();
|
|
435
|
+
const row = db().prepare(`SELECT scope_key FROM memories WHERE id = ?`).get(id);
|
|
436
|
+
if (!row) {
|
|
437
|
+
console.error("not found");
|
|
438
|
+
process.exit(1);
|
|
439
|
+
}
|
|
440
|
+
deleteMemoryFile(row.scope_key, id);
|
|
441
|
+
deleteFromIndex(id);
|
|
442
|
+
console.log(`deleted ${id}`);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (cmd === "scopes") {
|
|
446
|
+
const { memories } = paths();
|
|
447
|
+
if (!fs.existsSync(memories)) {
|
|
448
|
+
console.log("(no memories dir yet)");
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const entries = [];
|
|
452
|
+
for (const name of fs.readdirSync(memories)) {
|
|
453
|
+
const dir = `${memories}/${name}`;
|
|
454
|
+
if (!fs.statSync(dir).isDirectory())
|
|
455
|
+
continue;
|
|
456
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".md")).length;
|
|
457
|
+
const marker = name === project.key ? " <- current project"
|
|
458
|
+
: name === PERSONAL_SCOPE.key ? " <- personal"
|
|
459
|
+
: "";
|
|
460
|
+
entries.push({ key: name, files, marker });
|
|
461
|
+
}
|
|
462
|
+
entries.sort((a, b) => b.files - a.files);
|
|
463
|
+
for (const e of entries) {
|
|
464
|
+
console.log(` ${e.files.toString().padStart(4)} ${e.key}${e.marker}`);
|
|
465
|
+
}
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (cmd === "migrate") {
|
|
469
|
+
const flags = parseFlags(rest);
|
|
470
|
+
const toKey = flags.to ?? project.key;
|
|
471
|
+
let fromKey = flags.from;
|
|
472
|
+
if (!fromKey) {
|
|
473
|
+
// Auto-detect: the "legacy" scope is what the key WOULD be if we
|
|
474
|
+
// ignored the git remote (pure cwd hash). Only offer it if it differs
|
|
475
|
+
// from the current project scope AND has files on disk.
|
|
476
|
+
const cwd = resolveCwdScope(process.cwd());
|
|
477
|
+
if (cwd.key !== project.key && scopeHasFiles(cwd.key)) {
|
|
478
|
+
fromKey = cwd.key;
|
|
479
|
+
console.log(`(auto-detected legacy scope: ${fromKey})`);
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
console.error(`no --from given and no legacy cwd-based scope with files detected.\n` +
|
|
483
|
+
`current project scope: ${project.key}\n` +
|
|
484
|
+
`cwd-only scope: ${cwd.key}`);
|
|
485
|
+
process.exit(2);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if (fromKey === toKey) {
|
|
489
|
+
console.error(`--from and --to are identical (${fromKey}); nothing to do.`);
|
|
490
|
+
process.exit(2);
|
|
491
|
+
}
|
|
492
|
+
const dryRun = flags["dry-run"] === "true";
|
|
493
|
+
const onConflict = flags["on-conflict"];
|
|
494
|
+
if (onConflict && !["newer", "overwrite", "skip"].includes(onConflict)) {
|
|
495
|
+
console.error(`invalid --on-conflict: ${onConflict}`);
|
|
496
|
+
process.exit(2);
|
|
497
|
+
}
|
|
498
|
+
const stats = migrateScope(fromKey, toKey, {
|
|
499
|
+
toProjectName: project.projectName,
|
|
500
|
+
dryRun,
|
|
501
|
+
onConflict,
|
|
502
|
+
logger: (m) => console.log(m),
|
|
503
|
+
});
|
|
504
|
+
console.log(`${dryRun ? "DRY RUN: " : ""}moved ${stats.moved}, skipped ${stats.skipped}, conflicts ${stats.conflicts}`);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
usage();
|
|
508
|
+
}
|
|
509
|
+
main()
|
|
510
|
+
.catch((err) => {
|
|
511
|
+
console.error(err);
|
|
512
|
+
process.exit(1);
|
|
513
|
+
})
|
|
514
|
+
.finally(() => {
|
|
515
|
+
closeDb();
|
|
516
|
+
});
|