open-memex 0.7.3 → 0.8.0-alpha.2

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
@@ -202,7 +202,8 @@ breaking v1→v2 transition) shipped with 0.3.0. Full rules: `CONTRIBUTING.md`.
202
202
  languages in sync); behavior changes → both READMEs
203
203
  and the frozen `docs/V2-DESIGN.md` (append a `D<n>` decision entry, never rewrite history);
204
204
  new commands → README CLI sections + this file's Commands. A change without its docs
205
- is not done.
205
+ is not done. Anything deferred as "later" goes into the near-term backlog in
206
+ `docs/V2-DESIGN.md` §18 the same day — unwritten later means never.
206
207
  - **Version bumps ship with features.** `package.json` + `package-lock.json` carry the
207
208
  in-development version. New features on a dev branch bump the minor on the alpha
208
209
  line (`0.3.0` → `0.4.0-alpha.1`); fixes bump the patch (`-alpha.1` → `-alpha.2`).
package/CHANGELOG.md CHANGED
@@ -9,6 +9,18 @@ the `@alpha` tag are development snapshots and are not listed individually.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ - Index-time synonym/translation expansion (D80, supersedes D79): the curated map (`src/retrieve/synonyms.ts` — EN dev-domain groups, ZH groups and EN↔ZH translation pairs in one bidirectional list) is now applied at sync/index time — `writeRow` expands each memory's keywords (`expansionVariantsForDoc()`, capped at 32) into the indexed `aliases` column, so any related word matches in a plain round-1 query. The query-time second-chance round is removed (`search()` is a single FTS round; `SearchStats.secondRound` gone); `expandQueryWithSynonyms` stays as a bench-only export. Fixture (51 memories / 41 queries): recall@1 0.732 → 0.756, recall@5 0.854 → **0.976**, MRR 0.791 → **0.859** — all 5 EN↔ZH vocabulary misses rescued at rank 1–3. Map updates need a reindex to backfill.
13
+
14
+ - Semantic ranking experiment (§4.7 of `docs/retrieval-ablation-study.md`): local multilingual embeddings (`Xenova/paraphrase-multilingual-MiniLM-L12-v2`, 384-dim, no API) via `scripts/bench-semantic.ts` (new `--model` flag). Fixture: vec-only R@5 92.7% / MRR 0.881 beats BM25-only (80.5% / 0.752); naive RRF hybrid underperforms vec-only (87.8%) — fusion needs design. Pooled 22k long sessions: vec-only collapses (3.4%) from mean-pooling dilution on 10k-char docs — a measured methodology artifact, not a verdict for short memories; chunked rerun deliberately skipped.
15
+
16
+ - LongMemEval-S retrieval bench (`scripts/bench-longmemeval.ts`): 470 questions, recall_any@k + MRR on public data, per-question isolated corpora plus a pooled 22k-memory stress mode, with synonym on/off ablation. Result: R@5 97.0% / MRR 0.909 (ahead of agentmemory’s published BM25-only, level with their BM25+vector). Finding: the count-based second-round trigger fired 0/470 — logged as a D79 redesign follow-up, not a scale answer.
17
+
18
+ - Query-time synonym expansion (D79, retrieval layer 4): `search()` runs a second-chance round when the first FTS pass is thin, re-running with curated synonym variants OR-ed in (`src/retrieve/synonyms.ts`, EN dev-domain + ZH groups, no model). Round-two rows only fill gaps; round-one ranking is never diluted. `SearchStats.secondRound` records it (shown in `search --explain`). Baseline with the round: recall@1 0.81, recall@5 0.95, MRR 0.87 over 37 fixture queries. **Superseded by D80 (2026-10-07)** — see above.
19
+
20
+ - Retrieval eval harness (D77 layer 1): `scripts/retrieval-eval.ts` runs a checked-in synthetic fixture (48 memories + 34 queries: exact terms, paraphrases, synonyms, Chinese, cross-language hard cases) against the FTS5 baseline and reports recall@k/MRR. Current baseline: recall@1 0.85, recall@5 0.97, MRR 0.90 (single miss: English query vs Chinese-only memory). Published in both READMEs with methodology notes.
21
+
22
+ - Added `test-full` npm script (`node --experimental-strip-types scripts/test-full.ts`).
23
+
12
24
  ## [0.7.3] - 2026-10-07
13
25
 
14
26
  - Tool-definition quality pass (Glama TDQS 4.0/5 on v0.7.2): fixed `memory_pr_status` declaring `readOnlyHint` while `apply=true` mutates local files; added explicit MCP annotations (read-only / destructive / open-world) to all 11 tools; descriptions now route between sibling tools (propose vs submit vs promote, resolve vs pr_status, forget vs supersede), state error behavior and real defaults (search limit 8, list limit 20), and the search/list `type` and `limit` parameters are documented. D76 records the rule; `scripts/smoke-mcp.ts` guards the pr_status annotation.
package/README.md CHANGED
@@ -22,6 +22,10 @@ memory on your machine.
22
22
  explicitly share it.
23
23
  - **One memory, every agent**: wire up several editors with one command; they
24
24
  share the same memory instead of keeping separate silos.
25
+ - **Precise synonym search**: `ship` finds `deploy`, `单点登录` finds `SSO` —
26
+ a curated multilingual map of equivalents is indexed with every memory at
27
+ write time, so no related wording misses the right answer. Deterministic
28
+ and auditable, no embedding model required.
25
29
 
26
30
  ![Terminal demo: two memories saved on Monday, recalled by search in a fresh session on Friday](./docs/assets/open-memex-demo.svg)
