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.
@@ -0,0 +1,184 @@
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
+
28
+ import { LATIN_STOPWORDS } from "./query.ts";
29
+
30
+ type Group = string[];
31
+
32
+ const CJK_RE = /[\u4e00-\u9fff]/;
33
+
34
+ /**
35
+ * Curated equivalence groups. EN dev-domain vocabulary, ZH equivalents,
36
+ * and EN↔ZH translation pairs live in one bidirectional group: any member
37
+ * expands to all the others, in either direction.
38
+ */
39
+ const GROUPS: Group[] = [
40
+ ["deploy", "ship", "release", "rollout", "publish", "部署", "发布", "上线"],
41
+ ["test", "testing", "tests", "测试", "单测", "集成测试"],
42
+ ["regression test", "回归测试"],
43
+ ["rollback", "revert", "回滚", "撤回", "撤销", "回退"],
44
+ ["incident", "outage", "postmortem", "故障", "事故", "宕机"],
45
+ ["auth", "authentication", "login", "sso", "single sign-on", "单点登录", "登录"],
46
+ ["secret", "secrets", "credential", "credentials", "token", "密钥"],
47
+ ["database", "db", "数据库"],
48
+ ["backup", "restore", "备份", "恢复"],
49
+ ["retention", "保留"],
50
+ ["production", "生产", "生产环境"],
51
+ ["staging", "预发布环境", "预发布"],
52
+ ["wipe", "wipes", "清空", "清除"],
53
+ ["nightly", "night", "每晚"],
54
+ ["thursday", "周四", "星期四"],
55
+ ["mandatory", "强制", "必须"],
56
+ ["flaky", "flake", "unreliable", "intermittent"],
57
+ ["bug", "defect", "issue"],
58
+ ["ci", "pipeline"],
59
+ ["cache", "caching", "cached"],
60
+ ["invalidate", "invalidation", "expire", "expiry"],
61
+ ["oncall", "on-call", "pager"],
62
+ ["migration", "migrate", "migrating"],
63
+ ["index", "indexing", "indexed"],
64
+ ["slow", "latency", "performance", "perf"],
65
+ ["refactor", "refactoring"],
66
+ ["merge", "merging"],
67
+ ["branch", "branches"],
68
+ ["commit", "commits"],
69
+ ["review", "code review", "评审", "代码评审"],
70
+ ["mock", "stub"],
71
+ ["debug", "debugging", "troubleshoot", "troubleshooting"],
72
+ ["log", "logs", "logging"],
73
+ ["monitor", "monitoring", "alert", "alerting"],
74
+ ["scale", "scaling"],
75
+ ["timeout", "timeouts"],
76
+ ["retry", "retries"],
77
+ ["queue", "job", "worker"],
78
+ ["api", "endpoint"],
79
+ ["docker", "container", "image"],
80
+ ["kubernetes", "k8s"],
81
+ ["env", "environment"],
82
+ ["config", "configuration", "setting", "settings"],
83
+ ["hotfix", "hot fix"],
84
+ ["estimate", "estimation"],
85
+ ["乱码", "编码问题"],
86
+ ["并发", "并行"],
87
+ ["重构"],
88
+ ["规范", "约定"],
89
+ ];
90
+
91
+ const termLookup = new Map<string, string[]>();
92
+ const phraseEntries: Array<{ phrase: string; siblings: string[] }> = [];
93
+ const cjkEntries: Array<{ keys: string[]; siblings: string[] }> = [];
94
+
95
+ for (const g of GROUPS) {
96
+ const cjkKeys = g.filter((m) => CJK_RE.test(m));
97
+ if (cjkKeys.length > 0) cjkEntries.push({ keys: cjkKeys, siblings: [...g] });
98
+ for (const m of g) {
99
+ if (CJK_RE.test(m)) continue;
100
+ const siblings = g.filter((x) => x !== m);
101
+ if (m.includes(" ")) phraseEntries.push({ phrase: m.toLowerCase(), siblings });
102
+ else termLookup.set(m.toLowerCase(), siblings);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Synonym variants for one lowercased Latin term (excluding the term
108
+ * itself). Empty array when the term has no curated synonyms.
109
+ */
110
+ export function synonymVariants(term: string): string[] {
111
+ return termLookup.get(term.toLowerCase()) ?? [];
112
+ }
113
+
114
+ /** Cap on auto-generated variants per memory — bounds index growth. */
115
+ export const MAX_AUTO_VARIANTS = 32;
116
+
117
+ /**
118
+ * Index-time expansion for one memory's text (body + tags). Keyword-
119
+ * centric (Stone): extract content terms, expand each through the
120
+ * curated map, return variants not already present in the text.
121
+ * Pure logic — safe to unit test, runs once per memory at sync time.
122
+ */
123
+ export function expansionVariantsForDoc(text: string): string[] {
124
+ const lower = text.toLowerCase();
125
+ // Content terms only: function words carry no signal (same filter the
126
+ // query builder applies, so both sides agree on what a keyword is).
127
+ const latinTokens = new Set(
128
+ (lower.match(/[a-z0-9_.\-]+/g) ?? []).filter(
129
+ (t) => t.length > 1 && !LATIN_STOPWORDS.has(t),
130
+ ),
131
+ );
132
+ const out: string[] = [];
133
+ const seen = new Set<string>();
134
+ const push = (v: string) => {
135
+ const key = v.toLowerCase();
136
+ if (seen.has(key) || out.length >= MAX_AUTO_VARIANTS) return;
137
+ seen.add(key);
138
+ out.push(v);
139
+ };
140
+ // A variant is "already present" when the doc has it as a token (Latin),
141
+ // as a substring (Latin phrase), or as a substring (CJK — no tokenizer).
142
+ const present = (v: string): boolean =>
143
+ CJK_RE.test(v)
144
+ ? text.includes(v)
145
+ : v.includes(" ")
146
+ ? lower.includes(v.toLowerCase())
147
+ : latinTokens.has(v.toLowerCase());
148
+
149
+ for (const t of latinTokens) {
150
+ for (const v of termLookup.get(t) ?? []) if (!present(v)) push(v);
151
+ }
152
+ for (const { phrase, siblings } of phraseEntries) {
153
+ if (!lower.includes(phrase)) continue;
154
+ for (const v of siblings) if (!present(v)) push(v);
155
+ }
156
+ for (const { keys, siblings } of cjkEntries) {
157
+ if (!keys.some((k) => text.includes(k))) continue;
158
+ for (const v of siblings) if (!present(v)) push(v);
159
+ }
160
+ return out;
161
+ }
162
+
163
+ /**
164
+ * Expand a raw query string with synonym variants (legacy query-time
165
+ * helper, kept for bench scripts). The product no longer expands at
166
+ * query time — D80 moved expansion to the index.
167
+ */
168
+ export function expandQueryWithSynonyms(q: string): string {
169
+ const extra = new Set<string>();
170
+ const latinTerms = q.toLowerCase().match(/[a-z0-9_.\-]+/g) ?? [];
171
+ for (const t of latinTerms) {
172
+ for (const s of synonymVariants(t)) extra.add(s);
173
+ }
174
+ for (const { keys, siblings } of cjkEntries) {
175
+ if (keys.some((k) => q.includes(k))) {
176
+ for (const s of siblings) extra.add(s);
177
+ }
178
+ }
179
+ // Don't re-add terms already in the query.
180
+ const have = new Set(latinTerms);
181
+ const additions = [...extra].filter((s) => !have.has(s.toLowerCase()) && !q.includes(s));
182
+ if (additions.length === 0) return q;
183
+ return `${q} ${additions.join(" ")}`;
184
+ }
package/src/store/sync.ts CHANGED
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { db } from "./db.ts";
4
4
  import { cjkIndexText } from "../retrieve/cjk.ts";
5
+ import { expansionVariantsForDoc } from "../retrieve/synonyms.ts";
5
6
  import { contentHash, repairChain } from "./lifecycle.ts";
6
7
  import {
7
8
  iterMemoryFiles,
@@ -63,7 +64,16 @@ export function upsertFromFile(mf: MemoryFile): void {
63
64
  function writeRow(mf: MemoryFile, mtimeMs: number): void {
64
65
  const { fm, body, filePath } = mf;
65
66
  const tags = (fm.tags ?? []).join(",");
66
- const aliases = (fm.aliases ?? []).join(",");
67
+ // D80 index-time expansion: curated synonym/translation variants for the
68
+ // memory's keywords are indexed in the aliases column (and therefore the
69
+ // CJK bigram column below), so any related word matches in round one.
70
+ // Derived at sync time — the Markdown file stays the source of truth.
71
+ const supplied = fm.aliases ?? [];
72
+ const have = new Set(supplied.map((a) => a.toLowerCase()));
73
+ const auto = expansionVariantsForDoc(body + "\n" + tags).filter(
74
+ (a) => !have.has(a.toLowerCase()),
75
+ );
76
+ const aliases = [...supplied, ...auto].join(",");
67
77
  db().prepare(UPSERT_SQL).run(
68
78
  fm.id,
69
79
  fm.scope_key,