open-memex 0.8.0-alpha.2 → 0.8.0

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
@@ -144,8 +144,9 @@ Every write path (tool, keyword hook, CLI `add`) must:
144
144
 
145
145
  Capture-time aliases (D61): `memory_add`/`supersede` may carry up to 4
146
146
  `aliases` (alternate phrasings) into frontmatter; they index in their own FTS
147
- column. Only when `cfg.captureAliases` is on (init asks once, default on) —
148
- when off, passed aliases are dropped. Always pass them through
147
+ column. Always on since 2026-10-07 (Stone: init no longer asks) —
148
+ `cfg.captureAliases` still gates them for backward compatibility with
149
+ installs that set it manually, but new installs never see the question. Always pass them through
149
150
  `normalizeAliases`; the keyword-capture path (no agent in the loop) never
150
151
  invents aliases. Checkpoint-proposal guidance (propose, never auto-draft) is
151
152
  copy, not mechanism: keep the rubric wording in sync across the skill, the
@@ -205,8 +206,11 @@ breaking v1→v2 transition) shipped with 0.3.0. Full rules: `CONTRIBUTING.md`.
205
206
  is not done. Anything deferred as "later" goes into the near-term backlog in
206
207
  `docs/V2-DESIGN.md` §18 the same day — unwritten later means never.
207
208
  - **Version bumps ship with features.** `package.json` + `package-lock.json` carry the
208
- in-development version. New features on a dev branch bump the minor on the alpha
209
- line (`0.3.0` → `0.4.0-alpha.1`); fixes bump the patch (`-alpha.1` → `-alpha.2`).
209
+ in-development version. A new alpha line starts with a minor bump after the
210
+ previous minor shipped (`0.3.0` → `0.4.0-alpha.1`); while that minor is still
211
+ unreleased, further features AND fixes on the same line only bump the alpha
212
+ suffix (`0.8.0-alpha.2` → `-alpha.3`) — never jump to the next minor before
213
+ the current one is released.
210
214
  The bump goes in the same commit as the feature, never as an afterthought.
211
215
  The version number serves the publish: no publish, no mandatory bump. But once a
212
216
  version has been pushed to the remote (shared), later changes must bump — two
package/CHANGELOG.md CHANGED
@@ -9,6 +9,10 @@ the `@alpha` tag are development snapshots and are not listed individually.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.8.0] - 2026-10-07
13
+
14
+ - User-defined alias vocabulary as query-time expansion (D81): any memory type may now carry `alias:` (nickname, e.g. 香蕉计划) and `target:` (referent, e.g. 支付系统重构项目) frontmatter — an alias memory defines vocabulary once per scope. At query time, query terms hit the alias registry (exact match, one cheap SELECT per search) and the query is rewritten with the other side of the relation — bidirectional, depth < 3, cycle-safe — then re-run as round 2/3. Rounds merge by normalized-max (`mergeRounds` in `src/retrieve/alias-memory.ts`): each round's BM25 scores are divided by that round's best, so an alias-side champion competes evenly with a literal-side champion; the merged pool is re-ranked and cut to [1.5K, 2K], never back to K. No reindex when an alias changes. Creation: `memory_add` alias/target params, CLI `add --alias/--target`, or frontmatter edit (v1: user-explicit only). Fixture grows to 54 memories / 44 queries (3 bidirectional alias queries): recall@1 0.750, recall@5 **0.977**, MRR 0.857. Also removes the init `captureAliases` and keyword-capture questions — both are now always on (Stone 2026-10-07).
15
+
12
16
  - 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
17
 
14
18
  - 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.
package/README.md CHANGED
@@ -406,8 +406,19 @@ Three ways memories get in:
406
406
  **Aliases.** Each saved memory can carry up to 4 alternate phrasings
