open-memex 0.7.3 → 0.8.0-alpha.3

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,182 @@
1
+ /**
2
+ * Synthetic retrieval-eval fixture (D77).
3
+ *
4
+ * 54 personal-scope memories + 44 queries with gold ids. Written BEFORE
5
+ * running the eval — the fixture is the test, not tuned to the results.
6
+ * All memories share one scope/tier so the measured ranking is pure BM25.
7
+ * Mixed EN/ZH; includes paraphrase and synonym queries (the hard cases
8
+ * from the retrieval-robustness question), near-duplicate distractors,
9
+ * and D81 user-defined alias vocabulary (bidirectional query-time
10
+ * expansion: 香蕉计划 ↔ 支付系统重构项目).
11
+ */
12
+
13
+ export interface FixtureMemory {
14
+ id: string;
15
+ type: string;
16
+ tags: string[];
17
+ aliases?: string[];
18
+ /** D81: user-defined alias vocabulary (nickname → referent). */
19
+ alias?: string;
20
+ target?: string;
21
+ body: string;
22
+ }
23
+
24
+ export interface FixtureQuery {
25
+ query: string;
26
+ gold: string[];
27
+ note: string;
28
+ }
29
+
30
+ export const MEMORIES: FixtureMemory[] = [
31
+ // ---- deploy & CI ----
32
+ { id: "mem-001", type: "decision", tags: ["deploy", "ci"], body: "Deploy to production only from the release branch. Merging to main triggers staging; the release branch is cut from main every Thursday and production deploys only from release/*." },
33
+ { id: "mem-002", type: "gotcha", tags: ["ci", "github-actions"], body: "GitHub Actions macOS runners are 10x the per-minute cost of linux. The e2e job was moved to ubuntu-latest and macOS is kept only for the notarization step." },
34
+ { id: "mem-003", type: "howto", tags: ["deploy", "rollback"], body: "To roll back a production deploy: revert the merge commit on the release branch, push, and the pipeline redeploys the previous artifact. Do not redeploy old artifacts manually from the Actions UI." },
35
+ { id: "mem-004", type: "fact", tags: ["ci"], body: "CI runs lint, typecheck, unit tests, and the smoke suite on every PR. The full e2e suite runs only on the release branch to save minutes." },
36
+ // distractor: shares deploy vocabulary, different topic
37
+ { id: "mem-005", type: "fact", tags: ["deploy"], body: "The deploy dashboard is at deploys.internal.example.com. It shows who deployed what and when, but rollbacks must still go through the release branch revert flow." },
38
+
39
+ // ---- testing ----
40
+ { id: "mem-006", type: "preference", tags: ["testing"], body: "Prefer table-driven tests over copy-pasted cases. One test function, a slice of inputs and expected outputs — much easier to extend than five near-identical test blocks." },
41
+ { id: "mem-007", type: "gotcha", tags: ["testing", "flaky"], body: "The checkout flow test is flaky on CI because it depends on wall-clock timing. It was quarantined with t.Parallel disabled and a retry wrapper; do not unquarantine without fixing the clock dependency." },
42
+ { id: "mem-008", type: "decision", tags: ["testing", "coverage"], body: "Coverage gate is 80% on new code, measured by the CI coverage job. Legacy modules are exempt until they are touched — then the touched lines must be covered." },
43
+
44
+ // ---- editor & tools ----
45
+ { id: "mem-009", type: "preference", tags: ["editor", "vscode"], body: "VS Code: format on save with the workspace prettier config, never the global one. The workspace config lives in .vscode/settings.json and wins over user settings." },
46
+ { id: "mem-010", type: "howto", tags: ["git"], aliases: ["how to undo last commit", "git revert commit"], body: "To undo the last local commit but keep the changes: git reset --soft HEAD~1. To discard everything including working tree: git reset --hard HEAD~1 (destructive, no undo)." },
47
+ { id: "mem-011", type: "fact", tags: ["node"], body: "The repo pins Node 22.14.0 in .nvmrc. CI and the Dockerfile both use it; running 20.x locally causes the better-sqlite3 native binding to fail to load." },
48
+
49
+ // ---- database ----
50
+ { id: "mem-012", type: "gotcha", tags: ["postgres", "migrations"], body: "Postgres migrations must be backward compatible: never drop a column in the same release that stops writing to it. Expand first, migrate, then contract in the next release." },
51
+ { id: "mem-013", type: "decision", tags: ["postgres", "index"], body: "Added a partial index on orders(status) WHERE status = 'pending' after the pending-orders query started sequential-scanning 40M rows. Query time went from 9s to 40ms." },
52
+ { id: "mem-014", type: "howto", tags: ["sqlite"], aliases: ["sqlite busy timeout", "database is locked fix"], body: "SQLite 'database is locked' under concurrent writers: enable WAL mode and set a busy timeout (PRAGMA busy_timeout=5000). Readers never block writers in WAL." },
53
+
54
+ // ---- API design ----
55
+ { id: "mem-015", type: "decision", tags: ["api", "rest"], body: "Public API uses cursor pagination (opaque cursor param), never offset/limit. Offset breaks when rows are inserted concurrently; the cursor is the last seen id." },
56
+ { id: "mem-016", type: "lesson", tags: ["api", "versioning"], body: "Lesson from the v2 migration: versioning the whole API at once was a mistake. Additive changes ship unversioned; only breaking changes get a new version prefix." },
57
+
58
+ // ---- security ----
59
+ { id: "mem-017", type: "constraint", tags: ["security", "secrets"], body: "Never commit secrets. The pre-commit hook runs gitleaks; if a secret lands in history it must be rotated immediately — rewriting history does not unexpose it." },
60
+ { id: "mem-018", type: "howto", tags: ["security", "jwt"], aliases: ["refresh token rotation"], body: "JWT access tokens live 15 minutes; refresh tokens rotate on every use and are bound to the device fingerprint. A reused refresh token invalidates the whole token family." },
61
+
62
+ // ---- performance ----
63
+ { id: "mem-019", type: "gotcha", tags: ["performance", "n+1"], body: "The N+1 in the invoice endpoint came from lazy-loading line items per invoice in a loop. Fixed with a single JOIN fetch; p95 dropped from 2.1s to 180ms." },
64
+ { id: "mem-020", type: "decision", tags: ["performance", "caching"], aliases: ["cache invalidation strategy"], body: "Cache invalidation uses write-through for the product catalog: writes update both DB and cache synchronously. Stale reads were worse than the write latency cost." },
65
+
66
+ // ---- Chinese memories ----
67
+ { id: "mem-021", type: "decision", tags: ["部署", "规范"], body: "生产环境只允许周四发布,紧急 hotfix 需要 TL 在发布群里 @所有人 说明原因。周五不发布,避免周末 oncall。" },
68
+ { id: "mem-022", type: "gotcha", tags: ["微信", "小程序"], body: "微信小程序的 request 并发上限是 10,超过会直接失败不排队。批量上传图片必须自己做并发控制,分批 8 个一组发。" },
69
+ { id: "mem-023", type: "preference", tags: ["代码风格"], body: "中文注释写在代码上方,不要写在行尾。行尾注释在窄屏和 diff 里经常被截断,上方注释可读性更好。" },
70
+ { id: "mem-024", type: "howto", tags: ["git", "中文"], aliases: ["git 中文乱码", "commit message 乱码"], body: "Windows 上 git log 中文乱码:设置 git config --global core.quotepath false,同时把终端编码调成 UTF-8。" },
71
+ { id: "mem-025", type: "fact", tags: ["考勤"], body: "每月 5 号之前提交上月考勤,逾期系统自动锁定找 HR 解锁。弹性工作制但 10 点前要到岗。" },
72
+ // distractor sharing vocab
73
+ { id: "mem-026", type: "fact", tags: ["发布"], body: "发布群是企业微信里的「发版通知」群,CI 成功后机器人会自动推送版本号和 changelog 链接。" },
74
+
75
+ // ---- incidents ----
76
+ { id: "mem-027", type: "lesson", tags: ["incident", "postgres"], body: "Incident 2026-08: the primary ran out of disk because WAL archiving stalled silently. Lesson: alert on archive_command failures, not just disk usage — disk was the symptom." },
77
+ { id: "mem-028", type: "lesson", tags: ["incident", "dns"], body: "Incident 2026-09: DNS TTL of 24h turned a 5-minute failover into a day-long outage. Critical records now use 300s TTL; the long TTL only ever saved pennies." },
78
+
79
+ // ---- architecture decisions ----
80
+ { id: "mem-029", type: "decision", tags: ["architecture", "monorepo"], body: "Monorepo with pnpm workspaces won over polyrepo: cross-package refactors were the dominant pain and CI can already scope builds per changed package." },
81
+ { id: "mem-030", type: "decision", tags: ["architecture", "queue"], body: "Chose SQS over RabbitMQ for the notification pipeline: at-least-once delivery is fine there, and we did not want to operate another stateful service." },
82
+ { id: "mem-031", type: "fact", tags: ["architecture"], body: "The system has three services: api-gateway (edge), core (business logic), worker (async jobs). The worker scales independently on queue depth." },
83
+
84
+ // ---- frontend ----
85
+ { id: "mem-032", type: "gotcha", tags: ["react", "hooks"], body: "React useEffect with an empty dep array still re-runs in StrictMode dev double-invoke. Effects that POST must be idempotent or guarded by a ref — production is fine, dev double-fires." },
86
+ { id: "mem-033", type: "preference", tags: ["css"], body: "Use CSS modules for component styles, global stylesheet only for resets and tokens. The one time we put component CSS global it leaked into the admin panel." },
87
+
88
+ // ---- personal productivity ----
89
+ { id: "mem-034", type: "preference", tags: ["productivity"], body: "Deep work block is 9–11am, no meetings. The calendar auto-declines invites in that window with a note pointing to the team agreement." },
90
+ { id: "mem-035", type: "fact", tags: ["productivity", "standup"], body: "Standup is async in Slack #standup by 10am. Format: yesterday / today / blockers, one line each. No video call unless someone flags a blocker." },
91
+
92
+ // ---- more distractors & variety ----
93
+ { id: "mem-036", type: "fact", tags: ["ci"], body: "The CI badge in the README reflects the main branch only. PR builds show as checks on the PR itself, not on the badge." },
94
+ { id: "mem-037", type: "howto", tags: ["docker"], body: "Multi-stage Docker build: first stage compiles with dev dependencies, final stage copies only dist/ and production node_modules. Image went from 1.2GB to 180MB." },
95
+ { id: "mem-038", type: "gotcha", tags: ["docker", "arm"], body: "Docker builds on Apple Silicon default to arm64; the prod cluster is amd64. Always build with --platform linux/amd64 or the container crashes on deploy with exec format error." },
96
+ { id: "mem-039", type: "decision", tags: ["logging"], body: "Structured JSON logs everywhere, no free-text log lines. The log aggregator parses fields; free text broke the error-rate dashboard twice." },
97
+ { id: "mem-040", type: "preference", tags: ["code-review"], body: "Code review SLA: first review within 4 business hours. If you are blocked on review, ping in #dev — do not merge your own PR except for docs." },
98
+
99
+ // ---- synonyms/paraphrase targets ----
100
+ { id: "mem-041", type: "howto", tags: ["k8s"], aliases: ["kubernetes pod crashloop", "pod 一直重启"], body: "Kubernetes CrashLoopBackOff: check kubectl describe pod for the exit code first, then logs --previous. Nine times out of ten it is a missing env var, not the image." },
101
+ { id: "mem-042", type: "fact", tags: ["oncall"], body: "Oncall rotation is weekly, Monday to Monday. The handoff note must list: active incidents, risky deploys this week, and anything with a TODO owner on vacation." },
102
+ { id: "mem-043", type: "decision", tags: ["auth"], body: "SSO via OIDC is mandatory for all internal tools. Password-only auth was removed after the credential-stuffing scare in March; service accounts use mTLS instead." },
103
+ { id: "mem-044", type: "gotcha", tags: ["timezone"], body: "Store all timestamps in UTC, convert at the display edge. The reporting bug that mixed server-local time with UTC took a week to untangle because both looked plausible." },
104
+ { id: "mem-045", type: "howto", tags: ["ssh"], body: "SSH into prod goes through the bastion with agent forwarding disabled. Copy the key to the bastion instead — forwarding once let a compromised dev box pivot further." },
105
+ { id: "mem-046", type: "preference", tags: ["meetings"], body: "No-meeting Wednesdays for the eng team. Recurring meetings that land on Wednesday get moved, not exempted." },
106
+ { id: "mem-047", type: "fact", tags: ["vpn"], body: "The company VPN auto-disconnects after 12 hours. Long-running remote jobs should run on the dev server inside the network, not over a laptop VPN session." },
107
+ { id: "mem-048", type: "lesson", tags: ["estimation"], body: "Estimates are given as ranges (3–5 days), never single numbers. The single-number estimates were wrong 80% of the time; ranges forced the uncertainty conversation upfront." },
108
+ { id: "mem-049", type: "fact", tags: ["备份", "数据库"], body: "数据库备份每天凌晨两点执行,全量保留三十天,恢复演练每个季度做一次。" },
109
+ { id: "mem-050", type: "decision", tags: ["发布", "测试"], body: "上线前必须跑完全量回归测试,测试报告要贴到发布群里,TL 确认后才能发。" },
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: "香蕉计划下周一启动评审,材料周五前提交。" },
116
+ ];
117
+
118
+ export const QUERIES: FixtureQuery[] = [
119
+ // ---- exact-term queries ----
120
+ { query: "production deploy release branch", gold: ["mem-001"], note: "exact terms" },
121
+ { query: "GitHub Actions macOS runner cost", gold: ["mem-002"], note: "exact terms" },
122
+ { query: "rollback production deploy revert", gold: ["mem-003"], note: "exact terms" },
123
+ { query: "table driven tests", gold: ["mem-006"], note: "exact terms" },
124
+ { query: "partial index pending orders", gold: ["mem-013"], note: "exact terms" },
125
+ { query: "cursor pagination offset", gold: ["mem-015"], note: "exact terms" },
126
+ { query: "WAL archiving disk full", gold: ["mem-027"], note: "exact terms" },
127
+ { query: "JWT refresh token rotation", gold: ["mem-018"], note: "exact terms" },
128
+
129
+ // ---- paraphrase queries (same meaning, different words) ----
130
+ { query: "how do I undo my last commit", gold: ["mem-010"], note: "paraphrase; alias covers it" },
131
+ { query: "sqlite locked concurrent writers", gold: ["mem-014"], note: "paraphrase; alias covers it" },
132
+ { query: "k8s pod keeps restarting", gold: ["mem-041"], note: "paraphrase; alias covers it" },
133
+ { query: "when can we ship to prod", gold: ["mem-001"], note: "paraphrase of deploy rule" },
134
+ { query: "tests that fail randomly on CI", gold: ["mem-007"], note: "paraphrase: flaky" },
135
+ { query: "why did the API versioning go wrong", gold: ["mem-016"], note: "paraphrase of lesson" },
136
+ { query: "slow invoice endpoint fix", gold: ["mem-019"], note: "paraphrase: N+1" },
137
+
138
+ // ---- synonym queries (no shared content words) ----
139
+ { query: "shipping code to production safely", gold: ["mem-001"], note: "synonym: shipping/deploy" },
140
+ { query: "unreliable test quarantine", gold: ["mem-007"], note: "synonym: unreliable/flaky" },
141
+ { query: "database locking issue", gold: ["mem-014"], note: "synonym" },
142
+ { query: "stale data problem cache", gold: ["mem-020"], note: "synonym: stale reads" },
143
+
144
+ // ---- Chinese queries ----
145
+ { query: "周四发布 hotfix 流程", gold: ["mem-021"], note: "CJK exact" },
146
+ { query: "小程序并发限制", gold: ["mem-022"], note: "CJK exact" },
147
+ { query: "git 中文显示乱码", gold: ["mem-024"], note: "CJK; alias covers it" },
148
+ { query: "注释写在哪里比较好", gold: ["mem-023"], note: "CJK paraphrase" },
149
+ { query: "考勤什么时候交", gold: ["mem-025"], note: "CJK paraphrase" },
150
+
151
+ // ---- cross-language (expected hard) ----
152
+ { query: "production release thursday rule", gold: ["mem-021"], note: "EN query, ZH memory — hard" },
153
+ { query: "wechat miniprogram request limit", gold: ["mem-022"], note: "EN query, ZH memory — hard" },
154
+
155
+ // ---- vocabulary-mismatch (translation pairs; the real-world slice LongMemEval-S lacks) ----
156
+ // Written 2026-10-07 BEFORE any index-time expansion or embedding layer exists:
157
+ // each query shares ~zero content words with its gold memory.
158
+ { query: "database backup retention policy", gold: ["mem-049"], note: "EN query, ZH memory — 备份/保留" },
159
+ { query: "full regression test before production release", gold: ["mem-050"], note: "EN query, ZH memory — 回归/上线" },
160
+ { query: "预发布环境数据为什么会丢", gold: ["mem-051"], note: "ZH query, EN memory — staging/wipe" },
161
+ { query: "单点登录强制要求", gold: ["mem-043"], note: "ZH query, EN memory — SSO/mandatory" },
162
+
163
+ // ---- distractor resistance ----
164
+ { query: "where is the deploy dashboard", gold: ["mem-005"], note: "must prefer mem-005 over mem-001/003" },
165
+ { query: "CI badge meaning", gold: ["mem-036"], note: "must prefer mem-036 over mem-004" },
166
+ { query: "docker image too big", gold: ["mem-037"], note: "must prefer mem-037 over mem-038" },
167
+ { query: "container crashes on deploy", gold: ["mem-038"], note: "must prefer mem-038 over mem-037" },
168
+ { query: "standup format", gold: ["mem-035"], note: "exact" },
169
+ { query: "oncall handoff", gold: ["mem-042"], note: "exact" },
170
+ { query: "monorepo or polyrepo", gold: ["mem-029"], note: "exact" },
171
+ { query: "timestamps UTC or local", gold: ["mem-044"], note: "paraphrase" },
172
+
173
+ // ---- synonym-only queries (round 1 sees none of these words; round 2 must catch them) ----
174
+ { query: "how do we ship", gold: ["mem-001"], note: "synonym-only: ship→deploy/release" },
175
+ { query: "pager schedule", gold: ["mem-042"], note: "synonym-only: pager→oncall" },
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" },
182
+ ];
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Retrieval eval (D77, layer 1): offline FTS5 baseline on a synthetic fixture.
3
+ *
4
+ * Usage: node --experimental-strip-types scripts/retrieval-eval.ts
5
+ *
6
+ * Seeds a scratch OPEN_MEMEX_HOME with the fixture memories (all personal
7
+ * scope, so every memory sits in the same review tier and the measured
8
+ * ranking is pure BM25), runs each fixture query through search(), and
9
+ * reports recall@1 / recall@5 / MRR plus a per-query miss table.
10
+ *
11
+ * The fixture (scripts/retrieval-eval/fixture.ts) was written BEFORE the
12
+ * first run. Do not tune the fixture to the results — a miss is a finding,
13
+ * not a bug in the fixture.
14
+ */
15
+ import fs from "node:fs";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+
19
+ const HOME = fs.mkdtempSync(path.join(os.tmpdir(), "retrieval-eval-"));
20
+ process.env.OPEN_MEMEX_HOME = HOME;
21
+
22
+ import { MEMORIES, QUERIES } from "./retrieval-eval/fixture.ts";
23
+ import { serialize, normalizeFrontmatter } from "../src/store/markdown.ts";
24
+ import { syncScope } from "../src/store/sync.ts";
25
+ import { search } from "../src/retrieve/search.ts";
26
+ import { memoriesDirPath } from "../src/paths.ts";
27
+
28
+ const K = 5;
29
+
30
+ // --- seed ---
31
+ const dir = memoriesDirPath("personal");
32
+ fs.mkdirSync(dir, { recursive: true });
33
+ for (const m of MEMORIES) {
34
+ const fm = normalizeFrontmatter({
35
+ id: m.id,
36
+ scope: "personal",
37
+ type: m.type,
38
+ tags: m.tags,
39
+ aliases: m.aliases,
40
+ alias: m.alias,
41
+ target: m.target,
42
+ source: "retrieval-eval fixture",
43
+ });
44
+ fs.writeFileSync(path.join(dir, `${m.id}.md`), serialize(fm, m.body), "utf8");
45
+ }
46
+ const stats = syncScope("personal");
47
+ console.log(`seeded ${MEMORIES.length} memories (indexed: ${stats.added})\n`);
48
+
49
+ // --- run ---
50
+ let r1 = 0;
51
+ let r5 = 0;
52
+ let mrr = 0;
53
+ const misses: string[] = [];
54
+
55
+ for (const q of QUERIES) {
56
+ const stats = { ftsQuery: "", candidates: 0, hiddenSuperseded: 0, hiddenExcluded: 0, aliasExpansions: [] as string[] };
57
+ const hits = search(q.query, { scopeKeys: ["personal"], limit: 50, stats });
58
+ const ids = hits.map((h) => h.id);
59
+ let rank = -1;
60
+ for (const g of q.gold) {
61
+ const i = ids.indexOf(g);
62
+ if (i !== -1 && (rank === -1 || i < rank)) rank = i;
63
+ }
64
+ if (rank === 0) r1++;
65
+ if (rank !== -1 && rank < K) r5++;
66
+ if (rank !== -1) mrr += 1 / (rank + 1);
67
+ else misses.push(` MISS rank=- query="${q.query}" gold=${q.gold.join(",")} [${q.note}]`);
68
+ if (rank > 0) {
69
+ console.log(` rank=${rank + 1} query="${q.query}" gold=${q.gold.join(",")} [${q.note}]`);
70
+ }
71
+ }
72
+
73
+ const n = QUERIES.length;
74
+ console.log(`\n==== retrieval-eval: FTS5 baseline ====`);
75
+ console.log(`corpus : ${MEMORIES.length} memories (personal scope, single tier)`);
76
+ console.log(`queries: ${n}`);
77
+ console.log(`recall@1: ${(r1 / n).toFixed(3)} (${r1}/${n})`);
78
+ console.log(`recall@5: ${(r5 / n).toFixed(3)} (${r5}/${n})`);
79
+ console.log(`MRR : ${(mrr / n).toFixed(3)}`);
80
+ if (misses.length > 0) {
81
+ console.log(`\nmisses (${misses.length}):`);
82
+ for (const m of misses) console.log(m);
83
+ }
@@ -8,6 +8,13 @@ import { DEFAULT_CONFIG } from "../src/config.ts";
8
8
  import { resolveProjectScope, resolveCwdScope, pickScopeRoot, PERSONAL_SCOPE } from "../src/scope.ts";
