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/docs/V2-DESIGN.md CHANGED
@@ -1602,6 +1602,8 @@ requirement: personal data never touches third-party services). Benchmarks to tr
1602
1602
 
1603
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
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
+ - **D81 — user-defined alias vocabulary as query-time expansion (2026-10-07).** Stone: an alias should be a *kind of memory* — defined once, remembered forever. Any memory type may carry `alias:` (the nickname, e.g. 香蕉计划) and `target:` (what it refers to, e.g. 支付系统重构项目) frontmatter; the alias memory defines vocabulary once per scope. At query time (`src/retrieve/alias-memory.ts`), query terms are looked up against the alias registry — one cheap `SELECT` per `search()` call over rows with non-empty alias+target (trigger A: exact registry hit, never a count heuristic; the D79 0/470 trigger is not repeated). On a hit the query is rewritten with the other side of the relation — bidirectional (alias→target and target→alias), depth < 3, cycle-safe — and re-run as round 2 (round 3 for chains). Rounds merge by normalized-max (`mergeRounds`): each round's BM25 scores are divided by that round's best (raw cross-round scores are incomparable — °C vs °F), then final = max, so an alias-side champion competes evenly with a literal-side champion — alias hits are never down-weighted for coming from a later round. Merged pool is re-ranked then cut to [1.5K, 2K] (Stone) — never back to K, or round-2 hits would be starved the way D79's slice-to-K starved them. No reindex when an alias changes (query-time). Scope-aware: a project codename stays in its project. *Rationale: D80's curated map is static and shared — personal vocabulary (小宝贝, 香蕉计划) doesn't belong in it; per-memory D61 aliases don't propagate to new memories. Alias memories close the gap: user-defined, scope-aware, effective immediately.* v1: user-explicit creation only (`memory_add` alias/target params, CLI `--alias`/`--target`, frontmatter edit); agent-proposed aliases deferred. D81 also rewords the init `captureAliases` prompt (it is not the synonym switch — D80's expansion always applies). Eval: 3 new fixture queries (香蕉计划↔支付系统重构项目 both directions + the alias memory itself), 44 queries total: recall@1 0.750, recall@5 **0.977** (43/44), MRR 0.857 — the single miss remains `how do we ship` (known D80 trade-off).
1606
+
1605
1607
 
1606
1608
  ## Open Questions
1607
1609
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-memex",
3
- "version": "0.8.0-alpha.2",
3
+ "version": "0.8.0",
4
4
  "mcpName": "io.github.stoneskin/open-memex",
5
5
  "description": "Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.",
6
6
  "keywords": [
@@ -147,7 +147,7 @@ function rankOf(
147
147
  q: LMEItem,
148
148
  isGold: (id: string) => boolean,
149
149
  ): number {
150
- const stats = { ftsQuery: "", candidates: 0, hiddenSuperseded: 0, hiddenExcluded: 0 };
150
+ const stats = { ftsQuery: "", candidates: 0, hiddenSuperseded: 0, hiddenExcluded: 0, aliasExpansions: [] as string[] };
151
151
  const hits = search(q.question, {
152
152
  scopeKeys: ["personal"],
153
153
  limit: K,
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Synthetic retrieval-eval fixture (D77).
3
3
  *
4
- * 48 personal-scope memories + 36 queries with gold ids. Written BEFORE
4
+ * 54 personal-scope memories + 44 queries with gold ids. Written BEFORE
5
5
  * running the eval — the fixture is the test, not tuned to the results.
6
6
  * All memories share one scope/tier so the measured ranking is pure BM25.
7
7
  * Mixed EN/ZH; includes paraphrase and synonym queries (the hard cases
8
- * from the retrieval-robustness question) and near-duplicate distractors.
8
+ * from the retrieval-robustness question), near-duplicate distractors,
9
+ * and D81 user-defined alias vocabulary (bidirectional query-time
10
+ * expansion: 香蕉计划 ↔ 支付系统重构项目).
9
11
  */
10
12
 
11
13
  export interface FixtureMemory {
@@ -13,6 +15,9 @@ export interface FixtureMemory {
13
15
  type: string;
14
16
  tags: string[];
15
17
  aliases?: string[];
18
+ /** D81: user-defined alias vocabulary (nickname → referent). */
19
+ alias?: string;
20
+ target?: string;
16
21
  body: string;
17
22
  }
18
23
 
@@ -103,6 +108,11 @@ export const MEMORIES: FixtureMemory[] = [
103
108
  { id: "mem-049", type: "fact", tags: ["备份", "数据库"], body: "数据库备份每天凌晨两点执行,全量保留三十天,恢复演练每个季度做一次。" },
104
109
  { id: "mem-050", type: "decision", tags: ["发布", "测试"], body: "上线前必须跑完全量回归测试,测试报告要贴到发布群里,TL 确认后才能发。" },
105
110
  { id: "mem-051", type: "gotcha", tags: ["staging"], body: "The staging environment wipes itself every night at midnight. Never leave test data there overnight — it will be gone by morning." },
111
+
112
+ // ---- D81 user-defined alias vocabulary (query-time expansion) ----
113
+ { id: "mem-052", type: "fact", tags: ["alias"], alias: "香蕉计划", target: "支付系统重构项目", body: "香蕉计划是支付系统重构项目的内部代号,团队日常用香蕉计划指代这个项目。" },
114
+ { id: "mem-053", type: "decision", tags: ["backend"], body: "支付系统重构项目决定用 Go 重写网关层,Q4 启动。" },
115
+ { id: "mem-054", type: "fact", tags: ["planning"], body: "香蕉计划下周一启动评审,材料周五前提交。" },
106
116
  ];
107
117
 
108
118
  export const QUERIES: FixtureQuery[] = [
@@ -164,4 +174,9 @@ export const QUERIES: FixtureQuery[] = [
164
174
  { query: "how do we ship", gold: ["mem-001"], note: "synonym-only: ship→deploy/release" },
165
175
  { query: "pager schedule", gold: ["mem-042"], note: "synonym-only: pager→oncall" },
166
176
  { query: "my token got exposed", gold: ["mem-017"], note: "synonym-only: token→secret" },
177
+
178
+ // ---- D81 alias-memory queries (bidirectional query-time expansion) ----
179
+ { query: "香蕉计划用什么语言重写网关", gold: ["mem-053"], note: "alias→target: round 2 must find mem-053 via 支付系统重构项目" },
180
+ { query: "支付系统重构项目什么时候启动评审", gold: ["mem-054"], note: "target→alias: round 2 must find mem-054 via 香蕉计划" },
181
+ { query: "香蕉计划是哪个项目的代号", gold: ["mem-052"], note: "alias memory itself, round 1" },
167
182
  ];
@@ -37,6 +37,8 @@ for (const m of MEMORIES) {
37
37
  type: m.type,
38
38
  tags: m.tags,
39
39
  aliases: m.aliases,
40
+ alias: m.alias,
41
+ target: m.target,
40
42
  source: "retrieval-eval fixture",
41
43
  });
42
44
  fs.writeFileSync(path.join(dir, `${m.id}.md`), serialize(fm, m.body), "utf8");
@@ -51,7 +53,7 @@ let mrr = 0;
51
53
  const misses: string[] = [];
52
54
 
53
55
  for (const q of QUERIES) {
54
- const stats = { ftsQuery: "", candidates: 0, hiddenSuperseded: 0, hiddenExcluded: 0 };
56
+ const stats = { ftsQuery: "", candidates: 0, hiddenSuperseded: 0, hiddenExcluded: 0, aliasExpansions: [] as string[] };
55
57
  const hits = search(q.query, { scopeKeys: ["personal"], limit: 50, stats });
56
58
  const ids = hits.map((h) => h.id);
57
59
  let rank = -1;
@@ -9,6 +9,12 @@ import { resolveProjectScope, resolveCwdScope, pickScopeRoot, PERSONAL_SCOPE } f
9
9
  import { cjkIndexText, cjkQueryExpr, hasCjk } from "../src/retrieve/cjk.ts";
10
10
  import { toFtsQuery } from "../src/retrieve/query.ts";
11
11
  import { expansionVariantsForDoc, MAX_AUTO_VARIANTS } from "../src/retrieve/synonyms.ts";
12
+ import {
13
+ planAliasExpansions,
14
+ rewriteQueryOnce,
15
+ mergeRounds,
16
+ type AliasEntry,
17
+ } from "../src/retrieve/alias-memory.ts";
12
18
  import { contentHash, similarity, NEAR_DUP_THRESHOLD } from "../src/store/lifecycle.ts";
13
19
  import {
14
20
  MIN_NODE_VERSION,
@@ -894,5 +900,54 @@ console.log("== D80 index-time expansion ==");
894
900
  ok("no curated terms → no variants", expansionVariantsForDoc("hello world").length === 0);
895
901
  }
896
902
 
903
+ console.log("== D81 alias-memory query-time expansion ==");
904
+ {
905
+ const entries: AliasEntry[] = [
906
+ { id: "a1", scope_key: "personal", alias: "香蕉计划", target: "支付系统重构项目" },
907
+ { id: "a2", scope_key: "personal", alias: "小宝贝", target: "AI agent" },
908
+ ];
909
+ // alias → target.
910
+ const r1 = rewriteQueryOnce("香蕉计划进展如何", entries);
911
+ ok("alias rewrites to target", r1 === "支付系统重构项目进展如何", String(r1));
912
+ // target → alias (bidirectional).
913
+ const r2 = rewriteQueryOnce("支付系统重构项目什么时候评审", entries);
914
+ ok("target rewrites to alias", r2 === "香蕉计划什么时候评审", String(r2));
915
+ // Latin token match (whole token, not substring).
916
+ const r3 = rewriteQueryOnce("ask 小宝贝 for help", entries);
917
+ ok("latin alias rewrites", r3 === "ask AI agent for help", String(r3));
918
+ ok("latin substring does not match", rewriteQueryOnce("association meeting", [{ id: "x", scope_key: "p", alias: "sso", target: "单点登录" }]) === null);
919
+ // No match → null.
920
+ ok("no alias term → null", rewriteQueryOnce("今天天气不错", entries) === null);
921
+ // Depth < 3: at most two expansion rounds.
922
+ const chained: AliasEntry[] = [
923
+ { id: "c1", scope_key: "p", alias: "alpha", target: "beta" },
924
+ { id: "c2", scope_key: "p", alias: "beta", target: "gamma" },
925
+ { id: "c3", scope_key: "p", alias: "gamma", target: "delta" },
926
+ ];
927
+ const plan = planAliasExpansions("alpha status", chained);
928
+ ok("depth capped below 3", plan.length === 2, JSON.stringify(plan));
929
+ // Cycle-safe: alpha↔beta oscillates back to the seen query and stops.
930
+ const cyclic: AliasEntry[] = [
931
+ { id: "x1", scope_key: "p", alias: "alpha", target: "beta" },
932
+ { id: "x2", scope_key: "p", alias: "beta", target: "alpha" },
933
+ ];
934
+ ok("cycle stops", planAliasExpansions("alpha status", cyclic).length <= 1);
935
+
936
+ // Normalized-max merge: an alias-side champion (n=1.0) outranks a mediocre
937
+ // literal match (n=0.4) — no down-weighting for being from a later round.
938
+ const merged = mergeRounds([
939
+ [{ id: "lit-best", score: 10 }, { id: "lit-mid", score: 4 }],
940
+ [{ id: "alias-best", score: 5 }, { id: "lit-best", score: 2 }],
941
+ ]);
942
+ const order = merged.map((m) => m.doc.id);
943
+ ok("alias champion ties literal champion", merged[0].score === 1 && merged[1].score === 1, JSON.stringify(merged.map((m) => [m.doc.id, m.score]))),
944
+ ok("multi-round doc wins ties", order[0] === "lit-best", order.join(","));
945
+ ok("alias-only doc keeps full weight", order[1] === "alias-best", order.join(","));
946
+ ok("mediocre literal ranks last", order[2] === "lit-mid", order.join(","));
947
+ // Single round degrades to plain ordering.
948
+ const single = mergeRounds([[{ id: "a", score: 3 }, { id: "b", score: 9 }]]);
949
+ ok("single round orders by score", single[0].doc.id === "b" && single[0].score === 1, JSON.stringify(single.map((m) => [m.doc.id, m.score])));
950
+ }
951
+
897
952
  console.log(fails === 0 ? "\nALL PASS" : `\n${fails} FAILURES`);
898
953
  process.exit(fails === 0 ? 0 : 1);
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.stoneskin/open-memex",
4
4
  "title": "open-memex",
5
5
  "description": "Local-first memory for AI coding agents — Markdown + SQLite FTS5, shared across editors via MCP.",
6
- "version": "0.7.3",
6
+ "version": "0.8.0",
7
7
  "repository": {
8
8
  "url": "https://github.com/stoneskin/open-memex",
9
9
  "source": "github"
@@ -13,7 +13,7 @@
13
13
  {
14
14
  "registryType": "npm",
15
15
  "identifier": "open-memex",
16
- "version": "0.7.3",
16
+ "version": "0.8.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {
package/src/cli.ts CHANGED
@@ -124,7 +124,7 @@ Example:
124
124
 
125
125
  add: `Save a fact, preference, decision, or note to local memory.
126
126
 
127
- Usage: open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2] [--aliases "a;b"]
127
+ Usage: open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2] [--aliases "a;b"] [--alias NICK --target REFERENT]
128
128
 
129
129
  Flags:
130
130
  --scope project (default) or personal (personal never leaves this machine)
@@ -133,8 +133,12 @@ Flags:
133
133
  --aliases alternate phrasings of this fact (up to 4), indexed with the
134
134
  memory so differently-worded searches still match; separate
135
135
  with ; (commas also accepted — use ; when an alias itself
136
- contains one). Stored only when capture aliases are enabled
137
- (init default)
136
+ contains one). Always on.
137
+ --alias --target D81: define user vocabulary once — e.g. --alias 香蕉计划
138
+ --target 支付系统重构项目. Later queries mentioning either side
139
+ trigger a second search round on the other side (both
140
+ directions), scoped like the memory itself. Not gated by
141
+ capture aliases.
138
142
 
139
143
  Example:
140
144
  open-memex add "We deploy on Fridays" --scope project --tag process`,
@@ -1008,7 +1012,7 @@ async function main() {
1008
1012
  : [project.key, PERSONAL_SCOPE.key];
1009
1013
  const explain = flags.explain === "true";
1010
1014
  const stats = explain
1011
- ? { ftsQuery: "", candidates: 0, hiddenSuperseded: 0, hiddenExcluded: 0 }
1015
+ ? { ftsQuery: "", candidates: 0, hiddenSuperseded: 0, hiddenExcluded: 0, aliasExpansions: [] as string[] }
1012
1016
  : undefined;
1013
1017
  const hits = search(query, {
1014
1018
  scopeKeys: keys,
@@ -1021,6 +1025,9 @@ async function main() {
1021
1025
  console.log(
1022
1026
  `candidates: ${stats.candidates}, hidden by lifecycle: ${stats.hiddenSuperseded} superseded, ${stats.hiddenExcluded} retracted/archived`,
1023
1027
  );
1028
+ if (stats.aliasExpansions.length > 0) {
1029
+ console.log(`alias rounds: ${stats.aliasExpansions.map((q) => `"${q}"`).join(" → ")}`);
1030
+ }
1024
1031
  }
1025
1032
  if (hits.length === 0) {
1026
1033
  console.log("(no matches)");
@@ -1083,6 +1090,14 @@ async function main() {
1083
1090
  ),
1084
1091
  }
1085
1092
  : {}),
1093
+ // D81: --alias/--target define user vocabulary (not gated by capture
1094
+ // aliases — this is deliberate, like typing the words yourself).
1095
+ ...(typeof flags.alias === "string" && flags.alias !== "true" && flags.alias.trim()
1096
+ ? { alias: redact(flags.alias.trim(), cfg.redactPatterns).content }
1097
+ : {}),
1098
+ ...(typeof flags.target === "string" && flags.target !== "true" && flags.target.trim()
1099
+ ? { target: redact(flags.target.trim(), cfg.redactPatterns).content }
1100
+ : {}),
1086
1101
  source: "cli",
1087
1102
  created_at: rfc,
1088
1103
  updated_at: rfc,
package/src/init.ts CHANGED
@@ -893,26 +893,20 @@ export async function initProject(opts: {
893
893
  // Install-time settings (D19). Non-default answers persist to the JSONC
894
894
  // config file; `open-memex config set` changes them later.
895
895
  const patch: Record<string, unknown> = {};
896
- const keywordCaptureEnabled = await askBool(
897
- "Auto-capture keywords like remember… / note that… into memory?",
898
- DEFAULT_CONFIG.keywordCaptureEnabled,
899
- );
900
- if (keywordCaptureEnabled !== DEFAULT_CONFIG.keywordCaptureEnabled)
901
- patch.keywordCaptureEnabled = keywordCaptureEnabled;
896
+ // Keyword auto-capture is always on (Stone 2026-10-07): no init
897
+ // question — there is no reason to turn it off. The
898
+ // `keywordCaptureEnabled` config key is still honored if set manually.
902
899
  const injectOnFirstTurn = await askBool(
903
900
  "Inject relevant memories when a session starts?",
904
901
  DEFAULT_CONFIG.injectOnFirstTurn,
905
902
  );
906
903
  if (injectOnFirstTurn !== DEFAULT_CONFIG.injectOnFirstTurn)
907
904
  patch.injectOnFirstTurn = injectOnFirstTurn;
908
- // D61: capture-time aliases — agents attach alternate phrasings to each
909
- // memory so differently-worded questions still find it.
910
- const captureAliases = await askBool(
911
- "Attach alternate phrasings (aliases) to memories so reworded questions still find them?",
912
- DEFAULT_CONFIG.captureAliases,
913
- );
914
- if (captureAliases !== DEFAULT_CONFIG.captureAliases)
915
- patch.captureAliases = captureAliases;
905
+ // D61 capture-time aliases are always on (Stone 2026-10-07): the agent
906
+ // may always attach its own alternate phrasings — no init question.
907
+ // (Distinct from D80's automatic index-time synonym expansion, which
908
+ // also always applies.) The `captureAliases` config key is still
909
+ // honored if someone sets it manually; init just never asks anymore.
916
910
  if (Object.keys(patch).length > 0) {
917
911
  const file = saveConfig(patch);
918
912
  console.log(
@@ -0,0 +1,154 @@
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.ts";
26
+ import { latinTerms } from "./query.ts";
27
+
28
+ export interface AliasEntry {
29
+ id: string;
30
+ scope_key: string;
31
+ alias: string;
32
+ target: string;
33
+ }
34
+
35
+ /**
36
+ * All alias definitions visible to these scopes. One cheap query per
37
+ * search() call — no cache, so a newly saved alias memory applies on the
38
+ * very next query.
39
+ */
40
+ export function loadAliasEntries(scopeKeys?: string[]): AliasEntry[] {
41
+ const filter =
42
+ scopeKeys && scopeKeys.length > 0
43
+ ? `AND scope_key IN (${scopeKeys.map(() => "?").join(",")})`
44
+ : "";
45
+ return db()
46
+ .prepare(
47
+ `SELECT id, scope_key, alias, target FROM memories
48
+ WHERE alias <> '' AND target <> '' AND status = 'active' ${filter}`,
49
+ )
50
+ .all(...((scopeKeys ?? []) as unknown[])) as AliasEntry[];
51
+ }
52
+
53
+ const CJK_RE = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/;
54
+
55
+ function escapeRegExp(s: string): string {
56
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
57
+ }
58
+
59
+ /** CJK: substring (no tokenizer). Latin: whole-token match. */
60
+ function termInQuery(query: string, term: string): boolean {
61
+ if (CJK_RE.test(term)) return query.includes(term);
62
+ const tokens = new Set(latinTerms(query));
63
+ return tokens.has(term.toLowerCase());
64
+ }
65
+
66
+ function replaceTerm(query: string, term: string, replacement: string): string {
67
+ if (CJK_RE.test(term)) return query.replace(term, replacement);
68
+ return query.replace(
69
+ new RegExp(`\\b${escapeRegExp(term)}\\b`, "i"),
70
+ replacement,
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Rewrite the query once: every alias entry with a matching side swaps in
76
+ * the other side. All matching entries apply in one pass (a chain A→B→C
77
+ * can resolve within a single round). Returns null when nothing matched.
78
+ */
79
+ export function rewriteQueryOnce(
80
+ query: string,
81
+ entries: AliasEntry[],
82
+ ): string | null {
83
+ let out = query;
84
+ let changed = false;
85
+ for (const e of entries) {
86
+ if (termInQuery(out, e.alias)) {
87
+ out = replaceTerm(out, e.alias, e.target);
88
+ changed = true;
89
+ } else if (termInQuery(out, e.target)) {
90
+ out = replaceTerm(out, e.target, e.alias);
91
+ changed = true;
92
+ }
93
+ }
94
+ return changed ? out : null;
95
+ }
96
+
97
+ /**
98
+ * Plan the expansion rounds for a query. Pure — takes the registry entries
99
+ * explicitly so tests don't need a database. Returns the rewritten queries
100
+ * only (rounds 2+); the caller prepends the original query. Depth < 3:
101
+ * at most two expansion rounds. Cycle-safe via the seen set.
102
+ */
103
+ export function planAliasExpansions(
104
+ query: string,
105
+ entries: AliasEntry[],
106
+ ): string[] {
107
+ const out: string[] = [];
108
+ const seen = new Set([query]);
109
+ let current = query;
110
+ for (let depth = 0; depth < 2; depth++) {
111
+ const next = rewriteQueryOnce(current, entries);
112
+ if (!next || seen.has(next)) break;
113
+ seen.add(next);
114
+ out.push(next);
115
+ current = next;
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /**
121
+ * D81 merge: per-round score normalization + max.
122
+ *
123
+ * BM25 scores are query-dependent (different terms → different IDFs and
124
+ * query lengths), so raw scores from two rounds are incomparable — like
125
+ * comparing °C with °F. Each round is first divided by its own best
126
+ * (n=1.0 means "this round's champion"), then final = max across rounds.
127
+ * An alias-side champion (n=1.0) outranks a mediocre literal match
128
+ * (n=0.4): the two sides' champions start even, which is exactly "no
129
+ * down-weighting for being a synonym/alias". The kept row (and its
130
+ * snippet) comes from the round where the doc scored its best.
131
+ */
132
+ export function mergeRounds<T extends { id: string; score: number }>(
133
+ rounds: T[][],
134
+ ): Array<{ doc: T; score: number; rounds: number }> {
135
+ const acc = new Map<string, { doc: T; best: number; rounds: number }>();
136
+ rounds.forEach((docs) => {
137
+ const mx = docs.reduce((m, d) => Math.max(m, d.score), 0);
138
+ for (const d of docs) {
139
+ const n = mx > 0 ? d.score / mx : 0;
140
+ const cur = acc.get(d.id);
141
+ if (!cur) acc.set(d.id, { doc: d, best: n, rounds: 1 });
142
+ else {
143
+ cur.rounds += 1;
144
+ if (n > cur.best) {
145
+ cur.best = n;
146
+ cur.doc = d;
147
+ }
148
+ }
149
+ }
150
+ });
151
+ return [...acc.values()]
152
+ .map((v) => ({ doc: v.doc, score: v.best, rounds: v.rounds }))
153
+ .sort((a, b) => b.score - a.score || b.rounds - a.rounds);
154
+ }
@@ -32,7 +32,7 @@ const CJK_QUESTION_BIGRAMS = new Set([
32
32
 
33
33
  const MAX_TERMS = 24;
34
34
 
35
- function latinTerms(q: string, useStopwords = true): string[] {
35
+ export function latinTerms(q: string, useStopwords = true): string[] {
36
36
  const raw = q.toLowerCase().match(/[a-z0-9_.\-]+/g) ?? [];
37
37
  const kept = useStopwords
38
38
  ? raw.filter((t) => t.length > 1 && !LATIN_STOPWORDS.has(t))
@@ -1,5 +1,10 @@
1
1
  import { db } from "../store/db.ts";
2
2
  import { toFtsQuery } from "./query.ts";
3
+ import {
4
+ loadAliasEntries,
5
+ planAliasExpansions,
6
+ mergeRounds,
7
+ } from "./alias-memory.ts";
3
8
 
4
9
  export interface SearchHit {
5
10
  id: string;
@@ -88,6 +93,8 @@ export interface SearchStats {
88
93
  candidates: number;
89
94
  hiddenSuperseded: number;
90
95
  hiddenExcluded: number;
96
+ /** D81: query-time alias expansions actually run (rounds 2+, raw text). */
97
+ aliasExpansions: string[];
91
98
  }
92
99
 
93
100
  /**
@@ -176,6 +183,7 @@ export function search(
176
183
  opts.stats.candidates = 0;
177
184
  opts.stats.hiddenSuperseded = 0;
178
185
  opts.stats.hiddenExcluded = 0;
186
+ opts.stats.aliasExpansions = [];
179
187
  }
180
188
  if (!q) return [];
181
189
  const limit = Math.max(1, Math.min(opts.limit ?? 8, 50));
@@ -229,11 +237,38 @@ export function search(
229
237
 
230
238
  const raw: RawRow[] = fetchFts(q);
231
239
 
232
- // D80: vocabulary mismatch is solved at index time (synonyms.ts expands
233
- // curated variants into the aliases column at sync time), so the query
234
- // side is a single FTS round — no trigger, no second chance.
235
- if (opts.stats) opts.stats.candidates = raw.length;
236
- return resolveVisible(raw, limit, opts.stats);
240
+ // D81: user-defined alias vocabulary — query-time expansion rounds.
241
+ // Round 1 is the query as-is; each further round rewrites alias terms to
242
+ // the other side of the relation (bidirectional, depth < 3). The trigger
243
+ // is an exact registry hit (loadAliasEntries), not a count heuristic.
244
+ const aliasEntries = loadAliasEntries(opts.scopeKeys);
245
+ const expansions = planAliasExpansions(query, aliasEntries);
246
+ let merged: RawRow[] = raw;
247
+ let finalLimit = limit;
248
+ if (expansions.length > 0) {
249
+ const rounds: RawRow[][] = [raw];
250
+ for (const eq of expansions) {
251
+ const mq = toFtsQuery(eq, qflags);
252
+ if (mq) rounds.push(fetchFts(mq));
253
+ }
254
+ // Normalized-max merge (alias-memory.ts): each round's scores are
255
+ // divided by that round's best, so an alias-side champion competes
256
+ // evenly with a literal-side champion — no down-weighting for being
257
+ // from a later round.
258
+ merged = mergeRounds(rounds).map((m) => ({ ...m.doc, score: m.score }));
259
+ // Cut to [1.5K, 2K] — never back to K, or round-2 hits would be
260
+ // starved the way D79's slice-to-K starved them.
261
+ const pool = merged.length;
262
+ finalLimit = Math.min(pool, 2 * limit);
263
+ finalLimit = Math.max(finalLimit, Math.min(pool, Math.ceil(1.5 * limit)));
264
+ if (opts.stats) opts.stats.aliasExpansions = expansions;
265
+ }
266
+
267
+ // D80: the curated static map is solved at index time (synonyms.ts
268
+ // expands variants into the aliases column at sync time), so without a
269
+ // user alias the query side stays a single FTS round.
270
+ if (opts.stats) opts.stats.candidates = merged.length;
271
+ return resolveVisible(merged, finalLimit, opts.stats);
237
272
  }
238
273
 
239
274
  export function list(
package/src/store/db.ts CHANGED
@@ -48,6 +48,8 @@ CREATE TABLE IF NOT EXISTS memories (
48
48
  status TEXT NOT NULL DEFAULT 'active',
49
49
  tags TEXT NOT NULL DEFAULT '',
50
50
  aliases TEXT NOT NULL DEFAULT '',
51
+ alias TEXT NOT NULL DEFAULT '',
52
+ target TEXT NOT NULL DEFAULT '',
51
53
  content TEXT NOT NULL,
52
54
  cjk TEXT NOT NULL DEFAULT '',
53
55
  content_hash TEXT NOT NULL DEFAULT '',
@@ -103,7 +105,7 @@ END;
103
105
  `;
104
106
 
105
107
  /** Current index schema version. Bump when TABLE_SCHEMA/FTS_SCHEMA change. */
106
- const SCHEMA_VERSION = 7;
108
+ const SCHEMA_VERSION = 8;
107
109
 
108
110
  function userVersion(d: AnyDatabase): number {
109
111
  const row = d.prepare("PRAGMA user_version").get() as { user_version: number };
@@ -260,6 +260,9 @@ export interface SupersedeInput {
260
260
  type?: string;
261
261
  tags?: string[];
262
262
  aliases?: string[];
263
+ /** D81: undefined = inherit the old memory's pair. */
264
+ alias?: string;
265
+ target?: string;
263
266
  source?: string;
264
267
  }
265
268
 
@@ -288,6 +291,9 @@ export function supersede(
288
291
  tags: input.tags ?? oldMf.fm.tags,
289
292
  // D61: aliases carry forward unless the caller replaces them.
290
293
  aliases: input.aliases ?? oldMf.fm.aliases,
294
+ // D81: alias vocabulary carries forward the same way.
295
+ alias: input.alias ?? oldMf.fm.alias,
296
+ target: input.target ?? oldMf.fm.target,
291
297
  source: input.source ?? oldMf.fm.source,
292
298
  status: "active",
293
299
  created_at: now,
@@ -60,6 +60,17 @@ export interface Frontmatter {
60
60
  * Optional and additive: files without it parse unchanged.
61
61
  */
62
62
  aliases?: string[];
63
+ /**
64
+ * D81: user-defined alias vocabulary — `alias` is the nickname
65
+ * (e.g. 香蕉计划), `target` is what it refers to
66
+ * (e.g. 支付系统重构项目). Unlike D61 per-memory aliases (alternate
67
+ * phrasings of one memory), an alias memory defines vocabulary once and
68
+ * the query-time expansion (retrieve/alias-memory.ts) applies it to every
69
+ * search in its scope — no reindex when the alias changes. Optional;
70
+ * files without it parse unchanged.
71
+ */
72
+ alias?: string;
73
+ target?: string;
63
74
  source: string;
64
75
  created_at: string; // RFC 3339, never bare epoch (§3)
65
76
  updated_at: string; // RFC 3339
@@ -247,6 +258,13 @@ export function normalizeFrontmatter(
247
258
  return aliases.length > 0 ? { aliases } : {};
248
259
  })()
249
260
  : {}),
261
+ // D81: alias vocabulary — single trimmed strings, dropped when empty.
262
+ ...(typeof raw.alias === "string" && raw.alias.trim()
263
+ ? { alias: raw.alias.trim() }
264
+ : {}),
265
+ ...(typeof raw.target === "string" && raw.target.trim()
266
+ ? { target: raw.target.trim() }
267
+ : {}),
250
268
  source: typeof raw.source === "string" ? raw.source : "",
251
269
  created_at: msToRfc3339(timeToMs(raw.created_at)),
252
270
  updated_at: msToRfc3339(timeToMs(raw.updated_at)),
package/src/store/sync.ts CHANGED
@@ -15,8 +15,8 @@ import { projectRoot, paths } from "../paths.ts";
15
15
  import { loadConfig } from "../config.ts";
16
16
 
17
17
  const UPSERT_SQL = `
18
- 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)
19
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
18
+ 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)
19
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
20
20
  ON CONFLICT(id) DO UPDATE SET
21
21
  scope_key = excluded.scope_key,
22
22
  scope = excluded.scope,
@@ -28,6 +28,8 @@ const UPSERT_SQL = `
28
28
  status = excluded.status,
29
29
  tags = excluded.tags,
30
30
  aliases = excluded.aliases,
31
+ alias = excluded.alias,
32
+ target = excluded.target,
31
33
  content = excluded.content,
32
34
  cjk = excluded.cjk,
33
35
  content_hash = excluded.content_hash,
@@ -74,6 +76,7 @@ function writeRow(mf: MemoryFile, mtimeMs: number): void {
74
76
  (a) => !have.has(a.toLowerCase()),
75
77
  );
76
78
  const aliases = [...supplied, ...auto].join(",");
79
+ // D81: user-defined alias vocabulary (single nickname → referent pair).
77
80
  db().prepare(UPSERT_SQL).run(
78
81
  fm.id,
79
82
  fm.scope_key,
@@ -86,6 +89,8 @@ function writeRow(mf: MemoryFile, mtimeMs: number): void {
86
89
  fm.status,
87
90
  tags,
88
91
  aliases,
92
+ fm.alias ?? "",
93
+ fm.target ?? "",
89
94
  body,
90
95
  cjkIndexText(body + "\n" + tags + "\n" + aliases),
91
96
  contentHash(body),