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 +3 -0
- package/README.md +3 -0
- package/README.zh-CN.md +3 -0
- package/dist/cli.js +71 -7
- package/dist/doctor.js +23 -1
- package/dist/retrieve/search.js +16 -3
- package/dist/store/audit.js +101 -0
- package/dist/store/db.js +5 -0
- package/dist/store/markdown.js +6 -1
- package/dist/tools/memory.js +9 -1
- package/docs/V2-DESIGN.md +45 -0
- package/package.json +1 -1
- package/src/cli.ts +78 -7
- package/src/doctor.ts +23 -1
- package/src/retrieve/search.ts +23 -4
- package/src/store/audit.ts +136 -0
- package/src/store/db.ts +5 -0
- package/src/store/markdown.ts +6 -1
- package/src/tools/memory.ts +16 -1
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
|
|
48
|
-
--type
|
|
49
|
-
--limit
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/retrieve/search.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/store/markdown.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/tools/memory.js
CHANGED
|
@@ -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
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
|
|
57
|
-
--type
|
|
58
|
-
--limit
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/retrieve/search.ts
CHANGED
|
@@ -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")
|
|
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
|
-
|
|
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);
|
package/src/store/markdown.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/tools/memory.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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,
|