9
9
  import { cjkIndexText, cjkQueryExpr, hasCjk } from "../src/retrieve/cjk.ts";
10
10
  import { toFtsQuery } from "../src/retrieve/query.ts";
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";
11
18
  import { contentHash, similarity, NEAR_DUP_THRESHOLD } from "../src/store/lifecycle.ts";
12
19
  import {
13
20
  MIN_NODE_VERSION,
@@ -867,5 +874,80 @@ ok("cursor keeps skill", shouldInstallSkill("cursor", false) === true);
867
874
  ok("truncation note speaks only when cut", truncationNote(20, 54)?.includes("34 more") === true && truncationNote(20, 20) === null);
868
875
  }
869
876
 
877
+ console.log("== D80 index-time expansion ==");
878
+ {
879
+ // EN term expands to its group siblings, minus what's already in the text.
880
+ const v1 = expansionVariantsForDoc("Deploy to production only from the release branch.");
881
+ ok("ship expands from deploy", v1.includes("ship"), v1.join(","));
882
+ ok("no dupes of present terms", !v1.includes("deploy") && !v1.includes("release"), v1.join(","));
883
+ ok("function words are not keywords", !v1.includes("to") && !v1.includes("the"), v1.join(","));
884
+ // ZH substring expands both directions: doc has 数据库 → database indexed.
885
+ const v2 = expansionVariantsForDoc("数据库备份每天凌晨两点执行,全量保留三十天。");
886
+ ok("database indexed from 数据库", v2.includes("database"), v2.join(","));
887
+ ok("backup indexed from 备份", v2.includes("backup"), v2.join(","));
888
+ ok("retention indexed from 保留", v2.includes("retention"), v2.join(","));
889
+ // EN doc gets ZH variants: staging → 预发布环境.
890
+ const v3 = expansionVariantsForDoc("The staging environment wipes itself every night at midnight.");
891
+ ok("预发布环境 indexed from staging", v3.includes("预发布环境"), v3.join(","));
892
+ ok("wipes inflects to wipe group", v3.includes("清空"), v3.join(","));
893
+ // Multi-word Latin phrase: regression test.
894
+ const v4 = expansionVariantsForDoc("上线前必须跑完全量回归测试。");
895
+ ok("regression test indexed from 回归测试", v4.includes("regression test"), v4.join(","));
896
+ // Cap bounds index growth.
897
+ const v5 = expansionVariantsForDoc("test deploy bug cache queue api docker config log mock review commit merge branch");
898
+ ok("variant cap respected", v5.length <= MAX_AUTO_VARIANTS, String(v5.length));
899
+ // Empty / no-match input.
900
+ ok("no curated terms → no variants", expansionVariantsForDoc("hello world").length === 0);
901
+ }
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
+
870
952
  console.log(fails === 0 ? "\nALL PASS" : `\n${fails} FAILURES`);
