open-memex 0.6.1-alpha.1 → 0.6.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 CHANGED
@@ -18,6 +18,8 @@ via `prepublishOnly` — Node refuses `--experimental-strip-types` for files und
18
18
 
19
19
  Tool logic is host-agnostic and lives in `src/tools/ops.ts` (plain functions + shared zod arg shapes + `TOOL_DESCRIPTIONS`). `src/tools/memory.ts` (opencode) and `src/mcp.ts` are thin adapters — when adding or changing a tool, change `ops.ts` once and both hosts pick it up.
20
20
 
21
+ **Plugin code must not runtime-import `@opencode-ai/plugin`** (D58): it is a devDependency, and opencode does not resolve it for `file://` plugins loaded from a global npm install — the import fails and opencode silently skips the plugin (no error, no tools). Type-only imports are fine. `src/tools/memory.ts` carries a local stand-in for the SDK's `tool()` (a runtime identity) for exactly this reason.
22
+
21
23
  Consequences:
22
24
  - Imports **must** use explicit `.ts` extensions (`allowImportingTsExtensions: true`, `moduleResolution: "Bundler"`).
23
25
  - No transpile / bundle output. Do not add one; opencode loads the `.ts` file directly.
@@ -31,6 +33,7 @@ npm run typecheck # tsc --noEmit — the only
31
33
  npm run cli -- where | list | search "q" | add ... | forget <id> | reindex
32
34
  npm run cli -- <command> --help # per-command help (AI assistants discover flags this way)
33
35
  npm run cli -- sync-status # last sync time/kind + outbox drafts + repo review states + uncommitted files
36
+ npm run cli -- audit [--scope project|personal|both] # memory health: near-dup pairs, stale actives, broken chains, personal-in-repo (read-only)
34
37
  npm run cli -- pull # fetch + fast-forward only (explicit; diverged = clean failure, never force-merge)
35
38
  npm run cli -- push # push current branch to remote (explicit only; open-memex never auto-pushes)
36
39
  npm run cli -- export [--scope project|personal|both] [--all] [-o <file>] # portable .tar.gz bundle (markdown + manifest); private excluded unless --all