407
407
  (synonyms, another language's equivalent) that are indexed with it, so a
408
408
  question worded differently still finds the memory — "vacation days" finds the
409
- holiday policy. `init` asks once whether to enable this (default on); turn it
410
- off any time with `open-memex config set captureAliases false`.
409
+ holiday policy. Always on — the agent may attach up to 4 per memory, and you
410
+ can pass your own via `open-memex add --aliases`.
411
+
412
+ Three ways to add them: `open-memex add "text" --aliases "phrase one; phrase
413
+ two"` (semicolon-separated, up to 4); ask the agent ("remember: 小宝贝 is my
414
+ AI agent, alias it as 小沐"); or edit the memory's `aliases:` frontmatter
415
+ directly — Markdown is the source of truth and sync picks it up.
416
+
417
+ Separately, a curated synonym/translation map is expanded into the index
418
+ automatically at write time (D80) — `ship` finds `deploy` even with no
419
+ aliases on the memory. Personal nicknames belong in aliases; shared
420
+ dev-domain vocabulary belongs in the curated map. See [Retrieval: how
421
+ memories come back](#retrieval-how-memories-come-back).
411
422
 
412
423
  **Redaction.** Wrap anything sensitive in `<private>…</private>` and it is
413
424
  stripped before saving. Recognized secrets (API keys, tokens, high-entropy
@@ -523,20 +534,28 @@ the opencode plugin injects it directly on the first turn.
523
534
  ### Retrieval evaluation
524
535
 
525
536
  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
537
+ 54 memories + 44 queries (exact terms, paraphrases, synonyms, Chinese,
538
+ cross-language hard cases, synonym-only queries, and D81 user-defined
539
+ alias queries) — reproduce with
528
540
  `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**.
541
+ Current baseline, with index-time synonym/translation expansion (D80) and
542
+ query-time user alias expansion (D81):
543
+ **recall@1 0.75, recall@5 0.98, MRR 0.86**.
531
544
  Small synthetic corpus, so read it as a regression guard and a starting point,
532
545
  not a real-world claim.
533
546
 
534
547
  Three layers, each covering what the others miss: **BM25 keyword search**
535
548
  (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**
549
+ **synonym expansion in two halves** — (a) index-time (D80: a curated,
550
+ checked-in map of dev-domain equivalents and EN↔ZH translation pairs
551
+ expanded into the index at write time, so `ship` finds `deploy` and
552
+ `单点登录` finds `SSO` in a plain single-round query; deterministic, no
553
+ model download); (b) query-time user aliases (D81: an alias memory carrying
554
+ `alias:`/`target:` frontmatter defines your vocabulary once per scope —
555
+ e.g. 香蕉计划 → 支付系统重构项目 — and a query mentioning either side
556
+ triggers a second round on the other side, both directions, merged evenly
557
+ with round one so alias hits are never down-weighted; no reindex when the
558
+ alias changes); and **semantic vectors**
540
559
  as an opt-in experiment (local multilingual embeddings measured at R@5 92.7%
541
560
  on the cross-lingual fixture slice — see `docs/retrieval-ablation-study.md`
542
561
  §4.7; no embedding model is ever downloaded without your explicit opt-in).
@@ -572,11 +591,13 @@ asserted. Reproduce with
572
591
 
573
592
  Honest edges, so nothing surprises you:
574
593
 
575
- - **Keyword search, not semantic.** Retrieval is BM25 keyword matching: search
576
- finds the words you saved, not paraphrases. (Plain questions are fine —
577
- "how do we…" / "请问…" wording is filtered out before matching, so asking
578
- naturally doesn't dilute the results.) No embedding model is ever
579
- downloaded without your explicit opt-in.
594
+ - **Keyword search, not semantic.** Retrieval is BM25 keyword matching, plus a
595
+ curated synonym map expanded into the index at write time (`ship` finds
596
+ `deploy`, `单点登录` finds `SSO`). Paraphrases outside the curated map
597
+ don't match — that's what the opt-in semantic layer is for. (Plain
598
+ questions are fine — "how do we…" / "请问…" wording is filtered out before
599
+ matching, so asking naturally doesn't dilute the results.) No embedding
600
+ model is ever downloaded without your explicit opt-in.
580
601
  - **One machine.** Editors on the same machine share memory; there is no
581
602
  cross-machine sync. `export` / `import` bundles (below) move memory between
582
603
  machines manually.
package/README.zh-CN.md CHANGED
@@ -263,7 +263,11 @@ open-memex uninstall --yes
263
263
  - **检查点提议**:一个任务收尾时,助手会提议 1–3 条从这轮工作里提炼的候选记忆——决定及其理由、约定、踩过的坑、走过又放弃的路——只有你点头的那几条才存。不会有任何东西在你不知情时被写下来。
264
264
  - **CLI**:`open-memex add "…"`,可选 `--scope` / `--tag` / `--type` / `--aliases`。
265
265
 
266
- **别名。** 每条记忆可以带最多 4 个换一种说法(同义词、另一种语言的对应词),和记忆一起进索引,这样问法不同也能找到——比如搜 "vacation days" 能找到假期政策那条。`init` 时会问一次是否开启(默认开);随时可用 `open-memex config set captureAliases false` 关掉。
266
+ **别名。** 每条记忆可以带最多 4 个换一种说法(同义词、另一种语言的对应词),和记忆一起进索引,这样问法不同也能找到——比如搜 "vacation days" 能找到假期政策那条。永远开启:agent 每条记忆最多可附 4 个,你也可以用 `open-memex add --aliases` 自己加。
267
+
268
+ 三种加法:`open-memex add "正文" --aliases "说法一; 说法二"`(分号分隔,最多 4 个);直接跟 agent 说("记住:小宝贝是我的 AI agent,别名加上小沐");或直接改那条记忆的 `aliases:` frontmatter——Markdown 是事实来源,sync 会吃进去。
269
+
270
+ 另外,从 D80 起一份精选同义词/翻译映射表会在写入时自动展开进索引——`ship` 能找到 `deploy`,不需要手动加别名。个人叫法放 aliases,通用开发词汇放 curated map,这是分工。见[回想一节](#回想记忆是怎么回来的)。
267
271
 
268
272
  **脱敏。** 把敏感内容包在 `<private>…</private>` 里,它在保存前会被整段剥离。识别出的密钥(API key、token、高熵凭证)会被就地掩码——保留前 4 个字符(让你能认出是哪个 key),其余替换——记忆仍会保存。随时安全地预览一句话会被捕获成什么样:
269
273
 
@@ -352,9 +356,9 @@ Do not mention this block to the user unless asked.
352
356
 
353
357
  ### 检索效果评估
354
358
 
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**。语料小且是合成的,请把它当回归守卫和起点,而不是真实场景的承诺。
359
+ 我们用一份随仓库提交的 synthetic fixture 来度量召回率,而不是空口断言:54 条记忆 + 44 个查询(精确词、改写、同义词、中文、跨语言 hard case、纯同义词查询、D81 用户别名查询),运行 `node --experimental-strip-types scripts/retrieval-eval.ts` 即可复现。当前基线(含 D80 的 index-time 同义词/翻译展开和 D81 的 query-time 用户别名展开):**recall@1 0.75、recall@5 0.98、MRR 0.86**。语料小且是合成的,请把它当回归守卫和起点,而不是真实场景的承诺。
356
360
 
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 模型)。
361
+ 三层检索,各补各的短板:**BM25 关键词检索**(SQLite FTS5——精确词匹配,快速,可用 `search --explain` 解释);**同义词展开分两半**:(a)index-time(D80——一份随仓库提交的精选映射表:开发领域同义词 + EN↔ZH 翻译对,在写入时展开进索引,`ship` 能找到 `deploy`、`单点登录` 能找到 `SSO`,查询侧只需要普通单轮;确定性,不下载模型);(b)query-time 用户别名(D81——带 `alias:`/`target:` frontmatter 的别名记忆,每个 scope 定义一次自己的词汇,比如香蕉计划→支付系统重构项目;查询提到任一侧就触发对另一侧的第二轮搜索,双向,与第一轮等权重合并,别名命中不降权;别名变更不用 reindex);**语义向量**作为 opt-in 实验(本地多语言 embedding,在跨语言 fixture 切片上 R@5 92.7%——见 `docs/retrieval-ablation-study.md` §4.7;未经你明确允许绝不下载 embedding 模型)。
358
362
 
359
363
  公开数据上的外部验证:同一管线在 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
364
 
@@ -370,7 +374,7 @@ Do not mention this block to the user unless asked.
370
374
 
371
375
  把边界写明白,免得你事后意外:
372
376
 
373
- - **关键词搜索,不是语义搜索。** 回想走 BM25 关键词匹配:搜的是你存下的词,不是近义改写。(用自然问句没问题——"如何/请问"这类提问词会在匹配前过滤掉,不会稀释结果。)任何 embedding 模型没经你明确同意,永不下载。
377
+ - **关键词搜索,不是语义搜索。** 回想走 BM25 关键词匹配,外加一份精选同义词映射表在写入时展开(`ship` 找得到 `deploy`,`单点登录` 找得到 `SSO`)。映射表之外的意译改写(比如整句换说法)匹配不到——那是 opt-in 语义层的事。(用自然问句没问题——"如何/请问"这类提问词会在匹配前过滤掉,不会稀释结果。)任何 embedding 模型没经你明确同意,永不下载。
374
378
  - **单台机器。** 同一台机器上的编辑器共享记忆;没有跨机器同步。用 `export` / `import` 压缩包(见下)手动搬家。
375
379
  - **快照,不是全部。** 会话开始的回想是前 N 条快照(默认项目 8 + 个人 5);更老的记忆 `memory_search` 一步就能到,但不会同时全在上下文里。
376
380
  - **MCP 的引导是建议性的。** opencode 之外,主动捕获和回想靠助手遵循握手指令——MCP 里没有硬性的会话开始钩子。工具本身在被调用时永远可靠。
package/dist/.build-stamp CHANGED
@@ -1 +1 @@
1
- 9f341cad8c580b840308fbdb525de70a108bc40aa372f47c4b248baa67ab14f8
1
+ 55d6d5c601b1769ad9c4813aebefcb25ee3b29cab0c6c30f8450d9388fe83a6a
package/dist/cli.js CHANGED
@@ -107,7 +107,7 @@ Example:
107
107
  open-memex search "deploy checklist" --scope both`,
108
108
  add: `Save a fact, preference, decision, or note to local memory.
109
109
 
110
- Usage: open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2] [--aliases "a;b"]
110
+ Usage: open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2] [--aliases "a;b"] [--alias NICK --target REFERENT]
111
111
 
112
112
  Flags:
113
113
  --scope project (default) or personal (personal never leaves this machine)
@@ -116,8 +116,12 @@ Flags:
116
116
  --aliases alternate phrasings of this fact (up to 4), indexed with the
117
117
  memory so differently-worded searches still match; separate
118
118
  with ; (commas also accepted — use ; when an alias itself
119
- contains one). Stored only when capture aliases are enabled
120
- (init default)
119
+ contains one). Always on.
120
+ --alias --target D81: define user vocabulary once — e.g. --alias 香蕉计划
121
+ --target 支付系统重构项目. Later queries mentioning either side
122
+ trigger a second search round on the other side (both
123
+ directions), scoped like the memory itself. Not gated by
124
+ capture aliases.
121
125
 
122
126
  Example:
123
127
  open-memex add "We deploy on Fridays" --scope project --tag process`,
@@ -900,7 +904,7 @@ async function main() {
900
904
  : [project.key, PERSONAL_SCOPE.key];
901
905
  const explain = flags.explain === "true";
902
906
  const stats = explain
903
- ? { ftsQuery: "", candidates: 0, hiddenSuperseded: 0, hiddenExcluded: 0 }
907
+ ? { ftsQuery: "", candidates: 0, hiddenSuperseded: 0, hiddenExcluded: 0, aliasExpansions: [] }
904
908
  : undefined;
905
909
  const hits = search(query, {
906
910
  scopeKeys: keys,
@@ -911,6 +915,9 @@ async function main() {
911
915
  if (explain && stats) {
912
916
  console.log(`fts: ${stats.ftsQuery || "(empty query)"}`);
913
917
  console.log(`candidates: ${stats.candidates}, hidden by lifecycle: ${stats.hiddenSuperseded} superseded, ${stats.hiddenExcluded} retracted/archived`);
918
+ if (stats.aliasExpansions.length > 0) {
919
+ console.log(`alias rounds: ${stats.aliasExpansions.map((q) => `"${q}"`).join(" → ")}`);
920
+ }
914
921
  }
915
922
  if (hits.length === 0) {
916
923
  console.log("(no matches)");
@@ -971,6 +978,14 @@ async function main() {
971
978
  .filter(Boolean)),
972
979
  }
973
980
  : {}),
981
+ // D81: --alias/--target define user vocabulary (not gated by capture
982
+ // aliases — this is deliberate, like typing the words yourself).
983
+ ...(typeof flags.alias === "string" && flags.alias !== "true" && flags.alias.trim()
984
+ ? { alias: redact(flags.alias.trim(), cfg.redactPatterns).content }
985
+ : {}),
986
+ ...(typeof flags.target === "string" && flags.target !== "true" && flags.target.trim()
987
+ ? { target: redact(flags.target.trim(), cfg.redactPatterns).content }
988
+ : {}),
974
989
  source: "cli",
975
990
  created_at: rfc,
976
991
  updated_at: rfc,
package/dist/init.js CHANGED
@@ -811,17 +811,17 @@ export async function initProject(opts) {
811
811
  // Install-time settings (D19). Non-default answers persist to the JSONC
812
812
  // config file; `open-memex config set` changes them later.
813
813
  const patch = {};
814
- const keywordCaptureEnabled = await askBool("Auto-capture keywords like remember… / note that… into memory?", DEFAULT_CONFIG.keywordCaptureEnabled);
815
- if (keywordCaptureEnabled !== DEFAULT_CONFIG.keywordCaptureEnabled)
816
- patch.keywordCaptureEnabled = keywordCaptureEnabled;
814
+ // Keyword auto-capture is always on (Stone 2026-10-07): no init
815
+ // question — there is no reason to turn it off. The
816
+ // `keywordCaptureEnabled` config key is still honored if set manually.
817
817
  const injectOnFirstTurn = await askBool("Inject relevant memories when a session starts?", DEFAULT_CONFIG.injectOnFirstTurn);
818
818
  if (injectOnFirstTurn !== DEFAULT_CONFIG.injectOnFirstTurn)
819
819
  patch.injectOnFirstTurn = injectOnFirstTurn;
820
- // D61: capture-time aliases — agents attach alternate phrasings to each
821
- // memory so differently-worded questions still find it.
822
- const captureAliases = await askBool("Attach alternate phrasings (aliases) to memories so reworded questions still find them?", DEFAULT_CONFIG.captureAliases);
823
- if (captureAliases !== DEFAULT_CONFIG.captureAliases)
824
- patch.captureAliases = captureAliases;
820
+ // D61 capture-time aliases are always on (Stone 2026-10-07): the agent
821
+ // may always attach its own alternate phrasings — no init question.
822
+ // (Distinct from D80's automatic index-time synonym expansion, which
823
+ // also always applies.) The `captureAliases` config key is still
824
+ // honored if someone sets it manually; init just never asks anymore.
825
825
  if (Object.keys(patch).length > 0) {
826
826
  const file = saveConfig(patch);
827
827
  console.log(` + settings saved to ${file} (change later with \`open-memex config set <key> <value>\`)`);
@@ -0,0 +1,130 @@
1
+ /**
2
+ * D81: user-defined alias vocabulary as query-time expansion.
3
+ *
4
+ * An alias memory carries `alias:` (the nickname, e.g. 香蕉计划) and
5
+ * `target:` (what it refers to, e.g. 支付系统重构项目) frontmatter on any
6
+ * memory type. Unlike D80's index-time curated map (static, shared by all
7
+ * users), alias memories are dynamic user vocabulary:
8
+ * - defining one takes effect on the next query — no reindex;
9
+ * - aliases are scope-aware (a project codename stays in its project,
10
+ * a personal nickname stays personal).
11
+ *
12
+ * Search flow (search.ts):
13
+ * 1. Round 1 searches the query as-is.
14
+ * 2. Query terms are looked up against the alias registry (trigger A:
15
+ * an exact registry hit, not a count heuristic — the D79 trigger that
16
+ * fired 0/470 is not repeated).
17
+ * 3. Each expansion round rewrites the query with the other side of the
18
+ * relation — bidirectional (alias→target and target→alias), depth < 3,
19
+ * cycle-safe.
20
+ * 4. Rounds merge by normalized-max (mergeRounds): each round's scores are
21
+ * divided by that round's best, so an alias-side champion competes
22
+ * evenly with a literal-side champion — alias matches are never
23
+ * down-weighted for coming from a later round.
24
+ */
25
+ import { db } from "../store/db.js";
26
+ import { latinTerms } from "./query.js";
27
+ /**
28
+ * All alias definitions visible to these scopes. One cheap query per
29
+ * search() call — no cache, so a newly saved alias memory applies on the
30
+ * very next query.
31
+ */
32
+ export function loadAliasEntries(scopeKeys) {
33
+ const filter = scopeKeys && scopeKeys.length > 0
34
+ ? `AND scope_key IN (${scopeKeys.map(() => "?").join(",")})`
35
+ : "";
36
+ return db()
37
+ .prepare(`SELECT id, scope_key, alias, target FROM memories
38
+ WHERE alias <> '' AND target <> '' AND status = 'active' ${filter}`)
39
+ .all(...(scopeKeys ?? []));
40
+ }
41
+ const CJK_RE = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/;
42
+ function escapeRegExp(s) {
43
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
44
+ }
45
+ /** CJK: substring (no tokenizer). Latin: whole-token match. */
46
+ function termInQuery(query, term) {
47
+ if (CJK_RE.test(term))
48
+ return query.includes(term);
49
+ const tokens = new Set(latinTerms(query));
50
+ return tokens.has(term.toLowerCase());
51
+ }
52
+ function replaceTerm(query, term, replacement) {
53
+ if (CJK_RE.test(term))
54
+ return query.replace(term, replacement);
55
+ return query.replace(new RegExp(`\\b${escapeRegExp(term)}\\b`, "i"), replacement);
56
+ }
57
+ /**
58
+ * Rewrite the query once: every alias entry with a matching side swaps in
59
+ * the other side. All matching entries apply in one pass (a chain A→B→C
60
+ * can resolve within a single round). Returns null when nothing matched.
61
+ */
62
+ export function rewriteQueryOnce(query, entries) {
63
+ let out = query;
64
+ let changed = false;
65
+ for (const e of entries) {
66
+ if (termInQuery(out, e.alias)) {
67
+ out = replaceTerm(out, e.alias, e.target);
68
+ changed = true;
69
+ }
70
+ else if (termInQuery(out, e.target)) {
71
+ out = replaceTerm(out, e.target, e.alias);
72
+ changed = true;
73
+ }
74
+ }
75
+ return changed ? out : null;
76
+ }
77
+ /**
78
+ * Plan the expansion rounds for a query. Pure — takes the registry entries
79
+ * explicitly so tests don't need a database. Returns the rewritten queries
80
+ * only (rounds 2+); the caller prepends the original query. Depth < 3:
81
+ * at most two expansion rounds. Cycle-safe via the seen set.
82
+ */
83
+ export function planAliasExpansions(query, entries) {
84
+ const out = [];
85
+ const seen = new Set([query]);
86
+ let current = query;
87
+ for (let depth = 0; depth < 2; depth++) {
88
+ const next = rewriteQueryOnce(current, entries);
89
+ if (!next || seen.has(next))
90
+ break;
91
+ seen.add(next);
92
+ out.push(next);
93
+ current = next;
94
+ }
95
+ return out;
96
+ }
97
+ /**
98
+ * D81 merge: per-round score normalization + max.
99
+ *
100
+ * BM25 scores are query-dependent (different terms → different IDFs and
101
+ * query lengths), so raw scores from two rounds are incomparable — like
102
+ * comparing °C with °F. Each round is first divided by its own best
103
+ * (n=1.0 means "this round's champion"), then final = max across rounds.
104
+ * An alias-side champion (n=1.0) outranks a mediocre literal match
105
+ * (n=0.4): the two sides' champions start even, which is exactly "no
106
+ * down-weighting for being a synonym/alias". The kept row (and its
107
+ * snippet) comes from the round where the doc scored its best.
108
+ */
109
+ export function mergeRounds(rounds) {
110
+ const acc = new Map();
111
+ rounds.forEach((docs) => {
112
+ const mx = docs.reduce((m, d) => Math.max(m, d.score), 0);
113
+ for (const d of docs) {
114
+ const n = mx > 0 ? d.score / mx : 0;
115
+ const cur = acc.get(d.id);
116
+ if (!cur)
117
+ acc.set(d.id, { doc: d, best: n, rounds: 1 });
118
+ else {
119
+ cur.rounds += 1;
120
+ if (n > cur.best) {
121
+ cur.best = n;
122
+ cur.doc = d;
123
+ }
124
+ }
125
+ }
126
+ });
127
+ return [...acc.values()]
128
+ .map((v) => ({ doc: v.doc, score: v.best, rounds: v.rounds }))
129
+ .sort((a, b) => b.score - a.score || b.rounds - a.rounds);
130
+ }
@@ -27,7 +27,7 @@ const CJK_QUESTION_BIGRAMS = new Set([
27
27
  "什么", "怎么", "如何", "为何", "为什", "哪些", "哪个", "请问",
28
28
  ]);
29
29
  const MAX_TERMS = 24;
30
- function latinTerms(q, useStopwords = true) {
30
+ export function latinTerms(q, useStopwords = true) {
31
31
  const raw = q.toLowerCase().match(/[a-z0-9_.\-]+/g) ?? [];
32
32
  const kept = useStopwords
33
33
  ? raw.filter((t) => t.length > 1 && !LATIN_STOPWORDS.has(t))
@@ -1,5 +1,6 @@
1
1
  import { db } from "../store/db.js";
2
2
  import { toFtsQuery } from "./query.js";
3
+ import { loadAliasEntries, planAliasExpansions, mergeRounds, } from "./alias-memory.js";
3
4
  function toHit(r) {
4
5
  const aliases = r.aliases ? r.aliases.split(",").map((a) => a.trim()).filter(Boolean) : [];
5
6
  let snippet = r.snippet ?? "";
@@ -124,6 +125,7 @@ export function search(query, opts = {}) {
124
125
  opts.stats.candidates = 0;
125
126
  opts.stats.hiddenSuperseded = 0;
126
127
  opts.stats.hiddenExcluded = 0;
128
+ opts.stats.aliasExpansions = [];
127
129
  }
128
130
  if (!q)
129
131
  return [];
@@ -160,12 +162,40 @@ export function search(query, opts = {}) {
160
162
  return rows.map((r) => ({ ...r, score: -r.score }));
161
163
  };
162
164
  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.
165
+ // D81: user-defined alias vocabulary — query-time expansion rounds.
166
+ // Round 1 is the query as-is; each further round rewrites alias terms to
167
+ // the other side of the relation (bidirectional, depth < 3). The trigger
168
+ // is an exact registry hit (loadAliasEntries), not a count heuristic.
169
+ const aliasEntries = loadAliasEntries(opts.scopeKeys);
170
+ const expansions = planAliasExpansions(query, aliasEntries);
171
+ let merged = raw;
172
+ let finalLimit = limit;
173
+ if (expansions.length > 0) {
174
+ const rounds = [raw];
175
+ for (const eq of expansions) {
176
+ const mq = toFtsQuery(eq, qflags);
177
+ if (mq)
178
+ rounds.push(fetchFts(mq));
179
+ }
180
+ // Normalized-max merge (alias-memory.ts): each round's scores are
181
+ // divided by that round's best, so an alias-side champion competes
182
+ // evenly with a literal-side champion — no down-weighting for being
183
+ // from a later round.
184
+ merged = mergeRounds(rounds).map((m) => ({ ...m.doc, score: m.score }));
185
+ // Cut to [1.5K, 2K] — never back to K, or round-2 hits would be
186
+ // starved the way D79's slice-to-K starved them.
187
+ const pool = merged.length;
188
+ finalLimit = Math.min(pool, 2 * limit);
189
+ finalLimit = Math.max(finalLimit, Math.min(pool, Math.ceil(1.5 * limit)));
190
+ if (opts.stats)
191
+ opts.stats.aliasExpansions = expansions;
192
+ }
193
+ // D80: the curated static map is solved at index time (synonyms.ts
194
+ // expands variants into the aliases column at sync time), so without a
195
+ // user alias the query side stays a single FTS round.
166
196
  if (opts.stats)
167
- opts.stats.candidates = raw.length;
168
- return resolveVisible(raw, limit, opts.stats);
197
+ opts.stats.candidates = merged.length;
198
+ return resolveVisible(merged, finalLimit, opts.stats);
169
199
  }
170
200
  export function list(scopeKey, opts = {}) {
171
201
  const limit = Math.max(1, Math.min(opts.limit ?? 20, 100));
package/dist/store/db.js CHANGED
@@ -35,6 +35,8 @@ CREATE TABLE IF NOT EXISTS memories (
35
35
  status TEXT NOT NULL DEFAULT 'active',
36
36
  tags TEXT NOT NULL DEFAULT '',
37
37
  aliases TEXT NOT NULL DEFAULT '',
38
+ alias TEXT NOT NULL DEFAULT '',
39
+ target TEXT NOT NULL DEFAULT '',
38
40
  content TEXT NOT NULL,
39
41
  cjk TEXT NOT NULL DEFAULT '',
40
42
  content_hash TEXT NOT NULL DEFAULT '',
@@ -88,7 +90,7 @@ CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
88
90
  END;
89
91
  `;
90
92
  /** Current index schema version. Bump when TABLE_SCHEMA/FTS_SCHEMA change. */
91
- const SCHEMA_VERSION = 7;
93
+ const SCHEMA_VERSION = 8;
92
94
  function userVersion(d) {
93
95
  const row = d.prepare("PRAGMA user_version").get();
94
96
  return row.user_version;
@@ -215,6 +215,9 @@ export function supersede(oldId, input) {
215
215
  tags: input.tags ?? oldMf.fm.tags,
216
216
  // D61: aliases carry forward unless the caller replaces them.
217
217
  aliases: input.aliases ?? oldMf.fm.aliases,
218
+ // D81: alias vocabulary carries forward the same way.
219
+ alias: input.alias ?? oldMf.fm.alias,
220
+ target: input.target ?? oldMf.fm.target,
218
221
  source: input.source ?? oldMf.fm.source,
219
222
  status: "active",
220
223
  created_at: now,
@@ -161,6 +161,13 @@ export function normalizeFrontmatter(raw) {
161
161
  return aliases.length > 0 ? { aliases } : {};
162
162
  })()
163
163
  : {}),
164
+ // D81: alias vocabulary — single trimmed strings, dropped when empty.
165
+ ...(typeof raw.alias === "string" && raw.alias.trim()
166
+ ? { alias: raw.alias.trim() }
167
+ : {}),
168
+ ...(typeof raw.target === "string" && raw.target.trim()
169
+ ? { target: raw.target.trim() }
170
+ : {}),
164
171
  source: typeof raw.source === "string" ? raw.source : "",
165
172
  created_at: msToRfc3339(timeToMs(raw.created_at)),
166
173
  updated_at: msToRfc3339(timeToMs(raw.updated_at)),
@@ -8,8 +8,8 @@ import { iterMemoryFiles, iterInRepoMemoryFiles, readMemoryFile, timeToMs, } fro
8
8
  import { projectRoot, paths } from "../paths.js";
9
9
  import { loadConfig } from "../config.js";
10
10
  const UPSERT_SQL = `
11
- INSERT INTO memories (id, scope_key, scope, visibility, project_name, type, role, importance, status, tags, aliases, content, cjk, content_hash, superseded_by, source, file_path, mtime_ms, created_at, updated_at, review_state)
12
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
11
+ INSERT INTO memories (id, scope_key, scope, visibility, project_name, type, role, importance, status, tags, aliases, alias, target, content, cjk, content_hash, superseded_by, source, file_path, mtime_ms, created_at, updated_at, review_state)
12
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
13
13
  ON CONFLICT(id) DO UPDATE SET
14
14
  scope_key = excluded.scope_key,
15
15
  scope = excluded.scope,
@@ -21,6 +21,8 @@ const UPSERT_SQL = `
21
21
  status = excluded.status,
22
22
  tags = excluded.tags,
23
23
  aliases = excluded.aliases,
24
+ alias = excluded.alias,
25
+ target = excluded.target,
24
26
  content = excluded.content,
25
27
  cjk = excluded.cjk,
26
28
  content_hash = excluded.content_hash,
@@ -63,7 +65,8 @@ function writeRow(mf, mtimeMs) {
63
65
  const have = new Set(supplied.map((a) => a.toLowerCase()));
64
66
  const auto = expansionVariantsForDoc(body + "\n" + tags).filter((a) => !have.has(a.toLowerCase()));
65
67
  const aliases = [...supplied, ...auto].join(",");
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");
68
+ // D81: user-defined alias vocabulary (single nickname → referent pair).
69
+ 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, fm.alias ?? "", fm.target ?? "", 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");
67
70
  }
68
71
  export function deleteFromIndex(id) {
69
72
  db().prepare(`DELETE FROM memories WHERE id = ?`).run(id);
package/dist/tools/ops.js CHANGED
@@ -58,7 +58,7 @@ export const scopeArg = z
58
58
  export const aliasesArg = z
59
59
  .array(z.string())
60
60
  .optional()
61
- .describe("Optional: alternate phrasings of this fact — synonyms, another way a question might be worded, equivalents in the user's other language (e.g. 节假日 for 'public holidays'). They are indexed with the memory so differently-worded questions still match. Only used when the install has capture aliases enabled (init default). Send at most 4; extras and blanks are dropped silently, never an error.");
61
+ .describe("Optional: alternate phrasings of this fact — synonyms, another way a question might be worded, equivalents in the user's other language (e.g. 节假日 for 'public holidays'). They are indexed with the memory so differently-worded questions still match. Always on. Send at most 4; extras and blanks are dropped silently, never an error.");
62
62
  export const memoryAddArgs = {
63
63
  content: z.string().min(1).describe("The fact to remember. One idea per memory."),
64
64
  type: z
@@ -68,6 +68,14 @@ export const memoryAddArgs = {
68
68
  scope: scopeArg,
69
69
  tags: z.array(z.string()).optional().describe("Optional tags for filtering."),
70
70
  aliases: aliasesArg,
71
+ alias: z
72
+ .string()
73
+ .optional()
74
+ .describe("D81: nickname this memory defines (e.g. 香蕉计划). Pairs with `target`: the alias memory expands queries in its scope at search time, both directions, no reindex. Explicit vocabulary — always stored, not gated by capture aliases."),
75
+ target: z
76
+ .string()
77
+ .optional()
78
+ .describe("D81: what `alias` refers to (e.g. 支付系统重构项目). Only meaningful with `alias`."),
71
79
  source: z
72
80
  .string()
73
81
  .optional()
@@ -106,6 +114,14 @@ export const memorySupersedeArgs = {
106
114
  .describe("Category of the new memory. Defaults to the old memory's type."),
107
115
  tags: z.array(z.string()).optional().describe("Optional tags for the new memory."),
108
116
  aliases: aliasesArg,
117
+ alias: z
118
+ .string()
119
+ .optional()
120
+ .describe("D81: nickname the new memory defines (e.g. 香蕉计划). Omitted = inherit the old memory's alias."),
121
+ target: z
122
+ .string()
123
+ .optional()
124
+ .describe("D81: what `alias` refers to (e.g. 支付系统重构项目). Omitted = inherit."),
109
125
  };
110
126
  export const memoryForgetArgs = {
111
127
  id: z.string().min(1),
@@ -170,6 +186,11 @@ function buildFrontmatter(s, body) {
170
186
  status: "active",
171
187
  tags: body.tags,
172
188
  ...(body.aliases && body.aliases.length > 0 ? { aliases: body.aliases } : {}),
189
+ // D81: explicit alias vocabulary — single trimmed strings, dropped
190
+ // when empty. Not gated by captureAliases (that gates agent-invented
191
+ // phrasings; this is deliberate vocabulary).
192
+ ...(body.alias?.trim() ? { alias: body.alias.trim() } : {}),
193
+ ...(body.target?.trim() ? { target: body.target.trim() } : {}),
173
194
  source: body.source,
174
195
  created_at: rfc,
175
196
  updated_at: rfc,
@@ -221,13 +242,21 @@ export async function addMemory(getScope, cfg, args) {
221
242
  const fm = buildFrontmatter(s, {
222
243
  type: args.type ?? "fact",
223
244
  tags: args.tags ?? [],
224
- // D61: aliases are stored only when the install enables them (init
225
- // asks once, default on); normalize caps at 4 and drops junk silently.
245
+ // D61: aliases are always stored (Stone 2026-10-07: init no longer asks;
246
+ // default on). Normalize caps at 4 and drops junk silently.
226
247
  // Aliases pass through the same redaction as content (write-path
227
248
  // invariant) — an alias carrying a token gets masked, not stored raw.
228
249
  aliases: cfg.captureAliases
229
250
  ? normalizeAliases((args.aliases ?? []).map((a) => redact(a, cfg.redactPatterns).content))
230
251
  : [],
252
+ // D81: explicit alias vocabulary — same redaction as content, never
253
+ // gated by captureAliases.
254
+ alias: args.alias?.trim()
255
+ ? redact(args.alias.trim(), cfg.redactPatterns).content
256
+ : undefined,
257
+ target: args.target?.trim()
258
+ ? redact(args.target.trim(), cfg.redactPatterns).content
259
+ : undefined,
231
260
  source: args.source ?? "tool",
232
261
  });
233
262
  const { filePath } = writeMemoryFile(fm, redacted);
@@ -322,6 +351,14 @@ export async function supersedeMemory(cfg, args) {
322
351
  aliases: cfg.captureAliases && args.aliases !== undefined
323
352
  ? normalizeAliases(args.aliases.map((a) => redact(a, cfg.redactPatterns).content))
324
353
  : undefined,
354
+ // D81: explicit alias vocabulary — omitted inherits the old memory's
355
+ // pair; same redaction as content; never gated by captureAliases.
356
+ alias: args.alias !== undefined
357
+ ? redact(args.alias, cfg.redactPatterns).content || undefined
358
+ : undefined,
359
+ target: args.target !== undefined
360
+ ? redact(args.target, cfg.redactPatterns).content || undefined
361
+ : undefined,
325
362
  source: "tool",
326
363
  });
327
364
  upsertFromFile(oldMf);