871
953
  process.exit(fails === 0 ? 0 : 1);
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)
@@ -135,6 +135,11 @@ Flags:
135
135
  with ; (commas also accepted — use ; when an alias itself
136
136
  contains one). Stored only when capture aliases are enabled
137
137
  (init default)
138
+ --alias --target D81: define user vocabulary once — e.g. --alias 香蕉计划
139
+ --target 支付系统重构项目. Later queries mentioning either side
140
+ trigger a second search round on the other side (both
141
+ directions), scoped like the memory itself. Not gated by
142
+ capture aliases.
138
143
 
139
144
  Example:
140
145
  open-memex add "We deploy on Fridays" --scope project --tag process`,
@@ -1008,7 +1013,7 @@ async function main() {
1008
1013
  : [project.key, PERSONAL_SCOPE.key];
1009
1014
  const explain = flags.explain === "true";
1010
1015
  const stats = explain
1011
- ? { ftsQuery: "", candidates: 0, hiddenSuperseded: 0, hiddenExcluded: 0 }
1016
+ ? { ftsQuery: "", candidates: 0, hiddenSuperseded: 0, hiddenExcluded: 0, aliasExpansions: [] as string[] }
1012
1017
  : undefined;
1013
1018
  const hits = search(query, {
1014
1019
  scopeKeys: keys,
@@ -1021,6 +1026,9 @@ async function main() {
1021
1026
  console.log(
1022
1027
  `candidates: ${stats.candidates}, hidden by lifecycle: ${stats.hiddenSuperseded} superseded, ${stats.hiddenExcluded} retracted/archived`,
1023
1028
  );
1029
+ if (stats.aliasExpansions.length > 0) {
1030
+ console.log(`alias rounds: ${stats.aliasExpansions.map((q) => `"${q}"`).join(" → ")}`);
1031
+ }
1024
1032
  }
1025
1033
  if (hits.length === 0) {
1026
1034
  console.log("(no matches)");
@@ -1083,6 +1091,14 @@ async function main() {
1083
1091
  ),
1084
1092
  }
1085
1093
  : {}),
1094
+ // D81: --alias/--target define user vocabulary (not gated by capture
1095
+ // aliases — this is deliberate, like typing the words yourself).
1096
+ ...(typeof flags.alias === "string" && flags.alias !== "true" && flags.alias.trim()
1097
+ ? { alias: redact(flags.alias.trim(), cfg.redactPatterns).content }
1098
+ : {}),
1099
+ ...(typeof flags.target === "string" && flags.target !== "true" && flags.target.trim()
1100
+ ? { target: redact(flags.target.trim(), cfg.redactPatterns).content }
1101
+ : {}),
1086
1102
  source: "cli",
1087
1103
  created_at: rfc,
1088
1104
  updated_at: rfc,
package/src/init.ts CHANGED
@@ -905,10 +905,12 @@ export async function initProject(opts: {
905
905
  );
906
906
  if (injectOnFirstTurn !== DEFAULT_CONFIG.injectOnFirstTurn)
907
907
  patch.injectOnFirstTurn = injectOnFirstTurn;
908
- // D61: capture-time aliases — agents attach alternate phrasings to each
909
- // memory so differently-worded questions still find it.
908
+ // D61: capture-time aliases — the agent may attach its own alternate
909
+ // phrasings to each memory so differently-worded questions still find it.
910
+ // Distinct from D80's automatic index-time synonym expansion, which
911
+ // applies regardless of this setting.
910
912
  const captureAliases = await askBool(
911
- "Attach alternate phrasings (aliases) to memories so reworded questions still find them?",
913
+ "Let the agent attach its own alternate phrasings (aliases) to memories? (The built-in synonym map applies regardless.)",
912
914
  DEFAULT_CONFIG.captureAliases,
913
915
  );
914
916
  if (captureAliases !== DEFAULT_CONFIG.captureAliases)
@@ -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
+ }