package/README.md CHANGED
@@ -598,6 +598,7 @@ open-memex uninstall [--client vscode|cursor|opencode|visualstudio] [--global] [
598
598
  open-memex config # print effective config
599
599
  open-memex config set <key> <value> # change a setting
600
600
  open-memex doctor # environment health check
601
+ open-memex audit # memory health check (duplicates, stale, broken chains)
601
602
  open-memex capture --dry-run "记住我喜欢简洁的回答" # preview keyword capture
602
603
  open-memex mcp --print-config vscode|cursor|claude|opencode|visualstudio
603
604
  open-memex --help # this reference
@@ -610,6 +611,7 @@ Memory operations:
610
611
  ```sh
611
612
  open-memex add "This repo uses better-sqlite3" --type fact
612
613
  open-memex search "auth flow"
614
+ open-memex search "auth flow" --explain # show FTS expression, scores, lifecycle-hidden counts
613
615
  open-memex list --scope project
614
616
  open-memex supersede <id> "Updated content"
615
617
  open-memex status <id> deprecated
@@ -711,6 +713,7 @@ Maintenance:
711
713
  open-memex where # show storage + config paths
712
714
  open-memex scopes # list project scopes with memory counts
713
715
  open-memex reindex # rebuild the SQLite index from markdown
716
+ open-memex audit # memory health: duplicate pairs, stale memories, broken chains
714
717
  open-memex migrate --to-v2 [--dry-run] # v1 data → v2 (renames user scope to personal)
715
718
  ```
716
719
 
package/README.zh-CN.md CHANGED
@@ -420,6 +420,7 @@ open-memex uninstall [--client vscode|cursor|opencode|visualstudio] [--global] [
420
420
  open-memex config # 打印生效配置
421
421
  open-memex config set <key> <value> # 改一个设置
422
422
  open-memex doctor # 环境健康检查
423
+ open-memex audit # 记忆健康检查(近重复、陈旧、断链)
423
424
  open-memex capture --dry-run "记住我喜欢简洁的回答" # 预览关键词捕获
424
425
  open-memex mcp --print-config vscode|cursor|claude|opencode|visualstudio
425
426
  open-memex --help # 这份参考
@@ -432,6 +433,7 @@ open-memex --version # 已安装版本
432
433
  ```sh
433
434
  open-memex add "This repo uses better-sqlite3" --type fact
434
435
  open-memex search "auth flow"
436
+ open-memex search "auth flow" --explain # 显示 FTS 表达式、分数、被生命周期藏掉的计数
435
437
  open-memex list --scope project
436
438
  open-memex supersede <id> "Updated content"
437
439
  open-memex status <id> deprecated
@@ -514,6 +516,7 @@ open-memex resolve [id-or-path]
514
516
  open-memex where # 显示存储与配置路径
515
517
  open-memex scopes # 列出各项目作用域及记忆计数
516
518
  open-memex reindex # 从 markdown 重建 SQLite 索引
519
+ open-memex audit # 记忆健康检查:近重复对、陈旧记忆、断裂的取代链
517
520
  open-memex migrate --to-v2 [--dry-run] # v1 数据 → v2(把 user 作用域改名为 personal)
518
521
  ```
519
522
 
package/dist/cli.js CHANGED
@@ -41,12 +41,14 @@ Examples:
41
41
  open-memex list --scope personal --limit 20`,
42
42
  search: `Search memories by keyword (BM25 full-text), best matches first.
43
43
 
44
- Usage: open-memex search "query" [--scope project|personal|both] [--type T] [--limit N]
44
+ Usage: open-memex search "query" [--scope project|personal|both] [--type T] [--limit N] [--explain]
45
45
 
46
46
  Flags:
47
- --scope project (default), personal, or both
48
- --type filter by memory type
49
- --limit max results
47
+ --scope project (default), personal, or both
48
+ --type filter by memory type
49
+ --limit max results
50
+ --explain show the FTS expression, per-hit score, and how many matches
51
+ lifecycle hid (superseded / retracted / archived)
50
52
 
51
53
  Example:
52
54
  open-memex search "deploy checklist" --scope both`,
@@ -304,6 +306,18 @@ Usage: open-memex doctor
304
306
 
305
307
  Example:
306
308
  open-memex doctor`,
309
+ audit: `Memory health audit (read-only, reports never mutates): near-duplicate
310
+ pairs among active memories, stale actives untouched for 90+ days, broken
311
+ supersede chains, personal files found inside the repo memory dir, and
312
+ index/file drift.
313
+
314
+ Usage: open-memex audit [--scope project|personal|both]
315
+
316
+ Flags:
317
+ --scope project (default), personal, or both
318
+
319
+ Example:
320
+ open-memex audit --scope both`,
307
321
  };
308
322
  function usage(exitCode = 1) {
309
323
  console.log(`open-memex CLI
@@ -311,7 +325,7 @@ function usage(exitCode = 1) {
311
325
  Usage:
312
326
  open-memex where
313
327
  open-memex list [--scope project|personal] [--type T] [--limit N]
314
- open-memex search "query" [--scope project|personal|both] [--type T] [--limit N]
328
+ open-memex search "query" [--scope project|personal|both] [--type T] [--limit N] [--explain]
315
329
  open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2]
316
330
  open-memex supersede <id> "new content" [--type T] [--tag t1,t2]
317
331
  open-memex status <id> active|deprecated|retracted|archived
@@ -328,6 +342,7 @@ Usage:
328
342
  open-memex submit <id...> [--branch <name>] [--base <branch>]
329
343
  open-memex pr-status [--apply]
330
344
  open-memex reindex
345
+ open-memex audit [--scope project|personal|both]
331
346
  open-memex scopes
332
347
  open-memex migrate [--from <key>] [--to <key>]
333
348
  [--dry-run] [--on-conflict newer|overwrite|skip]
@@ -621,6 +636,36 @@ async function main() {
621
636
  process.exitCode = 1;
622
637
  return;
623
638
  }
639
+ if (cmd === "audit") {
640
+ const flags = parseFlags(rest);
641
+ syncScope(project.key, "cli");
642
+ syncScope(PERSONAL_SCOPE.key, "cli");
643
+ const keys = flags.scope === "personal"
644
+ ? [PERSONAL_SCOPE.key]
645
+ : flags.scope === "both"
646
+ ? [project.key, PERSONAL_SCOPE.key]
647
+ : [project.key];
648
+ const { runAudit, STALE_DAYS } = await import("./store/audit.js");
649
+ const r = runAudit(keys);
650
+ console.log(`audit: ${r.totalMemories} memories (${keys.length === 2 ? "project+personal" : keys[0] === PERSONAL_SCOPE.key ? "personal" : "project"})`);
651
+ console.log(` near-duplicate active pairs: ${r.duplicatePairs.length}`);
652
+ for (const p of r.duplicatePairs.slice(0, 10)) {
653
+ console.log(` ${p.a} ~ ${p.b} (similarity ${p.score.toFixed(2)}) — consider supersede`);
654
+ }
655
+ console.log(` stale actives (>= ${STALE_DAYS} days untouched): ${r.stale.length}`);
656
+ for (const s of r.stale.slice(0, 10)) {
657
+ console.log(` ${s.id} — ${s.ageDays} days`);
658
+ }
659
+ console.log(` broken supersede chains: ${r.brokenChains.length}`);
660
+ for (const b of r.brokenChains) {
661
+ console.log(` ${b.id} -> missing ${b.missingTarget}`);
662
+ }
663
+ console.log(` personal files inside repo memory dir: ${r.personalInRepo.length}`);
664
+ for (const f of r.personalInRepo)
665
+ console.log(` ${f}`);
666
+ console.log(` index drift: ${r.drift.filesNotIndexed} files not indexed, ${r.drift.rowsMissingFiles} rows missing files`);
667
+ return;
668
+ }
624
669
  // `config` prints the effective configuration (defaults + file). No DB needed.
625
670
  // `config set <key> <value>` persists a setting to the config file.
626
671
  if (cmd === "config") {
@@ -743,11 +788,20 @@ async function main() {
743
788
  : flags.scope === "project"
744
789
  ? [project.key]
745
790
  : [project.key, PERSONAL_SCOPE.key];
791
+ const explain = flags.explain === "true";
792
+ const stats = explain
793
+ ? { ftsQuery: "", candidates: 0, hiddenSuperseded: 0, hiddenExcluded: 0 }
794
+ : undefined;
746
795
  const hits = search(query, {
747
796
  scopeKeys: keys,
748
797
  limit: flags.limit ? Number(flags.limit) : undefined,
749
798
  type: flags.type,
799
+ stats,
750
800
  });
801
+ if (explain && stats) {
802
+ console.log(`fts: ${stats.ftsQuery || "(empty query)"}`);
803
+ console.log(`candidates: ${stats.candidates}, hidden by lifecycle: ${stats.hiddenSuperseded} superseded, ${stats.hiddenExcluded} retracted/archived`);
804
+ }
751
805
  if (hits.length === 0) {
752
806
  console.log("(no matches)");
753
807
  return;
@@ -756,7 +810,8 @@ async function main() {
756
810
  const tag = h.scope_key === PERSONAL_SCOPE.key ? "personal" : "project";
757
811
  const dep = h.status === "deprecated" ? " [deprecated]" : "";
758
812
  const rev = hitStateLabel(h);
759
- console.log(`[${tag}/${h.type}]${dep}${rev} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
813
+ const sc = explain ? ` score=${h.score.toFixed(3)}` : "";
814
+ console.log(`[${tag}/${h.type}]${dep}${rev}${sc} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
760
815
  }
761
816
  return;
762
817
  }
@@ -1261,7 +1316,16 @@ async function main() {
1261
1316
  }
1262
1317
  main()
1263
1318
  .catch((err) => {
1264
- console.error(err);
1319
+ // D57: lock contention past the busy timeout should read as a transient
1320
+ // condition, not a stack trace. The memory file is written before the
1321
+ // index update, so a locked-out write still lands on the next sync.
1322
+ const code = err?.code;
1323
+ if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED") {
1324
+ console.error("open-memex: the memory store is busy (another open-memex process is writing). Wait a moment and retry — nothing was lost.");
1325
+ }
1326
+ else {
1327
+ console.error(err);
1328
+ }
1265
1329
  process.exit(1);
1266
1330
  })
1267
1331
  .finally(() => {
package/dist/doctor.js CHANGED
@@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url";
10
10
  import { loadConfig, configSource, DEFAULT_CONFIG } from "./config.js";
11
11
  import { paths } from "./paths.js";
12
12
  import { resolveCwdScope } from "./scope.js";
13
+ import { db, backendName } from "./store/db.js";
13
14
  import { LEGACY_PACKAGE_NAME, opencodeGlobalConfigPath, userMcpConfigPath } from "./init.js";
14
15
  const EXPECTED_TOOLS = [
15
16
  "memory_add",
@@ -59,6 +60,27 @@ function storageCheck() {
59
60
  return { name: "storage", ok: false, detail: String(err) };
60
61
  }
61
62
  }
63
+ /**
64
+ * D57: several processes share one index (CLI, MCP per editor, opencode
65
+ * plugin). WAL + a 5s busy timeout is what keeps an overlapping write a
66
+ * brief wait instead of an instant failure — report the live settings.
67
+ */
68
+ function lockingCheck() {
69
+ try {
70
+ const d = db();
71
+ const jm = d.prepare("PRAGMA journal_mode").get().journal_mode;
72
+ const bt = d.prepare("PRAGMA busy_timeout").get().timeout;
73
+ const ok = jm === "wal" && bt >= 1000;
74
+ return {
75
+ name: "locking",
76
+ ok,
77
+ detail: `journal=${jm}, busy_timeout=${bt}ms (${backendName()})`,
78
+ };
79
+ }
80
+ catch (err) {
81
+ return { name: "locking", ok: false, detail: String(err) };
82
+ }
83
+ }
62
84
  /** Spawn the real MCP server, handshake, and verify the five tools list. */
63
85
  function mcpCheck() {
64
86
  const name = "mcp";
@@ -277,7 +299,7 @@ function vscodeMcpCheck() {
277
299
  }
278
300
  export async function runDoctor() {
279
301
  console.log("open-memex doctor");
280
- const checks = [nodeCheck(), configCheck(), scopeCheck(), storageCheck(), vscodeMcpCheck()];
302
+ const checks = [nodeCheck(), configCheck(), scopeCheck(), storageCheck(), lockingCheck(), vscodeMcpCheck()];
281
303
  checks.push(await mcpCheck());
282
304
  checks.push(legacyCheck());
283
305
  let allOk = true;
@@ -44,7 +44,7 @@ export function hitStateLabel(h) {
44
44
  * drafts/rejected rank after unreviewed content;
45
45
  * - deprecated stays visible as a warning but ranks after active.
46
46
  */
47
- function resolveVisible(rows, limit) {
47
+ function resolveVisible(rows, limit, stats) {
48
48
  const byId = new Map(rows.map((r) => [r.id, r]));
49
49
  const fullRow = (id) => {
50
50
  const cached = byId.get(id);
@@ -65,10 +65,15 @@ function resolveVisible(rows, limit) {
65
65
  const tiers = [[], [], [], []];
66
66
  const deprecated = [];
67
67
  for (const r of rows) {
68
- if (r.status === "retracted" || r.status === "archived")
68
+ if (r.status === "retracted" || r.status === "archived") {
69
+ if (stats)
70
+ stats.hiddenExcluded++;
69
71
  continue;
72
+ }
70
73
  let target = r;
71
74
  if (r.status === "superseded") {
75
+ if (stats)
76
+ stats.hiddenSuperseded++;
72
77
  let cur = r;
73
78
  const chain = new Set([r.id]);
74
79
  while (cur.status === "superseded" && cur.superseded_by) {
@@ -102,6 +107,12 @@ function resolveVisible(rows, limit) {
102
107
  */
103
108
  export function search(query, opts = {}) {
104
109
  const q = toFtsQuery(query);
110
+ if (opts.stats) {
111
+ opts.stats.ftsQuery = q;
112
+ opts.stats.candidates = 0;
113
+ opts.stats.hiddenSuperseded = 0;
114
+ opts.stats.hiddenExcluded = 0;
115
+ }
105
116
  if (!q)
106
117
  return [];
107
118
  const limit = Math.max(1, Math.min(opts.limit ?? 8, 50));
@@ -134,7 +145,9 @@ export function search(query, opts = {}) {
134
145
  .all(...params);
135
146
  // FTS5 bm25: lower = better; invert for intuition.
136
147
  const raw = rows.map((r) => ({ ...r, score: -r.score }));
137
- return resolveVisible(raw, limit);
148
+ if (opts.stats)
149
+ opts.stats.candidates = raw.length;
150
+ return resolveVisible(raw, limit, opts.stats);
138
151
  }
139
152
  export function list(scopeKey, opts = {}) {
140
153
  const limit = Math.max(1, Math.min(opts.limit ?? 20, 100));
@@ -0,0 +1,101 @@
1
+ import fs from "node:fs";
2
+ import { db } from "./db.js";
3
+ import { readMemoryFile, iterInRepoMemoryFiles, iterMemoryFiles, } from "./markdown.js";
4
+ import { similarity, NEAR_DUP_THRESHOLD } from "./lifecycle.js";
5
+ import { projectRoot } from "../paths.js";
6
+ import { loadConfig } from "../config.js";
7
+ // Memory health audit (D57). Read-only: reports, never mutates.
8
+ // Checks, per open question "retrieval explainability & memory health":
9
+ // - near-duplicate pairs among active memories (same dedup yardstick
10
+ // as write-time: Jaccard >= 0.8) that were never superseded;
11
+ // - stale actives: untouched for STALE_DAYS, still in the retrieval /
12
+ // injection pool;
13
+ // - broken supersede chains: superseded_by pointing at a missing id;
14
+ // - personal files inside the in-repo memory dir (iron-rule breach:
15
+ // personal memory must never land in a shared repo);
16
+ // - index/file drift after sync (should be zero; reported if not).
17
+ export const STALE_DAYS = 90;
18
+ export function runAudit(scopeKeys) {
19
+ const d = db();
20
+ const rows = d
21
+ .prepare(`SELECT id, scope_key, status, updated_at, superseded_by, content, file_path
22
+ FROM memories`)
23
+ .all();
24
+ const inScope = rows.filter((r) => scopeKeys.includes(r.scope_key));
25
+ const ids = new Set(rows.map((r) => r.id));
26
+ // Near-duplicate pairs: active only, same scope. O(n^2) per scope with a
27
+ // length pre-filter; local stores are small enough for this to be fine.
28
+ const pairs = [];
29
+ const byScope = new Map();
30
+ for (const r of inScope) {
31
+ if (r.status !== "active")
32
+ continue;
33
+ const arr = byScope.get(r.scope_key) ?? [];
34
+ arr.push(r);
35
+ byScope.set(r.scope_key, arr);
36
+ }
37
+ for (const [scopeKey, arr] of byScope) {
38
+ for (let i = 0; i < arr.length; i++) {
39
+ for (let j = i + 1; j < arr.length; j++) {
40
+ const a = arr[i];
41
+ const b = arr[j];
42
+ const la = a.content.length;
43
+ const lb = b.content.length;
44
+ if (Math.min(la, lb) / Math.max(la, lb) < 0.5)
45
+ continue;
46
+ const score = similarity(a.content, b.content);
47
+ if (score >= NEAR_DUP_THRESHOLD) {
48
+ pairs.push({ a: a.id, b: b.id, scopeKey, score });
49
+ }
50
+ }
51
+ }
52
+ }
53
+ pairs.sort((x, y) => y.score - x.score);
54
+ const now = Date.now();
55
+ const stale = inScope
56
+ .filter((r) => r.status === "active")
57
+ .map((r) => ({ id: r.id, scopeKey: r.scope_key, ageDays: Math.floor((now - r.updated_at) / 86_400_000) }))
58
+ .filter((s) => s.ageDays >= STALE_DAYS)
59
+ .sort((x, y) => y.ageDays - x.ageDays);
60
+ const brokenChains = inScope
61
+ .filter((r) => r.superseded_by && !ids.has(r.superseded_by))
62
+ .map((r) => ({ id: r.id, scopeKey: r.scope_key, missingTarget: r.superseded_by }));
63
+ // Personal files must never sit in the in-repo dir. Read-only: this only
64
+ // parses frontmatter of files already in the repo tree.
65
+ const personalInRepo = [];
66
+ try {
67
+ const root = projectRoot();
68
+ const memoryDir = loadConfig().memoryDir;
69
+ for (const fp of iterInRepoMemoryFiles(root, memoryDir)) {
70
+ const mf = readMemoryFile(fp);
71
+ if (mf && mf.fm.scope === "personal")
72
+ personalInRepo.push(fp);
73
+ }
74
+ }
75
+ catch {
76
+ // No project root (e.g. personal-only usage): nothing to scan.
77
+ }
78
+ // Drift: index rows whose file vanished, and files on disk the index
79
+ // never picked up. syncScope normally heals both before audit runs.
80
+ const indexedPaths = new Set(rows.map((r) => r.file_path));
81
+ let rowsMissingFiles = 0;
82
+ for (const r of inScope) {
83
+ if (!r.file_path || !fs.existsSync(r.file_path))
84
+ rowsMissingFiles++;
85
+ }
86
+ let filesNotIndexed = 0;
87
+ for (const key of scopeKeys) {
88
+ for (const fp of iterMemoryFiles(key)) {
89
+ if (!indexedPaths.has(fp))
90
+ filesNotIndexed++;
91
+ }
92
+ }
93
+ return {
94
+ totalMemories: inScope.length,
95
+ duplicatePairs: pairs,
96
+ stale,
97
+ brokenChains,
98
+ personalInRepo,
99
+ drift: { filesNotIndexed, rowsMissingFiles },
100
+ };
101
+ }
package/dist/store/db.js CHANGED
@@ -111,6 +111,11 @@ export function db() {
111
111
  const Database = loadDatabase();
112
112
  const d = new Database(indexDb);
113
113
  d.exec("PRAGMA journal_mode = WAL;");
114
+ // Multiple processes share this index (CLI, one MCP server per editor,
115
+ // the opencode plugin). Without a busy timeout, bun:sqlite defaults to
116
+ // 0 — any overlapping write fails instantly. 5s matches the tolerance
117
+ // better-sqlite3 was already giving the CLI/MCP side (D57).
118
+ d.exec("PRAGMA busy_timeout = 5000;");
114
119
  d.exec("PRAGMA synchronous = NORMAL;");
115
120
  d.exec("PRAGMA foreign_keys = ON;");
116
121
  d.exec(TABLE_SCHEMA);
@@ -205,7 +205,12 @@ export function writeMemoryFile(fm, body) {
205
205
  // Personal stays in appdata and never leaves the machine.
206
206
  const dir = memoriesDirFor(fm.scope_key);
207
207
  const filePath = path.join(dir, `${fm.id}.md`);
208
- fs.writeFileSync(filePath, serialize(fm, body), "utf8");
208
+ // Atomic publish (D57): write a sibling tmp file and rename over the
209
+ // target, so a concurrent reader (another process mid-sync) never sees a
210
+ // torn half-written memory. Same-directory rename is atomic on one fs.
211
+ const tmpPath = `${filePath}.tmp-${process.pid}`;
212
+ fs.writeFileSync(tmpPath, serialize(fm, body), "utf8");
213
+ fs.renameSync(tmpPath, filePath);
209
214
  const st = fs.statSync(filePath);
210
215
  return { filePath, mtimeMs: st.mtimeMs };
211
216
  }
@@ -1,5 +1,13 @@
1
- import { tool } from "@opencode-ai/plugin/tool";
2
1
  import { addMemory, searchMemories, listMemories, supersedeMemory, forgetMemory, memoryAddArgs, memorySearchArgs, memoryListArgs, memorySupersedeArgs, memoryForgetArgs, TOOL_DESCRIPTIONS, withOutboxNote, } from "./ops.js";
2
+ // Local stand-in for the SDK's tool() helper — same signature, and the SDK
3
+ // function is a runtime identity (`return input`). Do NOT import it from
4
+ // @opencode-ai/plugin at runtime: that package is a devDependency, and when
5
+ // opencode loads this plugin by file:// URL from a global npm install
6
+ // nothing resolves the module — the import fails and opencode silently
7
+ // skips the whole plugin: no error, no tools (D58).
8
+ function tool(input) {
9
+ return input;
10
+ }
3
11
  export function makeTools(getScope, cfg) {
4
12
  const memory_add = tool({
5
13
  description: TOOL_DESCRIPTIONS.memory_add,
package/docs/V2-DESIGN.md CHANGED
@@ -1152,6 +1152,51 @@ requirement: personal data never touches third-party services). Benchmarks to tr
1152
1152
  of maximum doubt: memories live only on this machine, personal ones never
1153
1153
  leave it, nothing is uploaded.
1154
1154
 
1155
+ - **D57** — explainable search, a memory audit, and the concurrency posture
1156
+ written down (0.6.1-alpha.2; addresses most of the retrieval-explainability
1157
+ / memory-health open question and establishes the concurrent-sessions
1158
+ behavior it asked for). (1) `open-memex search --explain` prints the
1159
+ constructed FTS expression, per-hit bm25 score, and how many matches
1160
+ lifecycle hid (superseded vs retracted/archived), so a miss is diagnosable
1161
+ — bad query, stale memory, or chain-hidden — without reading the index.
1162
+ (2) New `open-memex audit` (read-only; doctor checks the environment, this
1163
+ checks the memories): near-duplicate active pairs (same ≥ 0.8 Jaccard as
1164
+ write-time dedup), actives untouched for 90+ days, broken supersede chains,
1165
+ personal files found inside the in-repo memory dir (iron-rule breach), and
1166
+ index/file drift. Reports only; fixes stay with `supersede` / `forget` by
1167
+ hand. (3) Concurrency, measured: every process opens its own connection to
1168
+ one WAL index; memory files are per-memory ULIDs, so concurrent writes to
1169
+ *different* memories never collide on disk. SQLite was the gap — no
1170
+ `busy_timeout` was set, so better-sqlite3 silently tolerated 5s while
1171
+ bun:sqlite (opencode plugin) timed out at 0 and failed instantly under any
1172
+ overlapping write. Now unified at `PRAGMA busy_timeout = 5000` in `db()`.
1173
+ Past the timeout the CLI prints one plain line ("store is busy … nothing
1174
+ was lost") instead of a stack trace — and nothing *is* lost: the memory
1175
+ file is written before the index update, so the next sync reconciles it
1176
+ (verified end-to-end: a write blocked 8s still lands once the lock frees).
1177
+ Memory file writes are tmp+rename, so a concurrent reader never sees a
1178
+ torn file. Same-memory concurrent edits remain last-writer-wins (accepted:
1179
+ different processes editing the same memory simultaneously is a human
1180
+ conflict, not a storage one). `doctor` reports the live locking settings.
1181
+
1182
+ - **D58** — the opencode plugin must never runtime-import the host SDK
1183
+ (0.6.1-alpha.3). Field report: after a global `npm i -g open-memex`, the
1184
+ plugin was configured in `~/.config/opencode/opencode.jsonc` but silently
1185
+ inactive — no memory tools, no error, nothing in opencode's log. Bisected
1186
+ with the real host (opencode 1.18.34) and a minimal plugin: a bare
1187
+ `file://` entry loads fine; adding `import { tool } from
1188
+ "@opencode-ai/plugin/tool"` makes opencode skip the plugin without a
1189
+ trace. That import only ever resolved from a source checkout (where
1190
+ `npm install` provides the devDependency); from a global install's
1191
+ location nothing provides the module and the host does not resolve it
1192
+ for file:// plugins. The helper is a runtime identity (`return input`),
1193
+ so the fix is a local same-signature stand-in in `tools/memory.ts` —
1194
+ types still come from the SDK (type-only imports erase at runtime), and
1195
+ the plugin now depends only on real runtime dependencies. Verified in
1196
+ the real host against a global-install-shaped tree: all five tools
1197
+ register. Rule going forward: plugin code imports host SDK types only;
1198
+ runtime imports must resolve from the package's own dependencies.
1199
+
1155
1200
  ## Open Questions
1156
1201
 
1157
1202
  _All resolved — see D10 (rename), D11 (type/role split), D12 (explicit pull)._
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-memex",
3
- "version": "0.6.1-alpha.1",
3
+ "version": "0.6.1",
4
4
  "description": "Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/src/cli.ts CHANGED
@@ -50,12 +50,14 @@ Examples:
50
50
 
51
51
  search: `Search memories by keyword (BM25 full-text), best matches first.
52
52
 
53
- Usage: open-memex search "query" [--scope project|personal|both] [--type T] [--limit N]
53
+ Usage: open-memex search "query" [--scope project|personal|both] [--type T] [--limit N] [--explain]
54
54
 
55
55
  Flags:
56
- --scope project (default), personal, or both
57
- --type filter by memory type
58
- --limit max results
56
+ --scope project (default), personal, or both
57
+ --type filter by memory type
58
+ --limit max results
59
+ --explain show the FTS expression, per-hit score, and how many matches
60
+ lifecycle hid (superseded / retracted / archived)
59
61
 
60
62
  Example:
61
63
  open-memex search "deploy checklist" --scope both`,
@@ -337,6 +339,19 @@ Usage: open-memex doctor
337
339
 
338
340
  Example:
339
341
  open-memex doctor`,
342
+
343
+ audit: `Memory health audit (read-only, reports never mutates): near-duplicate
344
+ pairs among active memories, stale actives untouched for 90+ days, broken
345
+ supersede chains, personal files found inside the repo memory dir, and
346
+ index/file drift.
347
+
348
+ Usage: open-memex audit [--scope project|personal|both]
349
+
350
+ Flags:
351
+ --scope project (default), personal, or both
352
+
353
+ Example:
354
+ open-memex audit --scope both`,
340
355
  };
341
356
 
342
357
  function usage(exitCode = 1): never {
@@ -345,7 +360,7 @@ function usage(exitCode = 1): never {
345
360
  Usage:
346
361
  open-memex where
347
362
  open-memex list [--scope project|personal] [--type T] [--limit N]
348
- open-memex search "query" [--scope project|personal|both] [--type T] [--limit N]
363
+ open-memex search "query" [--scope project|personal|both] [--type T] [--limit N] [--explain]
349
364
  open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2]
350
365
  open-memex supersede <id> "new content" [--type T] [--tag t1,t2]
351
366
  open-memex status <id> active|deprecated|retracted|archived
@@ -362,6 +377,7 @@ Usage:
362
377
  open-memex submit <id...> [--branch <name>] [--base <branch>]
363
378
  open-memex pr-status [--apply]
364
379
  open-memex reindex
380
+ open-memex audit [--scope project|personal|both]
365
381
  open-memex scopes
366
382
  open-memex migrate [--from <key>] [--to <key>]
367
383
  [--dry-run] [--on-conflict newer|overwrite|skip]
@@ -698,6 +714,39 @@ async function main() {
698
714
  return;
699
715
  }
700
716
 
717
+ if (cmd === "audit") {
718
+ const flags = parseFlags(rest);
719
+ syncScope(project.key, "cli");
720
+ syncScope(PERSONAL_SCOPE.key, "cli");
721
+ const keys =
722
+ flags.scope === "personal"
723
+ ? [PERSONAL_SCOPE.key]
724
+ : flags.scope === "both"
725
+ ? [project.key, PERSONAL_SCOPE.key]
726
+ : [project.key];
727
+ const { runAudit, STALE_DAYS } = await import("./store/audit.ts");
728
+ const r = runAudit(keys);
729
+ console.log(`audit: ${r.totalMemories} memories (${keys.length === 2 ? "project+personal" : keys[0] === PERSONAL_SCOPE.key ? "personal" : "project"})`);
730
+ console.log(` near-duplicate active pairs: ${r.duplicatePairs.length}`);
731
+ for (const p of r.duplicatePairs.slice(0, 10)) {
732
+ console.log(` ${p.a} ~ ${p.b} (similarity ${p.score.toFixed(2)}) — consider supersede`);
733
+ }
734
+ console.log(` stale actives (>= ${STALE_DAYS} days untouched): ${r.stale.length}`);
735
+ for (const s of r.stale.slice(0, 10)) {
736
+ console.log(` ${s.id} — ${s.ageDays} days`);
737
+ }
738
+ console.log(` broken supersede chains: ${r.brokenChains.length}`);
739
+ for (const b of r.brokenChains) {
740
+ console.log(` ${b.id} -> missing ${b.missingTarget}`);
741
+ }
742
+ console.log(` personal files inside repo memory dir: ${r.personalInRepo.length}`);
743
+ for (const f of r.personalInRepo) console.log(` ${f}`);
744
+ console.log(
745
+ ` index drift: ${r.drift.filesNotIndexed} files not indexed, ${r.drift.rowsMissingFiles} rows missing files`,
746
+ );
747
+ return;
748
+ }
749
+
701
750
  // `config` prints the effective configuration (defaults + file). No DB needed.
702
751
  // `config set <key> <value>` persists a setting to the config file.
703
752
  if (cmd === "config") {
@@ -831,11 +880,22 @@ async function main() {
831
880
  : flags.scope === "project"
832
881
  ? [project.key]
833
882
  : [project.key, PERSONAL_SCOPE.key];
883
+ const explain = flags.explain === "true";
884
+ const stats = explain
885
+ ? { ftsQuery: "", candidates: 0, hiddenSuperseded: 0, hiddenExcluded: 0 }
886
+ : undefined;
834
887
  const hits = search(query, {
835
888
  scopeKeys: keys,
836
889
  limit: flags.limit ? Number(flags.limit) : undefined,
837
890
  type: flags.type,
891
+ stats,
838
892
  });
893
+ if (explain && stats) {
894
+ console.log(`fts: ${stats.ftsQuery || "(empty query)"}`);
895
+ console.log(
896
+ `candidates: ${stats.candidates}, hidden by lifecycle: ${stats.hiddenSuperseded} superseded, ${stats.hiddenExcluded} retracted/archived`,
897
+ );
898
+ }
839
899
  if (hits.length === 0) {
840
900
  console.log("(no matches)");
841
901
  return;
@@ -844,7 +904,8 @@ async function main() {
844
904
  const tag = h.scope_key === PERSONAL_SCOPE.key ? "personal" : "project";
845
905
  const dep = h.status === "deprecated" ? " [deprecated]" : "";
846
906
  const rev = hitStateLabel(h);
847
- console.log(`[${tag}/${h.type}]${dep}${rev} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
907
+ const sc = explain ? ` score=${h.score.toFixed(3)}` : "";
908
+ console.log(`[${tag}/${h.type}]${dep}${rev}${sc} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
848
909
  }
849
910
  return;
850
911
  }
@@ -1361,7 +1422,17 @@ function positionalArgs(argv: string[]): string[] {
1361
1422
 
1362
1423
  main()
1363
1424
  .catch((err) => {
1364
- console.error(err);
1425
+ // D57: lock contention past the busy timeout should read as a transient
1426
+ // condition, not a stack trace. The memory file is written before the
1427
+ // index update, so a locked-out write still lands on the next sync.
1428
+ const code = (err as { code?: string })?.code;
1429
+ if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED") {
1430
+ console.error(
1431
+ "open-memex: the memory store is busy (another open-memex process is writing). Wait a moment and retry — nothing was lost.",
1432
+ );
1433
+ } else {
1434
+ console.error(err);
1435
+ }
1365
1436
  process.exit(1);
1366
1437
  })
1367
1438
  .finally(() => {
package/src/doctor.ts CHANGED
@@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url";
10
10
  import { loadConfig, configSource, DEFAULT_CONFIG } from "./config.ts";
11
11
  import { paths } from "./paths.ts";
12
12
  import { resolveCwdScope } from "./scope.ts";
13
+ import { db, backendName } from "./store/db.ts";
13
14
  import { LEGACY_PACKAGE_NAME, opencodeGlobalConfigPath, userMcpConfigPath } from "./init.ts";
14
15
 
15
16
  interface Check {
@@ -71,6 +72,27 @@ function storageCheck(): Check {
71
72
  }
72
73
  }
73
74
 
75
+ /**
76
+ * D57: several processes share one index (CLI, MCP per editor, opencode
77
+ * plugin). WAL + a 5s busy timeout is what keeps an overlapping write a
78
+ * brief wait instead of an instant failure — report the live settings.
79
+ */
80
+ function lockingCheck(): Check {
81
+ try {
82
+ const d = db();
83
+ const jm = (d.prepare("PRAGMA journal_mode").get() as { journal_mode: string }).journal_mode;
84
+ const bt = (d.prepare("PRAGMA busy_timeout").get() as { timeout: number }).timeout;
85
+ const ok = jm === "wal" && bt >= 1000;
86
+ return {
87
+ name: "locking",
88
+ ok,
89
+ detail: `journal=${jm}, busy_timeout=${bt}ms (${backendName()})`,
90
+ };
91
+ } catch (err) {
92
+ return { name: "locking", ok: false, detail: String(err) };
93
+ }
94
+ }
95
+
74
96
  /** Spawn the real MCP server, handshake, and verify the five tools list. */
75
97
  function mcpCheck(): Promise<Check> {
76
98
  const name = "mcp";
@@ -298,7 +320,7 @@ function vscodeMcpCheck(): Check {
298
320
 
299
321
  export async function runDoctor(): Promise<boolean> {
300
322
  console.log("open-memex doctor");
301
- const checks: Check[] = [nodeCheck(), configCheck(), scopeCheck(), storageCheck(), vscodeMcpCheck()];
323
+ const checks: Check[] = [nodeCheck(), configCheck(), scopeCheck(), storageCheck(), lockingCheck(), vscodeMcpCheck()];
302
324
  checks.push(await mcpCheck());
303
325
  checks.push(legacyCheck());
304
326
  let allOk = true;
@@ -63,6 +63,14 @@ export function hitStateLabel(h: Pick<SearchHit, "scope_key" | "review_state">):
63
63
  return ` [${h.review_state || "draft"}]`;
64
64
  }
65
65
 
66
+ /** Optional diagnostics out-param for `search --explain` (D57). */
67
+ export interface SearchStats {
68
+ ftsQuery: string;
69
+ candidates: number;
70
+ hiddenSuperseded: number;
71
+ hiddenExcluded: number;
72
+ }
73
+
66
74
  /**
67
75
  * Lifecycle-aware post-processing (§3.3):
68
76
  * - retracted / archived are excluded from retrieval (kept for audit);
@@ -71,7 +79,7 @@ export function hitStateLabel(h: Pick<SearchHit, "scope_key" | "review_state">):
71
79
  * drafts/rejected rank after unreviewed content;
72
80
  * - deprecated stays visible as a warning but ranks after active.
73
81
  */
74
- function resolveVisible(rows: RawRow[], limit: number): SearchHit[] {
82
+ function resolveVisible(rows: RawRow[], limit: number, stats?: SearchStats): SearchHit[] {
75
83
  const byId = new Map(rows.map((r) => [r.id, r]));
76
84
  const fullRow = (id: string): RawRow | undefined => {
77
85
  const cached = byId.get(id);
@@ -96,9 +104,13 @@ function resolveVisible(rows: RawRow[], limit: number): SearchHit[] {
96
104
  const deprecated: SearchHit[] = [];
97
105
 
98
106
  for (const r of rows) {
99
- if (r.status === "retracted" || r.status === "archived") continue;
107
+ if (r.status === "retracted" || r.status === "archived") {
108
+ if (stats) stats.hiddenExcluded++;
109
+ continue;
110
+ }
100
111
  let target = r;
101
112
  if (r.status === "superseded") {
113
+ if (stats) stats.hiddenSuperseded++;
102
114
  let cur = r;
103
115
  const chain = new Set([r.id]);
104
116
  while (cur.status === "superseded" && cur.superseded_by) {
@@ -127,9 +139,15 @@ function resolveVisible(rows: RawRow[], limit: number): SearchHit[] {
127
139
  */
128
140
  export function search(
129
141
  query: string,
130
- opts: { scopeKeys?: string[]; limit?: number; type?: string } = {},
142
+ opts: { scopeKeys?: string[]; limit?: number; type?: string; stats?: SearchStats } = {},
131
143
  ): SearchHit[] {
132
144
  const q = toFtsQuery(query);
145
+ if (opts.stats) {
146
+ opts.stats.ftsQuery = q;
147
+ opts.stats.candidates = 0;
148
+ opts.stats.hiddenSuperseded = 0;
149
+ opts.stats.hiddenExcluded = 0;
150
+ }
133
151
  if (!q) return [];
134
152
  const limit = Math.max(1, Math.min(opts.limit ?? 8, 50));
135
153
  // Over-fetch: lifecycle filtering (chain resolution, exclusions) happens
@@ -176,7 +194,8 @@ export function search(
176
194
 
177
195
  // FTS5 bm25: lower = better; invert for intuition.
178
196
  const raw: RawRow[] = rows.map((r) => ({ ...r, score: -r.score }));
179
- return resolveVisible(raw, limit);
197
+ if (opts.stats) opts.stats.candidates = raw.length;
198
+ return resolveVisible(raw, limit, opts.stats);
180
199
  }
181
200
 
182
201
  export function list(
@@ -0,0 +1,136 @@
1
+ import fs from "node:fs";
2
+ import { db } from "./db.ts";
3
+ import {
4
+ readMemoryFile,
5
+ iterInRepoMemoryFiles,
6
+ iterMemoryFiles,
7
+ } from "./markdown.ts";
8
+ import { similarity, NEAR_DUP_THRESHOLD } from "./lifecycle.ts";
9
+ import { projectRoot } from "../paths.ts";
10
+ import { loadConfig } from "../config.ts";
11
+
12
+ // Memory health audit (D57). Read-only: reports, never mutates.
13
+ // Checks, per open question "retrieval explainability & memory health":
14
+ // - near-duplicate pairs among active memories (same dedup yardstick
15
+ // as write-time: Jaccard >= 0.8) that were never superseded;
16
+ // - stale actives: untouched for STALE_DAYS, still in the retrieval /
17
+ // injection pool;
18
+ // - broken supersede chains: superseded_by pointing at a missing id;
19
+ // - personal files inside the in-repo memory dir (iron-rule breach:
20
+ // personal memory must never land in a shared repo);
21
+ // - index/file drift after sync (should be zero; reported if not).
22
+
23
+ export const STALE_DAYS = 90;
24
+
25
+ interface AuditRow {
26
+ id: string;
27
+ scope_key: string;
28
+ status: string;
29
+ updated_at: number;
30
+ superseded_by: string | null;
31
+ content: string;
32
+ file_path: string;
33
+ }
34
+
35
+ export interface DuplicatePair {
36
+ a: string;
37
+ b: string;
38
+ scopeKey: string;
39
+ score: number;
40
+ }
41
+
42
+ export interface AuditReport {
43
+ totalMemories: number;
44
+ duplicatePairs: DuplicatePair[];
45
+ stale: { id: string; scopeKey: string; ageDays: number }[];
46
+ brokenChains: { id: string; scopeKey: string; missingTarget: string }[];
47
+ personalInRepo: string[];
48
+ drift: { filesNotIndexed: number; rowsMissingFiles: number };
49
+ }
50
+
51
+ export function runAudit(scopeKeys: string[]): AuditReport {
52
+ const d = db();
53
+ const rows = d
54
+ .prepare(
55
+ `SELECT id, scope_key, status, updated_at, superseded_by, content, file_path
56
+ FROM memories`,
57
+ )
58
+ .all() as AuditRow[];
59
+ const inScope = rows.filter((r) => scopeKeys.includes(r.scope_key));
60
+ const ids = new Set(rows.map((r) => r.id));
61
+
62
+ // Near-duplicate pairs: active only, same scope. O(n^2) per scope with a
63
+ // length pre-filter; local stores are small enough for this to be fine.
64
+ const pairs: DuplicatePair[] = [];
65
+ const byScope = new Map<string, AuditRow[]>();
66
+ for (const r of inScope) {
67
+ if (r.status !== "active") continue;
68
+ const arr = byScope.get(r.scope_key) ?? [];
69
+ arr.push(r);
70
+ byScope.set(r.scope_key, arr);
71
+ }
72
+ for (const [scopeKey, arr] of byScope) {
73
+ for (let i = 0; i < arr.length; i++) {
74
+ for (let j = i + 1; j < arr.length; j++) {
75
+ const a = arr[i]!;
76
+ const b = arr[j]!;
77
+ const la = a.content.length;
78
+ const lb = b.content.length;
79
+ if (Math.min(la, lb) / Math.max(la, lb) < 0.5) continue;
80
+ const score = similarity(a.content, b.content);
81
+ if (score >= NEAR_DUP_THRESHOLD) {
82
+ pairs.push({ a: a.id, b: b.id, scopeKey, score });
83
+ }
84
+ }
85
+ }
86
+ }
87
+ pairs.sort((x, y) => y.score - x.score);
88
+
89
+ const now = Date.now();
90
+ const stale = inScope
91
+ .filter((r) => r.status === "active")
92
+ .map((r) => ({ id: r.id, scopeKey: r.scope_key, ageDays: Math.floor((now - r.updated_at) / 86_400_000) }))
93
+ .filter((s) => s.ageDays >= STALE_DAYS)
94
+ .sort((x, y) => y.ageDays - x.ageDays);
95
+
96
+ const brokenChains = inScope
97
+ .filter((r) => r.superseded_by && !ids.has(r.superseded_by))
98
+ .map((r) => ({ id: r.id, scopeKey: r.scope_key, missingTarget: r.superseded_by! }));
99
+
100
+ // Personal files must never sit in the in-repo dir. Read-only: this only
101
+ // parses frontmatter of files already in the repo tree.
102
+ const personalInRepo: string[] = [];
103
+ try {
104
+ const root = projectRoot();
105
+ const memoryDir = loadConfig().memoryDir;
106
+ for (const fp of iterInRepoMemoryFiles(root, memoryDir)) {
107
+ const mf = readMemoryFile(fp);
108
+ if (mf && mf.fm.scope === "personal") personalInRepo.push(fp);
109
+ }
110
+ } catch {
111
+ // No project root (e.g. personal-only usage): nothing to scan.
112
+ }
113
+
114
+ // Drift: index rows whose file vanished, and files on disk the index
115
+ // never picked up. syncScope normally heals both before audit runs.
116
+ const indexedPaths = new Set(rows.map((r) => r.file_path));
117
+ let rowsMissingFiles = 0;
118
+ for (const r of inScope) {
119
+ if (!r.file_path || !fs.existsSync(r.file_path)) rowsMissingFiles++;
120
+ }
121
+ let filesNotIndexed = 0;
122
+ for (const key of scopeKeys) {
123
+ for (const fp of iterMemoryFiles(key)) {
124
+ if (!indexedPaths.has(fp)) filesNotIndexed++;
125
+ }
126
+ }
127
+
128
+ return {
129
+ totalMemories: inScope.length,
130
+ duplicatePairs: pairs,
131
+ stale,
132
+ brokenChains,
133
+ personalInRepo,
134
+ drift: { filesNotIndexed, rowsMissingFiles },
135
+ };
136
+ }
package/src/store/db.ts CHANGED
@@ -124,6 +124,11 @@ export function db(): AnyDatabase {
124
124
  const Database = loadDatabase();
125
125
  const d = new Database(indexDb);
126
126
  d.exec("PRAGMA journal_mode = WAL;");
127
+ // Multiple processes share this index (CLI, one MCP server per editor,
128
+ // the opencode plugin). Without a busy timeout, bun:sqlite defaults to
129
+ // 0 — any overlapping write fails instantly. 5s matches the tolerance
130
+ // better-sqlite3 was already giving the CLI/MCP side (D57).
131
+ d.exec("PRAGMA busy_timeout = 5000;");
127
132
  d.exec("PRAGMA synchronous = NORMAL;");
128
133
  d.exec("PRAGMA foreign_keys = ON;");
129
134
  d.exec(TABLE_SCHEMA);
@@ -294,7 +294,12 @@ export function writeMemoryFile(
294
294
  // Personal stays in appdata and never leaves the machine.
295
295
  const dir = memoriesDirFor(fm.scope_key);
296
296
  const filePath = path.join(dir, `${fm.id}.md`);
297
- fs.writeFileSync(filePath, serialize(fm, body), "utf8");
297
+ // Atomic publish (D57): write a sibling tmp file and rename over the
298
+ // target, so a concurrent reader (another process mid-sync) never sees a
299
+ // torn half-written memory. Same-directory rename is atomic on one fs.
300
+ const tmpPath = `${filePath}.tmp-${process.pid}`;
301
+ fs.writeFileSync(tmpPath, serialize(fm, body), "utf8");
302
+ fs.renameSync(tmpPath, filePath);
298
303
  const st = fs.statSync(filePath);
299
304
  return { filePath, mtimeMs: st.mtimeMs };
300
305
  }
@@ -1,4 +1,5 @@
1
- import { tool } from "@opencode-ai/plugin/tool";
1
+ import type { z } from "zod";
2
+ import type { ToolContext, ToolResult } from "@opencode-ai/plugin";
2
3
  import type { Scope } from "../scope.ts";
3
4
  import type { MyOMemoryConfig } from "../config.ts";
4
5
  import {
@@ -16,6 +17,20 @@ import {
16
17
  withOutboxNote,
17
18
  } from "./ops.ts";
18
19
 
20
+ // Local stand-in for the SDK's tool() helper — same signature, and the SDK
21
+ // function is a runtime identity (`return input`). Do NOT import it from
22
+ // @opencode-ai/plugin at runtime: that package is a devDependency, and when
23
+ // opencode loads this plugin by file:// URL from a global npm install
24
+ // nothing resolves the module — the import fails and opencode silently
25
+ // skips the whole plugin: no error, no tools (D58).
26
+ function tool<Args extends z.ZodRawShape>(input: {
27
+ description: string;
28
+ args: Args;
29
+ execute(args: z.infer<z.ZodObject<Args>>, context: ToolContext): Promise<ToolResult>;
30
+ }): { description: string; args: Args; execute: typeof input.execute } {
31
+ return input;
32
+ }
33
+
19
34
  export function makeTools(getScope: () => Scope, cfg: MyOMemoryConfig) {
20
35
  const memory_add = tool({
21
36
  description: TOOL_DESCRIPTIONS.memory_add,