27
31
 
@@ -516,6 +520,37 @@ time for the rest. Both top-N counts are configurable (see [Config](#config)).
516
520
  For MCP clients this block is delivered as handshake guidance the agent follows;
517
521
  the opencode plugin injects it directly on the first turn.
518
522
 
523
+ ### Retrieval evaluation
524
+
525
+ We measure recall on a checked-in synthetic fixture instead of asserting it:
526
+ 51 memories + 41 queries (exact terms, paraphrases, synonyms, Chinese,
527
+ cross-language hard cases, and synonym-only queries) — reproduce with
528
+ `node --experimental-strip-types scripts/retrieval-eval.ts`.
529
+ Current baseline, with index-time synonym/translation expansion (D80):
530
+ **recall@1 0.76, recall@5 0.98, MRR 0.86**.
531
+ Small synthetic corpus, so read it as a regression guard and a starting point,
532
+ not a real-world claim.
533
+
534
+ Three layers, each covering what the others miss: **BM25 keyword search**
535
+ (SQLite FTS5 — exact words, fast, explainable via `search --explain`);
536
+ **index-time synonym/translation expansion** (D80 — a curated, checked-in map
537
+ of dev-domain equivalents and EN↔ZH translation pairs expanded into the index
538
+ at write time, so `ship` finds `deploy` and `单点登录` finds `SSO` in a plain
539
+ single-round query; deterministic, no model download); and **semantic vectors**
540
+ as an opt-in experiment (local multilingual embeddings measured at R@5 92.7%
541
+ on the cross-lingual fixture slice — see `docs/retrieval-ablation-study.md`
542
+ §4.7; no embedding model is ever downloaded without your explicit opt-in).
543
+
544
+ External check on public data: the same pipeline scores **recall@5 97.0% /
545
+ MRR 0.909** on LongMemEval-S (470 questions, retrieval stage only — no answer
546
+ generation, no judge model; this is not the official LongMemEval score). For
547
+ reference, agentmemory's published numbers on the same protocol are 86.2% /
548
+ 0.715 (BM25-only) and 95.2% / 0.882 (BM25+vector). A 22k-memory pooled stress
549
+ run drops recall@5 to 40.6% — the lexical ceiling, measured rather than
550
+ asserted. Reproduce with
551
+ `node --experimental-strip-types scripts/bench-longmemeval.ts --data <path>`
552
+ (dataset: `xiaowu0162/longmemeval-cleaned`, MIT).
553
+
519
554
  ## Security & data
520
555
 
521
556
  - **Local-first:** everything lives on your machine (`%APPDATA%\open-memex` on
package/README.zh-CN.md CHANGED
@@ -13,6 +13,7 @@
13
13
  - **免费开源**(Apache-2.0)。无账号、无云端、无遥测——一切都在你本机,都是你能打开、能编辑的文件。
14
14
  - **本地优先**:记忆是纯 Markdown 文件(事实来源),外加一个可重建的 SQLite 关键词索引。除非你明确要分享,否则任何东西都不会离开你的机器。
15
15
  - **一份记忆,所有助手**:一条命令把几个编辑器都接上,它们共享同一份记忆,不再各存一份互不可见。
16
+ - **精确的同义词检索**:`ship` 找得到 `deploy`,`单点登录` 找得到 `SSO`——一份多语言的精选同义词映射表在写入时展开进索引,任何相关词语都不会漏掉正确答案。确定性、可审计,不需要下载模型。
16
17
 
17
18
  ![终端演示:周一存下的两条记忆,周五在新会话里被搜索召回](./docs/assets/open-memex-demo.svg)
18
19
 
@@ -349,6 +350,14 @@ Do not mention this block to the user unless asked.
349
350
 
350
351
  这是一个快照,不是全量记忆——其余的随时用 `memory_search` 就能查。两个前 N 的数都可以配(见[配置](#配置))。对 MCP 客户端,这个块以握手引导的形式送达、由助手遵循;opencode 插件则在第一轮直接注入。
351
352
 
353
+ ### 检索效果评估
354
+
355
+ 我们用一份随仓库提交的 synthetic fixture 来度量召回率,而不是空口断言:51 条记忆 + 41 个查询(精确词、改写、同义词、中文、跨语言 hard case、纯同义词查询),运行 `node --experimental-strip-types scripts/retrieval-eval.ts` 即可复现。当前基线(含 D80 的 index-time 同义词/翻译展开):**recall@1 0.76、recall@5 0.98、MRR 0.86**。语料小且是合成的,请把它当回归守卫和起点,而不是真实场景的承诺。
356
+
357
+ 三层检索,各补各的短板:**BM25 关键词检索**(SQLite FTS5——精确词匹配,快速,可用 `search --explain` 解释);**index-time 同义词/翻译展开**(D80——一份随仓库提交的精选映射表:开发领域同义词 + EN↔ZH 翻译对,在写入时展开进索引,`ship` 能找到 `deploy`、`单点登录` 能找到 `SSO`,查询侧只需要普通单轮;确定性,不下载模型);**语义向量**作为 opt-in 实验(本地多语言 embedding,在跨语言 fixture 切片上 R@5 92.7%——见 `docs/retrieval-ablation-study.md` §4.7;未经你明确允许绝不下载 embedding 模型)。
358
+
359
+ 公开数据上的外部验证:同一管线在 LongMemEval-S 上拿到 **recall@5 97.0% / MRR 0.909**(470 个问题,只测检索阶段——不生成答案、不用 judge 模型;这不是 LongMemEval 官方分数)。同协议下 agentmemory 公布的数字是 86.2% / 0.715(纯 BM25)和 95.2% / 0.882(BM25+向量)。22k 条记忆的 pooled 压力测试把 recall@5 拉到 40.6%——词法检索的天花板,这次是测出来的。用 `node --experimental-strip-types scripts/bench-longmemeval.ts --data <路径>` 复现(数据集 `xiaowu0162/longmemeval-cleaned`,MIT 协议)。
360
+
352
361
  ## 安全与数据
353
362
 
354
363
  - **本地优先:** 一切住在你机器上(Windows 是 `%APPDATA%\open-memex`,macOS/Linux 是 `~/.local/share/open-memex`),外加你自己选择的仓库。零云调用、零账号、零第三方 API、零遥测。
package/dist/.build-stamp CHANGED
@@ -1 +1 @@
1
- c5a82fa9f711dea3b1e8787d551424e978d606435b08f83d51ba5aaf0996e9ff
1
+ 9f341cad8c580b840308fbdb525de70a108bc40aa372f47c4b248baa67ab14f8
@@ -12,7 +12,7 @@
12
12
  // than the raw one.
13
13
  import { cjkQueryExpr, hasCjk } from "./cjk.js";
14
14
  /** English function words that carry no retrieval signal in questions. */
15
- const LATIN_STOPWORDS = new Set([
15
+ export const LATIN_STOPWORDS = new Set([
16
16
  "the", "a", "an", "and", "or", "of", "to", "in", "on", "for", "with",
17
17
  "without", "is", "are", "was", "were", "be", "been", "do", "does", "did",
18
18
  "how", "what", "why", "when", "where", "which", "who", "whom", "we", "i",
@@ -27,9 +27,11 @@ const CJK_QUESTION_BIGRAMS = new Set([
27
27
  "什么", "怎么", "如何", "为何", "为什", "哪些", "哪个", "请问",
28
28
  ]);
29
29
  const MAX_TERMS = 24;
30
- function latinTerms(q) {
30
+ function latinTerms(q, useStopwords = true) {
31
31
  const raw = q.toLowerCase().match(/[a-z0-9_.\-]+/g) ?? [];
32
- const kept = raw.filter((t) => t.length > 1 && !LATIN_STOPWORDS.has(t));
32
+ const kept = useStopwords
33
+ ? raw.filter((t) => t.length > 1 && !LATIN_STOPWORDS.has(t))
34
+ : raw.filter((t) => t.length > 1);
33
35
  const chosen = kept.length > 0 ? kept : raw;
34
36
  return [...new Set(chosen)].slice(0, MAX_TERMS);
35
37
  }
@@ -38,16 +40,25 @@ function latinTerms(q) {
38
40
  * Latin tokens keep the old behavior (prefix match on content/tags/type).
39
41
  * CJK runs become an OR of bigrams against the `cjk` column (see cjk.ts).
40
42
  * Mixed queries OR the two parts together.
43
+ *
44
+ * Flags are ablation toggles (all default to the shipped behavior):
45
+ * - stopwords: false → skip function-word filtering
46
+ * - orJoin: false → AND the terms instead of OR
47
+ * - prefix: false → exact term match instead of `term*`
41
48
  */
42
- export function toFtsQuery(q) {
43
- const latin = latinTerms(q)
44
- .map((t) => `"${t.replace(/"/g, '""')}"*`)
45
- .join(" OR ");
49
+ export function toFtsQuery(q, flags = {}) {
50
+ const useStopwords = flags.stopwords !== false;
51
+ const useOr = flags.orJoin !== false;
52
+ const usePrefix = flags.prefix !== false;
53
+ const joiner = useOr ? " OR " : " AND ";
54
+ const latin = latinTerms(q, useStopwords)
55
+ .map((t) => (usePrefix ? `"${t.replace(/"/g, '""')}"*` : `"${t.replace(/"/g, '""')}"`))
56
+ .join(joiner);
46
57
  const cjk = hasCjk(q)
47
- ? cjkQueryExpr(q, { dropBigrams: CJK_QUESTION_BIGRAMS, maxTerms: MAX_TERMS })
58
+ ? cjkQueryExpr(q, { dropBigrams: useStopwords ? CJK_QUESTION_BIGRAMS : undefined, maxTerms: MAX_TERMS })
48
59
  : "";
49
60
  if (latin && cjk)
50
- return `(${latin}) OR {cjk}:(${cjk})`;
61
+ return `(${latin}) ${useOr ? "OR" : "AND"} {cjk}:(${cjk})`;
51
62
  if (cjk)
52
63
  return `{cjk}:(${cjk})`;
53
64
  return latin;
@@ -117,7 +117,8 @@ function resolveVisible(rows, limit, stats) {
117
117
  * function/question words filtered so they can't dilute the ranking.
118
118
  */
119
119
  export function search(query, opts = {}) {
120
- const q = toFtsQuery(query);
120
+ const qflags = { stopwords: opts.stopwords, orJoin: opts.orJoin, prefix: opts.prefix };
121
+ const q = toFtsQuery(query, qflags);
121
122
  if (opts.stats) {
122
123
  opts.stats.ftsQuery = q;
123
124
  opts.stats.candidates = 0;
@@ -145,17 +146,23 @@ export function search(query, opts = {}) {
145
146
  ORDER BY score ASC
146
147
  LIMIT ?
147
148
  `;
148
- const params = [q];
149
- if (opts.scopeKeys && opts.scopeKeys.length > 0)
150
- params.push(...opts.scopeKeys);
151
- if (opts.type)
152
- params.push(opts.type);
153
- params.push(fetchLimit);
154
- const rows = db()
155
- .prepare(sql)
156
- .all(...params);
157
- // FTS5 bm25: lower = better; invert for intuition.
158
- const raw = rows.map((r) => ({ ...r, score: -r.score }));
149
+ const fetchFts = (matchExpr) => {
150
+ const params = [matchExpr];
151
+ if (opts.scopeKeys && opts.scopeKeys.length > 0)
152
+ params.push(...opts.scopeKeys);
153
+ if (opts.type)
154
+ params.push(opts.type);
155
+ params.push(fetchLimit);
156
+ const rows = db()
157
+ .prepare(sql)
158
+ .all(...params);
159
+ // FTS5 bm25: lower = better; invert for intuition.
160
+ return rows.map((r) => ({ ...r, score: -r.score }));
161
+ };
162
+ const raw = fetchFts(q);
163
+ // D80: vocabulary mismatch is solved at index time (synonyms.ts expands
164
+ // curated variants into the aliases column at sync time), so the query
165
+ // side is a single FTS round — no trigger, no second chance.
159
166
  if (opts.stats)
160
167
  opts.stats.candidates = raw.length;
161
168
  return resolveVisible(raw, limit, opts.stats);
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Index-time synonym/translation expansion (D80).
3
+ *
4
+ * Stone's rule: solve vocabulary mismatch at write time, so any related
5
+ * word finds the answer with a plain round-1 query. At sync/index time
6
+ * every memory's keywords are expanded through a curated, checked-in
7
+ * equivalence map and the variants are written into the indexed `aliases`
8
+ * column (FTS + CJK bigram columns both pick them up via sync.ts). The
9
+ * query side stays simple: one FTS round, no trigger, no second chance.
10
+ *
11
+ * Deliberately NOT an embedding model or an LLM call: deterministic,
12
+ * auditable, zero dependencies, and the failure mode (a bad synonym)
13
+ * is a visible line in this file, not a silent vector.
14
+ *
15
+ * Groups are bidirectional — any member expands to the other members.
16
+ * Keep groups tight: an over-broad synonym costs precision on every
17
+ * query, and at index time a bad entry pollutes every document instead
18
+ * of one query. When in doubt, leave it out. A map update only reaches
19
+ * already-indexed memories after a reindex.
20
+ *
21
+ * Design note (D80): the map covers the *precise* slice — EN↔ZH
22
+ * translation pairs and dev-domain equivalents where embeddings are
23
+ * fuzzy (ship/deploy are neighbors in vector space but "单点登录"/SSO
24
+ * is an exact pair no embedding guarantees). Embeddings cover the
25
+ * semantic long tail; this map covers what must never be missed.
26
+ */
27
+ import { LATIN_STOPWORDS } from "./query.js";
28
+ const CJK_RE = /[\u4e00-\u9fff]/;
29
+ /**
30
+ * Curated equivalence groups. EN dev-domain vocabulary, ZH equivalents,
31
+ * and EN↔ZH translation pairs live in one bidirectional group: any member
32
+ * expands to all the others, in either direction.
33
+ */
34
+ const GROUPS = [
35
+ ["deploy", "ship", "release", "rollout", "publish", "部署", "发布", "上线"],
36
+ ["test", "testing", "tests", "测试", "单测", "集成测试"],
37
+ ["regression test", "回归测试"],
38
+ ["rollback", "revert", "回滚", "撤回", "撤销", "回退"],
39
+ ["incident", "outage", "postmortem", "故障", "事故", "宕机"],
40
+ ["auth", "authentication", "login", "sso", "single sign-on", "单点登录", "登录"],
41
+ ["secret", "secrets", "credential", "credentials", "token", "密钥"],
42
+ ["database", "db", "数据库"],
43
+ ["backup", "restore", "备份", "恢复"],
44
+ ["retention", "保留"],
45
+ ["production", "生产", "生产环境"],
46
+ ["staging", "预发布环境", "预发布"],
47
+ ["wipe", "wipes", "清空", "清除"],
48
+ ["nightly", "night", "每晚"],
49
+ ["thursday", "周四", "星期四"],
50
+ ["mandatory", "强制", "必须"],
51
+ ["flaky", "flake", "unreliable", "intermittent"],
52
+ ["bug", "defect", "issue"],
53
+ ["ci", "pipeline"],
54
+ ["cache", "caching", "cached"],
55
+ ["invalidate", "invalidation", "expire", "expiry"],
56
+ ["oncall", "on-call", "pager"],
57
+ ["migration", "migrate", "migrating"],
58
+ ["index", "indexing", "indexed"],
59
+ ["slow", "latency", "performance", "perf"],
60
+ ["refactor", "refactoring"],
61
+ ["merge", "merging"],
62
+ ["branch", "branches"],
63
+ ["commit", "commits"],
64
+ ["review", "code review", "评审", "代码评审"],
65
+ ["mock", "stub"],
66
+ ["debug", "debugging", "troubleshoot", "troubleshooting"],
67
+ ["log", "logs", "logging"],
68
+ ["monitor", "monitoring", "alert", "alerting"],
69
+ ["scale", "scaling"],
70
+ ["timeout", "timeouts"],
71
+ ["retry", "retries"],
72
+ ["queue", "job", "worker"],
73
+ ["api", "endpoint"],
74
+ ["docker", "container", "image"],
75
+ ["kubernetes", "k8s"],
76
+ ["env", "environment"],
77
+ ["config", "configuration", "setting", "settings"],
78
+ ["hotfix", "hot fix"],
79
+ ["estimate", "estimation"],
80
+ ["乱码", "编码问题"],
81
+ ["并发", "并行"],
82
+ ["重构"],
83
+ ["规范", "约定"],
84
+ ];
85
+ const termLookup = new Map();
86
+ const phraseEntries = [];
87
+ const cjkEntries = [];
88
+ for (const g of GROUPS) {
89
+ const cjkKeys = g.filter((m) => CJK_RE.test(m));
90
+ if (cjkKeys.length > 0)
91
+ cjkEntries.push({ keys: cjkKeys, siblings: [...g] });
92
+ for (const m of g) {
93
+ if (CJK_RE.test(m))
94
+ continue;
95
+ const siblings = g.filter((x) => x !== m);
96
+ if (m.includes(" "))
97
+ phraseEntries.push({ phrase: m.toLowerCase(), siblings });
98
+ else
99
+ termLookup.set(m.toLowerCase(), siblings);
100
+ }
101
+ }
102
+ /**
103
+ * Synonym variants for one lowercased Latin term (excluding the term
104
+ * itself). Empty array when the term has no curated synonyms.
105
+ */
106
+ export function synonymVariants(term) {
107
+ return termLookup.get(term.toLowerCase()) ?? [];
108
+ }
109
+ /** Cap on auto-generated variants per memory — bounds index growth. */
110
+ export const MAX_AUTO_VARIANTS = 32;
111
+ /**
112
+ * Index-time expansion for one memory's text (body + tags). Keyword-
113
+ * centric (Stone): extract content terms, expand each through the
114
+ * curated map, return variants not already present in the text.
115
+ * Pure logic — safe to unit test, runs once per memory at sync time.
116
+ */
117
+ export function expansionVariantsForDoc(text) {
118
+ const lower = text.toLowerCase();
119
+ // Content terms only: function words carry no signal (same filter the
120
+ // query builder applies, so both sides agree on what a keyword is).
121
+ const latinTokens = new Set((lower.match(/[a-z0-9_.\-]+/g) ?? []).filter((t) => t.length > 1 && !LATIN_STOPWORDS.has(t)));
122
+ const out = [];
123
+ const seen = new Set();
124
+ const push = (v) => {
125
+ const key = v.toLowerCase();
126
+ if (seen.has(key) || out.length >= MAX_AUTO_VARIANTS)
127
+ return;
128
+ seen.add(key);
129
+ out.push(v);
130
+ };
131
+ // A variant is "already present" when the doc has it as a token (Latin),
132
+ // as a substring (Latin phrase), or as a substring (CJK — no tokenizer).
133
+ const present = (v) => CJK_RE.test(v)
134
+ ? text.includes(v)
135
+ : v.includes(" ")
136
+ ? lower.includes(v.toLowerCase())
137
+ : latinTokens.has(v.toLowerCase());
138
+ for (const t of latinTokens) {
139
+ for (const v of termLookup.get(t) ?? [])
140
+ if (!present(v))
141
+ push(v);
142
+ }
143
+ for (const { phrase, siblings } of phraseEntries) {
144
+ if (!lower.includes(phrase))
145
+ continue;
146
+ for (const v of siblings)
147
+ if (!present(v))
148
+ push(v);
149
+ }
150
+ for (const { keys, siblings } of cjkEntries) {
151
+ if (!keys.some((k) => text.includes(k)))
152
+ continue;
153
+ for (const v of siblings)
154
+ if (!present(v))
155
+ push(v);
156
+ }
157
+ return out;
158
+ }
159
+ /**
160
+ * Expand a raw query string with synonym variants (legacy query-time
161
+ * helper, kept for bench scripts). The product no longer expands at
162
+ * query time — D80 moved expansion to the index.
163
+ */
164
+ export function expandQueryWithSynonyms(q) {
165
+ const extra = new Set();
166
+ const latinTerms = q.toLowerCase().match(/[a-z0-9_.\-]+/g) ?? [];
167
+ for (const t of latinTerms) {
168
+ for (const s of synonymVariants(t))
169
+ extra.add(s);
170
+ }
171
+ for (const { keys, siblings } of cjkEntries) {
172
+ if (keys.some((k) => q.includes(k))) {
173
+ for (const s of siblings)
174
+ extra.add(s);
175
+ }
176
+ }
177
+ // Don't re-add terms already in the query.
178
+ const have = new Set(latinTerms);
179
+ const additions = [...extra].filter((s) => !have.has(s.toLowerCase()) && !q.includes(s));
180
+ if (additions.length === 0)
181
+ return q;
182
+ return `${q} ${additions.join(" ")}`;
183
+ }
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { db } from "./db.js";
4
4
  import { cjkIndexText } from "../retrieve/cjk.js";
5
+ import { expansionVariantsForDoc } from "../retrieve/synonyms.js";
5
6
  import { contentHash, repairChain } from "./lifecycle.js";
6
7
  import { iterMemoryFiles, iterInRepoMemoryFiles, readMemoryFile, timeToMs, } from "./markdown.js";
7
8
  import { projectRoot, paths } from "../paths.js";
@@ -54,7 +55,14 @@ export function upsertFromFile(mf) {
54
55
  function writeRow(mf, mtimeMs) {
55
56
  const { fm, body, filePath } = mf;
56
57
  const tags = (fm.tags ?? []).join(",");
57
- const aliases = (fm.aliases ?? []).join(",");
58
+ // D80 index-time expansion: curated synonym/translation variants for the
59
+ // memory's keywords are indexed in the aliases column (and therefore the
60
+ // CJK bigram column below), so any related word matches in round one.
61
+ // Derived at sync time — the Markdown file stays the source of truth.
62
+ const supplied = fm.aliases ?? [];
63
+ const have = new Set(supplied.map((a) => a.toLowerCase()));
64
+ const auto = expansionVariantsForDoc(body + "\n" + tags).filter((a) => !have.has(a.toLowerCase()));
65
+ const aliases = [...supplied, ...auto].join(",");
58
66
  db().prepare(UPSERT_SQL).run(fm.id, fm.scope_key, fm.scope, fm.visibility, fm.project_name, fm.type, fm.role, fm.importance, fm.status, tags, aliases, body, cjkIndexText(body + "\n" + tags + "\n" + aliases), contentHash(body), fm.superseded_by ?? null, fm.source ?? "", filePath, mtimeMs, timeToMs(fm.created_at), timeToMs(fm.updated_at), fm.review_state ?? "draft");
59
67
  }
60
68
  export function deleteFromIndex(id) {
package/docs/V2-DESIGN.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # OpenMemex — Design Document (protocol v0.2)
2
2
 
3
- **Status:** FROZEN — protocol v0.2 (2026-09-26). Decisions D1–D78 are settled; open questions are tracked at the end of this document.
3
+ **Status:** FROZEN — protocol v0.2 (2026-09-26). Decisions D1–D79 are settled; open questions are tracked at the end of this document.
4
4
  **Author:** Stone, with 小沐
5
5
  **Changelog vs v1:** incorporates round-3 review from Perplexity, Grok, Gemini, ChatGPT, DeepSeek.
6
6
  Key changes: Design Principles section; `role` separated from `type`; two iron rules;
@@ -562,6 +562,8 @@ requirement: personal data never touches third-party services). Benchmarks to tr
562
562
  - **Phase 4 — Future, signal-gated.** Cloud `RemoteProvider` customization only on: multi-private-repo
563
563
  sharing needs, fine-grained ACL, audit/compliance mandates · optional API-backed exporters/providers
564
564
  for enterprise knowledge systems.
565
+ - **Near-term backlog — committed direction, not yet built (2026-10-07).** Stone's rule: anything said as "later" is written down here. D77-L2: per-memory `retrieved_count`/`useful_count` (`useful_self` vs `useful_team`) + `memory_useful` tool. D77-L3: `memory_status` reuse report (top reused, zero-reuse, per-scope reuse rate) feeding the Spark Tank pilot metrics. D77: external bench — LongMemEval-S run 2026-10-07 (`scripts/bench-longmemeval.ts`, 470 questions, recall_any@k: R@5 97.0% / MRR 0.909 per-question, R@5 40.6% on a pooled 22k corpus; ahead of agentmemory’s BM25-only, level with their BM25+vector). MemoryAgentBench still open as a second external data point. D79 follow-up SUPERSEDED → built as **D80** (2026-10-07): index-time synonym/translation expansion shipped; 41-query fixture recall@5 0.854 → 0.976, MRR 0.791 → 0.859. Embedding-model configurability (Stone 2026-10-07): the multilingual model is his personal need (EN↔ZH), not everyone’s — the model must be user-configurable. Timing: (1) NOW in the bench only — a `--model` flag to measure the multilingual tax (multilingual vs English-only on English data), which decides the DEFAULT. (2) AT SHIP TIME — `embedding.model` in `MyOMemoryConfig` + `SETTABLE_KEYS`, model-agnostic storage from day one (dim + model id stored with vectors, cache dir), download UX and `doctor` checks. Not before the gate passes: building config UX for a layer we haven’t decided to ship is premature. If multilingual tax ≈ 0, default multilingual (covers everyone); else default English-only with a docs note.D78: curate+submit — pre-submit lint, then confidence-tier auto-curation (auto-apply / batch-confirm / explicit human). D79 follow-ups: feedback-weighted ranking once D77 counters land; embeddings gate decision against the published baseline; reranker evaluation. TDQS nit (separate PR): tighten search/list `type` to `z.enum`. Retrieval eval: grow the fixture (project-scope tier cases) as retrieval logic evolves. Retrieval split — local vs share server (Stone 2026-10-07): the two environments emphasize different mechanisms. Local (personal machine): BM25 + curated index-time expansion as the primary — deterministic, zero model download (D7), fast on CPU, index bloat bounded by the small corpus; embeddings opt-in only. Share server (team/org, enterprise): embeddings + ANN + reranker as the primary — server-grade compute with models resident, corpus too large and multi-author for any curated map; the curated map stays as a precision layer plus org glossary. The server additionally aggregates D77 useful_count across users into feedback-weighted ranking (already in D79 follow-ups) — a signal a single machine can never have. Bottom-up loop: curated pairs that many users converge on locally become org-glossary candidates. Economics flip: N users each downloading a model vs one model amortized on the server.
566
+
565
567
  - **Phase 5 — Candidates recorded 2026-10-04 (not committed).** From the competitor scan (D75): an offline FTS5 retrieval eval with a published recall number (gates any semantic-layer decision) [promoted to next build, D77] · per-client trust levels on the review ladder · cold-start Markdown import into outbox drafts · `doctor` check that each editor's MCP registration points at the intended data root (mis-rooted servers are a real failure mode elsewhere). Sequencing and scope TBD per candidate.
566
568
 
567
569
  ## 19. Migration v1 → v2
@@ -1598,6 +1600,9 @@ requirement: personal data never touches third-party services). Benchmarks to tr
1598
1600
 
1599
1601
  - **D78 — pre-submit curation: human-on-the-loop, not in-the-loop (2026-10-07).** Fragmented captures must be organized before submit, but the submitter's patience is the scarce resource — every manual gate lowers the submit rate, so curation defaults to automatic and the human handles exceptions. Confidence tiers: **auto-apply** (exact duplicates, trivial fragments, inferred type/tags, title normalization); **batch-confirm** (semantic merges, splits — one summary screen, one tap to accept or undo, never per-item approval); **explicit human decision** (dropping substantive content, and submit itself — the D-hard boundary "project scope never auto-submits" stands: automation covers organizing, sharing stays human). The safety net is the supersede chain: every auto-merge preserves provenance and is undoable, so pre-approval is replaced by reversibility (undo-send, not confirm-dialog). Trigger: no new step to remember — `submit` becomes curate+submit in one command, with the curation summary attached to the PR description; session wrap-up is the alternative hook (batch-silent, per the D61 checkpoint posture). Closed loop: human undos feed back into confidence thresholds, consuming the D77 `useful_count` signals. This revises the earlier review-ladder instinct of per-item approval: the human moves from in-the-loop to on-the-loop.
1600
1602
 
1603
+ - **D79 — query-time synonym expansion with a second-chance round (2026-10-07).** Fourth retrieval layer after D56 (query construction) and D61 (agent-side expansion guidance, capture-time aliases): a curated, checked-in synonym map (`src/retrieve/synonyms.ts` — EN dev-domain groups plus ZH groups, bidirectional) applied at query time. `search()` runs round one unchanged; when it returns fewer candidates than requested, round two re-runs with synonym variants OR-ed in and fills only the gaps — round-one ids and scores are never diluted by the expansion. Deliberately not embeddings and not an LLM call: deterministic, auditable, zero dependencies; a bad synonym is a visible line in the file, not a silent vector. `SearchStats.secondRound` records whether the round ran (surfaced in `search --explain`). Eval proof: 3 synonym-only fixture queries are unanswerable in round one by construction (their words appear in no memory); round two answers 2 of 3 at rank –2. Baseline with the round: recall@1 0.81, recall@5 0.95, MRR 0.87 over 37 queries. Follow-ups, not in this change: feedback-weighted ranking once D77 counters land; the embeddings gate decision against these numbers; reranker evaluation. **Update (2026-10-07, LongMemEval-S):** measured on 470 questions (recall_any@k protocol, per-question isolated corpora): R@1 86.0% / R@5 97.0% / R@10 98.3% / MRR 0.909 — ahead of agentmemory’s published BM25-only (86.2% / 94.6% / 0.715) and level with their BM25+vector (95.2% / 98.6% / 0.882). But the second round fired on **0 of 470** queries: the `raw.length < limit` trigger never trips once the corpus fills the page — the round as designed is a thin-corpus fallback, not a scale answer. A pooled 22k-memory stress run drops R@5 to 40.6% (the lexical ceiling, now measured instead of asserted). Consequence: the trigger needs a redesign (score/coverage-based, or always-expand-with-fusion) before the round can claim the scale story; the pooled number is the baseline an embedding layer must beat. **Ablation (2026-10-07, same bench):** OFAT over the lexical factors, limit=10, effect on MRR: OR semantics **+0.652** (AND collapses to 0.257 — the load-bearing wall); prefix matching **-0.010** (disabling it lifts R@1 86.0%→87.9% — mildly harmful, needs a second dataset before acting); stopword filtering **+0.005** (keep: cheap, principled); synonym round **0.000** at every limit and scale tested. Limit sweep: R@1 is invariant to limit (86.0% at 1–50); returns flatten at limit 5 (R@5 97.0%, +1.3pp to go to 50); round-2 fired 10/470 at limit=50 and rescued nothing (identical to no-synonyms). Pooled 22k at limit=50: R@5 still 40.6%, round-2 fired 0. Reading: the lexical lemon is squeezed — OR does the work, the rest is ±0.01. The next gains must come from ranking under distractors (embedding/reranker layer), not more lexical tweaks. Rescue experiment (same day): fallback-append vs always-expand+RRF-fusion, 470q, stratified by query–gold overlap — **zero rescues** under either design, at either scale; not one question changes outcome. The 8 residual misses are reasoning problems (preference/multi-hop/temporal), not vocabulary gaps. The round’s value lives in a mismatch slice the public data barely contains (proven on constructed queries: 2 of 3 rescued). Keep the curated map; stop spending design budget on the trigger. (Superseded same day: index-time expansion, see backlog.)
1604
+ - **D80 — index-time synonym/translation expansion (2026-10-07).** Supersedes D79's query-time second round (removed from `search()`; `SearchStats.secondRound` gone): the curated map (`src/retrieve/synonyms.ts` — EN dev-domain groups, ZH groups and EN↔ZH translation pairs merged into one bidirectional group list) is applied at sync/index time. `writeRow` expands each memory's keywords via `expansionVariantsForDoc()` and appends the variants to the indexed `aliases` column (FTS and CJK-bigram columns both pick them up), so any related word matches in a plain round-1 query. Query side: single FTS round, no trigger, no second chance. Keyword-centric per Stone ("重要的是关键词"): Latin content terms (same stopword filter as the query builder, so both sides agree on what a keyword is) + CJK substring matching + multi-word Latin phrases (`regression test`); variants already present in the text are skipped; capped at 32 per memory to bound index growth. Derived at sync time — the Markdown file stays the source of truth; a map update needs a reindex to backfill. *Rationale: the precise slice (exact EN↔ZH pairs like 单点登录/SSO) must never be missed, and embeddings are fuzzy where the map is exact; paying the cost once at write time keeps every query simple and trigger-free.* Eval on the 41-query cross-lingual fixture: recall@1 0.732 → 0.756, recall@5 0.854 → **0.976**, MRR 0.791 → **0.859** — all 5 EN↔ZH vocabulary misses rescued at rank 1–3. Known trade-off: a single-keyword query can rank an aliases-only hit below shorter docs sharing the variant (`how do we ship`: rank 2 → 8 — its hit is 1 of 15 variants on a long aliases field); accepted, the old rank depended on the fragile thin-corpus trigger. `expandQueryWithSynonyms` kept as a bench-only export.
1605
+
1601
1606
  ## Open Questions
1602
1607
 
1603
1608
  _Pruned 2026-10-04 (D75): entries decided in D56/D57/D61/D68 and the shipped layers of the retrieval-robustness question were removed — the decisions are the record. What remains below is genuinely open._
@@ -1608,7 +1613,7 @@ _Pruned 2026-10-04 (D75): entries decided in D56/D57/D61/D68 and the shipped lay
1608
1613
 
1609
1614
  - **Novice experience: the 60-second magic moment and the plain-language layer (open, 2026-10-03; trimmed 2026-10-04):** the privacy model is now stated in plain words at init (D56). Still open: init offering to save one sample memory and immediately having the user's agent answer a question with it — the user *sees* recall work before being asked to trust it — and a plain-language layer over the review ladder ("saved on this computer" / "shared with the team") with outbox/submit/promote vocabulary becoming the advanced view. This is the same vocabulary wall the PM/BA question above hits from the other side.
1610
1615
 
1611
- - **Retrieval evaluation: measure recall before choosing a semantic layer (open, 2026-10-04):** the retrieval-robustness question closed with all three cheap layers shipped (D56 query construction; D61 agent-side expansion + capture-time aliases), and embeddings stayed deferred *by judgment, without numbers*. A comparable project (agent-memory) publishes an offline recall@5 of 0.98 and wears it as a badge. open-memex should build the same kind of small, fixed eval — a query set over a seeded store, recall@k / MRR for the current FTS5 pipeline — and publish the number. The semantic-layer decision then gets a gate: if FTS5 + aliases is good enough, embeddings stay deferred; if a query class measurably fails, that class defines what an optional local-embedding layer must beat. Eval first, architecture second.
1616
+ - **Retrieval evaluation: measure recall before choosing a semantic layer (open, 2026-10-04):** the retrieval-robustness question closed with all three cheap layers shipped (D56 query construction; D61 agent-side expansion + capture-time aliases), and embeddings stayed deferred *by judgment, without numbers*. A comparable project (agent-memory) publishes an offline recall@5 of 0.98 and wears it as a badge. open-memex should build the same kind of small, fixed eval — a query set over a seeded store, recall@k / MRR for the current FTS5 pipeline — and publish the number. The semantic-layer decision then gets a gate: if FTS5 + aliases is good enough, embeddings stay deferred; if a query class measurably fails, that class defines what an optional local-embedding layer must beat. Eval first, architecture second. **Update (2026-10-07):** the eval shipped (D77, `scripts/retrieval-eval.ts`, baseline recall@1 0.81 / recall@5 0.95 / MRR 0.87); query-time synonym expansion landed as retrieval layer 4 (D79) — embeddings stay deferred, now with numbers behind the judgment.
1612
1617
 
1613
1618
  - **Per-client trust levels (open, 2026-10-04):** the review ladder is currently global: every host's writes face the same gates. A comparable tool (auxly) sets a trust level per agent — one agent's writes land immediately, another's queue for approval, a third is read-only. That maps naturally onto open-memex, since the ladder already exists; the open questions are the config surface (per host? per MCP client name? how does the server learn the client's identity reliably?), the default for unknown clients, and whether trust should also gate *reads* of the personal scope. Signal to watch: one user running several agents with different autonomy expectations.
1614
1619
 
@@ -34,7 +34,7 @@ npm run build
34
34
  最后跑全量测试。`test-full` 之前必须先 `build`:测试里有 dist 内容指纹守卫,没 build 会误报失败。
35
35
 
36
36
  ```powershell
37
- npm run test-full
37
+ node --experimental-strip-types scripts/test-full.ts
38
38
  ```
39
39
 
40
40
  测试全绿再往下走,中间任何一步报错就停。
@@ -91,7 +91,13 @@ npm publish
91
91
  ```
92
92
 
93
93
  3. 把版本号告诉小沐(例如"0.7.2 published"):小沐核验 npm latest,然后打 git tag `vX.Y.Z` 并建 GitHub Release。
94
- 4. 同步 MCP Registry。**必须等 npm 新版生效后再跑**:registry 会去 npm 抓新版本的实际包验 `mcpName` 和版本。`npm publish` 成功后先等 1–2 分钟,再执行发布(登录态过期时才需要先重跑 `login github`,也是一条一条来):
94
+ 4. 同步 MCP Registry。**必须等 npm 新版生效后再跑**:registry 会去 npm 抓新版本的实际包验 `mcpName` 和版本。`npm publish` 成功后先等 1–2 分钟,再执行发布(一条一条来)。如果登录态过期(`publish` 报认证错误),先重跑登录:
95
+
96
+ ```powershell
97
+ & "$env:USERPROFILE\tools\mcp-publisher\mcp-publisher.exe" login github
98
+ ```
99
+
100
+ 登录态没过期就跳过这一步,直接发布:
95
101
 
96
102
  ```powershell
97
103
  & "$env:USERPROFILE\tools\mcp-publisher\mcp-publisher.exe" publish