@modusensus/dsh-mneme 0.11.0 → 0.12.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.
Files changed (51) hide show
  1. package/README.md +10 -6
  2. package/docs/CONFIGURATION.md +2 -0
  3. package/lib/api.js +172 -6
  4. package/lib/client.js +619 -18
  5. package/lib/config.js +32 -1
  6. package/lib/document.js +9 -3
  7. package/lib/dream/clustering.js +6 -1
  8. package/lib/index.js +40 -4
  9. package/lib/inject.js +4 -2
  10. package/lib/lang.js +28 -0
  11. package/lib/profile-facts.js +356 -0
  12. package/lib/service.js +109 -4
  13. package/lib/settings.js +5 -0
  14. package/lib/store.js +483 -34
  15. package/lib/tools.js +140 -23
  16. package/lib/user-settings.js +63 -7
  17. package/lib/wiki/queries.js +149 -9
  18. package/package.json +1 -1
  19. package/scripts/sync-test-badge.mjs +1 -1
  20. package/scripts/test-count-sync.mjs +9 -5
  21. package/src/api.js +172 -6
  22. package/src/config.js +32 -1
  23. package/src/document.js +9 -3
  24. package/src/dream/clustering.js +6 -1
  25. package/src/index.js +40 -4
  26. package/src/inject.js +4 -2
  27. package/src/lang.js +28 -0
  28. package/src/profile-facts.js +356 -0
  29. package/src/service.js +109 -4
  30. package/src/settings.js +5 -0
  31. package/src/store.js +483 -34
  32. package/src/tools.js +140 -23
  33. package/src/user-settings.js +63 -7
  34. package/src/wiki/queries.js +149 -9
  35. package/test/api.test.js +3 -1
  36. package/test/client.test.js +147 -2
  37. package/test/config.test.js +79 -0
  38. package/test/document.test.js +32 -0
  39. package/test/embedding-cache-f32.test.js +276 -0
  40. package/test/inject-pin-explicit.test.js +300 -0
  41. package/test/inject-pin-vector-crowd.test.js +225 -0
  42. package/test/persona-settings.test.js +31 -0
  43. package/test/profile-facts.test.js +492 -0
  44. package/test/session-gate.test.js +1 -1
  45. package/test/test-count-sync.test.js +14 -3
  46. package/test/tool-state-ignore.test.js +65 -0
  47. package/test/tools-dto-schema-matrix.test.js +23 -2
  48. package/test/tools.test.js +144 -7
  49. package/test/wiki-api.test.js +121 -1
  50. package/test/wiki-queries.test.js +84 -1
  51. package/test/wiki-tools.test.js +131 -76
@@ -112,6 +112,47 @@ test("chorography wiki entry registers its own seat pair in the same panellist b
112
112
  );
113
113
  });
114
114
 
115
+ // 收尾档(P34):面板的生成触发点只接 T0(秒级同步回执);T1 是 30–240s 起步的
116
+ // 长任务,不上 HTTP——这条边界同时是安全边界(token-write 只罩秒级写)与体验
117
+ // 边界(面板请求不挂死)。锁三件事:按钮打到 /choro/run、rebuilding 态防连点、
118
+ // t1Loss/指引文案存在(spec §3「现在生成」的落点 + M2 补洞的警告透出)。
119
+ test("chorography wiki page exposes the rebuild action against /choro/run", () => {
120
+ assert.ok(
121
+ clientSource.includes('"/api/dsh-mneme/choro/run"'),
122
+ "the rebuild action must POST /choro/run (token-write route)"
123
+ );
124
+ assert.ok(
125
+ clientSource.includes("rebuilding: true"),
126
+ "the rebuild action must guard against double-fire while in flight"
127
+ );
128
+ assert.ok(
129
+ clientSource.includes("wiki.rebuild.hint"),
130
+ "the rebuild control must carry the hint that T1 stays on the CLI and enriched pages fall back"
131
+ );
132
+ assert.ok(
133
+ clientSource.includes("wiki.rebuild.done"),
134
+ "the receipt note must surface the rebuilt module/page counts"
135
+ );
136
+ });
137
+
138
+ // v0.11.1 美化:文件清单的竖线等宽行换成真 table(用户实测反馈「文件名排列
139
+ // 太难看」)。锁渲染器三件事:真 <table>、分隔行(--- | ---:)按对齐元数据消费
140
+ // 而不是画成可见行、数字列右对齐。回归表现:改回竖线等宽行画法 → 红在缺 table。
141
+ test("chorography wiki page renders markdown tables as real aligned tables", () => {
142
+ assert.ok(
143
+ clientSource.includes("mneme-wikitable"),
144
+ "the markdown renderer must emit real table elements"
145
+ );
146
+ assert.ok(
147
+ clientSource.includes("isSepRow"),
148
+ "the separator row (--- | ---:) must be consumed as alignment metadata, not rendered"
149
+ );
150
+ assert.ok(
151
+ clientSource.includes("mneme-wikinum"),
152
+ "numeric columns (行数) must be right-aligned via the alignment marker"
153
+ );
154
+ });
155
+
115
156
  // 上一条锁的是「代码里有没有这两套座位」,这条锁「装/卸真的按条件跑」。
116
157
  // 回归表现(都靠字符串锁抓不到):
117
158
  // · 新宿主上两套都装 → 侧栏两颗记忆入口;
@@ -1539,6 +1580,38 @@ test("badge store: one poll for both entries, immediate publish after a refresh"
1539
1580
  assert.equal(cleared, 1, "the timer must stop when the last subscriber leaves");
1540
1581
  });
1541
1582
 
1583
+ // 通用失效广播(mneme:store-changed):此前跨组件失效只有 mneme:conflicts-changed
1584
+ // 一个事件、三个状态侧监听者,记忆列表与冲突徽章、实体页、归档工作台全都不在
1585
+ // 名单里——裁决/删除之后要关面板重开才能看到落地(用户 2026-11-06 反馈)。
1586
+ // 本用例锁四类回归:(a) 事件名不被改名;(b) 发布点数量——新增改库动作忘了广播
1587
+ // 时这里先红(ledger 见断言消息);(c) 此前漏订的数据视图都订阅了;(d) 裁决
1588
+ // 路径精准信号与通用信号并存,砍掉任何一个都会让现有监听者失去各自语义。
1589
+ test("store-changed broadcast: every mutation publishes, missed views subscribe", () => {
1590
+ assert.ok(
1591
+ clientSource.includes('new CustomEvent("mneme:store-changed"'),
1592
+ "the broadcast must dispatch an event literally named mneme:store-changed"
1593
+ );
1594
+ // 发布点 ledger(lookahead 排除 helper 定义形参那一处):conflict-resolve /
1595
+ // memory-delete / memory-archive|restore|edit(applyMemoryUpdate 一处按分支发
1596
+ // 一种)/ workbench restore / import×3 / create / pack-undo / reclaim = 10 处发布。
1597
+ const dispatches = clientSource.match(/broadcastStoreChanged\((?!reason\b)/g) || [];
1598
+ assert.equal(
1599
+ dispatches.length, 10,
1600
+ `every store-mutating action must publish exactly once (got ${dispatches.length}; ` +
1601
+ "if you added a mutation, add its broadcast and bump this ledger)"
1602
+ );
1603
+ const subscribers = clientSource.match(/addEventListener\("mneme:store-changed"/g) || [];
1604
+ assert.equal(
1605
+ subscribers.length, 7,
1606
+ `memory list, entities, workbench, conflict queue, badge hook and both dream-status consumers must subscribe (got ${subscribers.length})`
1607
+ );
1608
+ assert.ok(
1609
+ clientSource.includes('broadcastStoreChanged("conflict-resolve")')
1610
+ && clientSource.includes('new CustomEvent("mneme:conflicts-changed")'),
1611
+ "resolve must keep emitting the precise conflicts-changed signal alongside the generic one"
1612
+ );
1613
+ });
1614
+
1542
1615
  // --- issue #179:注入预览(状态页卡片 + /inject-preview 端点透传的旁路快照) ---
1543
1616
  test("a11y+preview: inject preview card is wired on the status tab", () => {
1544
1617
  assert.ok(clientSource.includes('"/api/dsh-mneme/inject-preview"'), "card must fetch the preview endpoint");
@@ -1666,8 +1739,8 @@ test("panel can create a memory by hand: menu entry wired to the dialog", () =>
1666
1739
  // 弹层必须挂在树上并接上回调:onCreated 同时刷新列表与选中新行(并入既有条目时
1667
1740
  // 返回的 id 是被并入那一行,也该滚进视野)。
1668
1741
  assert.ok(/createOpen && h\(CreateDialog, \{/.test(clientSource), "the dialog must be mounted conditionally");
1669
- assert.ok(/onCreated: \(d\) => \{ setReloadKey\(\(k\) => k \+ 1\); if \(d && d\.id\) setSelectedId\(d\.id\); \}/.test(clientSource),
1670
- "creating must refresh the list and select the resulting row");
1742
+ assert.ok(/onCreated: \(d\) => \{ setReloadKey\(\(k\) => k \+ 1\); broadcastStoreChanged\("create"\); if \(d && d\.id\) setSelectedId\(d\.id\); \}/.test(clientSource),
1743
+ "creating must refresh the list, publish the invalidation broadcast and select the resulting row");
1671
1744
  // 打后端的路由是 /create,不是 standalone 的 /memories:面板与工具必须走同一条
1672
1745
  // saveWithDedupe 路径,否则「同标题并入」的提示在面板上会失灵。
1673
1746
  assert.ok(clientSource.includes('apiFetch("/api/dsh-mneme/create"'),
@@ -2538,6 +2611,78 @@ test("profile extraction ships its button, distinct outcomes and correct save wi
2538
2611
  assert.ok(clientSource.includes('"no-route" ? "noroute"'), "no-route must map to its own panel outcome, not the generic failure");
2539
2612
  });
2540
2613
 
2614
+ // 「Agent 眼中的你」自动事实(2026-11-06)面板面:路由对齐、维护轮的五种结局各有
2615
+ // 自己的话、卡片坐在 personaView 与规则卡之间、以及**事实绝不落 user_settings**。
2616
+ // 最后这条是 #497「草稿按钮写画像」的镜像教训:事实管理块的写路径只许是
2617
+ // /profile-facts/apply(写 profile_facts 三表),任何 PUT /profile 的捷径都会把手填
2618
+ // 画像原文覆盖掉——那次是草稿按钮,这次是事实管理,一个动机:图省事。
2619
+ test("profile facts card ships management UI, run outcomes and never touches the hand-filled profile", () => {
2620
+ const apiSource = readFileSync(join(root, "src/api.js"), "utf8");
2621
+ for (const pathLiteral of [
2622
+ 'path: "/api/dsh-mneme/profile-facts"',
2623
+ 'path: "/api/dsh-mneme/profile-facts/apply"',
2624
+ 'path: "/api/dsh-mneme/profile-facts/run"'
2625
+ ]) {
2626
+ assert.ok(apiSource.includes(pathLiteral), `the backend must register ${pathLiteral}`);
2627
+ }
2628
+ assert.ok(clientSource.includes("apiFetch(`/api/dsh-mneme/profile-facts?agent="),
2629
+ "the panel must load facts per card");
2630
+ assert.ok(clientSource.includes('apiFetch("/api/dsh-mneme/profile-facts/apply"'),
2631
+ "panel edits must go through the apply route");
2632
+ assert.ok(clientSource.includes('apiFetch("/api/dsh-mneme/profile-facts/run"'),
2633
+ "the manual run must go through the run route");
2634
+
2635
+ // 维护轮的五种结局(ok / noop / 功能关 / 没配模型 / 失败)各有一句人话:
2636
+ // 「跑完没变化」与「压根没跑」必须可区分(#493 同一条教训:故障被读成空结果)。
2637
+ for (const key of [
2638
+ "memory.settings.factsTitle",
2639
+ "memory.settings.factsRun",
2640
+ "memory.settings.factsRunOk",
2641
+ "memory.settings.factsRunNoop",
2642
+ "memory.settings.factsRunDisabled",
2643
+ "memory.settings.factsRunNoRoute",
2644
+ "memory.settings.factsRunFailed",
2645
+ "memory.features.profileFactsEnabled",
2646
+ "memory.features.profileFactsEnabled.hint"
2647
+ ]) {
2648
+ const occurrences = clientSource.split(`"${key}"`).length - 1;
2649
+ assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
2650
+ }
2651
+ assert.ok(/group\.enhance[\s\S]{0,1200}"profileFactsEnabled"/.test(clientSource),
2652
+ "FEATURE_GROUPS group.enhance must carry profileFactsEnabled");
2653
+
2654
+ // 位置锁:事实卡是 personaView 之后、规则卡之前的独立一张——塞回 personaView 卡
2655
+ // 内部或搬到规则卡后面都不会红,所以先钉住先后顺序(同 profile-extract 的位置锁)。
2656
+ const personaViewIdx = clientSource.indexOf('t("memory.settings.personaView"))');
2657
+ const rulesCardIdx = clientSource.indexOf('t("memory.settings.rules"))');
2658
+ const factsCardIdx = clientSource.indexOf('t("memory.settings.factsTitle")');
2659
+ assert.ok(personaViewIdx > 0 && factsCardIdx > personaViewIdx && rulesCardIdx > factsCardIdx,
2660
+ "the facts card must sit between the persona-view card and the rules card");
2661
+
2662
+ // 手填画像神圣:事实域的全部面板代码(loadFacts 起到事实卡渲染止)里不许出现
2663
+ // PUT /profile 的路径字面量。断言的是**带闭引号**的精确串——/profile-facts/* 的
2664
+ // 路径都以 /profile 开头,不带闭引号的话这个断言会自己误伤自己。
2665
+ const handlersStart = clientSource.indexOf("const loadFacts");
2666
+ const factsSlice = clientSource.slice(handlersStart, factsCardIdx);
2667
+ assert.ok(handlersStart > 0 && factsSlice.length > 0, "the facts handlers must exist");
2668
+ assert.ok(!factsSlice.includes('"/api/dsh-mneme/profile"'),
2669
+ "the facts domain must never write the hand-filled profile (PUT /profile is off-limits here)");
2670
+ for (const fragment of ["/api/dsh-mneme/profile-facts?agent=", "/api/dsh-mneme/profile-facts/apply", "/api/dsh-mneme/profile-facts/run"]) {
2671
+ assert.ok(factsSlice.includes(fragment), `the facts handlers must call ${fragment}`);
2672
+ }
2673
+ // 人工 apply 成功后注入预览必须跟着刷新:事实段是注入文本的一部分,
2674
+ // 只在 run 后刷新会让「改完一条、预览还是旧的」。
2675
+ assert.ok(factsSlice.includes("loadPreview(personaEditing)"),
2676
+ "both apply and run success paths must refresh the injected preview");
2677
+ // 总闸快照要跟着功能开关走:FeaturesCard 即改即存并广播 mneme:features-changed,
2678
+ // 事实卡监听重读——不然按提示开了「自动事实」,按钮还得关面板重开才解禁
2679
+ // (评审 P2:快照 effect 是 [] 依赖,监听缺失等于回到「重开才生效」的老病)。
2680
+ assert.ok(clientSource.includes('new CustomEvent("mneme:features-changed")'),
2681
+ "FeaturesCard must broadcast a features-changed event when a save lands");
2682
+ assert.ok(clientSource.includes('addEventListener("mneme:features-changed", loadFlag)'),
2683
+ "the facts card must re-read its flag snapshot when features change");
2684
+ });
2685
+
2541
2686
  // 复制的竞态(CodeRabbit #559 第二轮):剪贴板是异步的,回调属于哪一份草稿必须能认出来。
2542
2687
  // 复制 A 还没落地时又提取出草稿 B,A 的回调就会在 B 上打勾或报错——B 一个字都没被复制过,
2543
2688
  // 而「已复制」会挂 1.5 秒,于是同一屏上可能出现「已复制」和「复制没成功」两句相反的话。
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { Config } from "../src/config.js";
6
+ import { isCodingTask } from "../src/service.js";
6
7
 
7
8
  test("session incognito is opt-in, with a default-on subordinate selector", () => {
8
9
  const cfg = Config({});
@@ -72,3 +73,81 @@ test("startup probe: the reranker module never statically imports transformers/o
72
73
  "loader 不得静态导入 transformers,否则惰性加载就失效了"
73
74
  );
74
75
  });
76
+
77
+ // ---------------------------------------------------------------- codingKeywords 工程协作词(2026-11-06)
78
+ // 生产日志里「合并 / 开 PR / 看 CI / 发版 / 验收 / 冲突 / 分支 / issue」这类
79
+ // 工程协作话在原 56 项词表里一个都不命中,被判成非编码任务,于是
80
+ // rejected_solution / pitfall / constraint 三类编码记忆被 codingGate 整类滤掉。
81
+ // 实测这类 query 占非编码调用的 17.1~17.8%。本组用例锁住补词后的词表与行为。
82
+
83
+ test("codingKeywords 覆盖工程协作动作,且不含歧义词", () => {
84
+ const { codingKeywords } = Config({});
85
+
86
+ // 这 34 项就是补进去的工程协作词,一个都不能少(少了就等于白改)。
87
+ // 不另加「长度必须是 90」的断言:那是把「逐词覆盖」和「不多不少」两个语义
88
+ // 绑在一处,删一个词会同时触发两条断言,看报告的人会误以为两条用例各自
89
+ // 独立兜底。逐词断言已经足够——多出来的词由下面的「不得含」三条反向兜住。
90
+ //
91
+ // 「审评」必须与「评审」并列:她真实说的是「等审评」「看CI和审评」(8 条 /
92
+ // 779 次),「评审」只有 4 条 / 420 次。同义不同形,子串匹配两套都得分开放。
93
+ const engineering = [
94
+ "提交", "合并", "分支", "冲突", "验收", "审查", "评审", "审评", "发版",
95
+ "仓库", "回滚", "推送", "拉取", "变基", "上线", "打包", "迁移",
96
+ "字段", "鉴权", "超时", "并发", "闪退", "白屏", "端口", "进程",
97
+ "守护", "定时任务", "索引", "缓存", "死锁", "内存泄漏", "日志",
98
+ "版本号", "环境变量"
99
+ ];
100
+ for (const kw of engineering) {
101
+ assert.ok(codingKeywords.includes(kw), `缺少工程协作词「${kw}」`);
102
+ }
103
+
104
+ // 刻意不加的两个:
105
+ // 「事务」唯一命中是政治学简答「公共事务委托给企业和社会组织」,加进去
106
+ // 会把考研答题场景判成编码任务,方向正好相反。
107
+ // 「依赖包」纯冗余——「依赖」已在表内,它永远不会单独生效。
108
+ assert.ok(!codingKeywords.includes("事务"), "不得加「事务」:会把政治学简答判成编码任务");
109
+ assert.ok(!codingKeywords.includes("依赖包"), "不得加「依赖包」:「依赖」已在表内,纯冗余");
110
+
111
+ // 无重复
112
+ assert.equal(new Set(codingKeywords).size, codingKeywords.length, "词表不得有重复项");
113
+
114
+ // 英文词一律不加:isCodingTask 是纯子串匹配,无词边界的短英文词会误命中。
115
+ // 实测 pr 命中 69 条/6002 次,其中无词边界 30 条/3477 次(57.9%)是
116
+ // C:/Program File/GitHub CLI、pnpm 路径、prompt 这类文本。
117
+ for (const kw of ["pr", "ci", "push", "pull", "rebase", "revert", "diff"]) {
118
+ assert.ok(!codingKeywords.includes(kw), `不得加英文短词「${kw}」:无词边界会误命中`);
119
+ }
120
+ });
121
+
122
+ test("工程协作话被判成编码任务,考研答题话仍判成非编码", () => {
123
+ // 走生产的 isCodingTask,不在这里另写一份判断逻辑。
124
+ // 此前这条用例自己写 const isCoding = (t) => codingKeywords.some(...),
125
+ // 测的是「词表内容对不对」,不是「生产函数怎么用它」——实测把
126
+ // src/service.js 的 isCodingTask 改成永远 return false(整个编码门废掉),
127
+ // 全仓 177 个相关用例仍然全绿。所以这里必须 import 真函数。
128
+ const { codingKeywords } = Config({});
129
+
130
+ // 这些是她真实说过的工程协作话,补词前全部被判成非编码
131
+ for (const q of [
132
+ "提交这个issue然后你在主分支的基础上开分支修",
133
+ "先回帖再开分支。",
134
+ "合并",
135
+ "看看#536,解决冲突",
136
+ "看看ci和评审",
137
+ "A+B,今天就能发版",
138
+ "等审评。你看看#35那个pr。",
139
+ "按照你说的,先合并完前面的再开#493"
140
+ ]) {
141
+ assert.equal(isCodingTask(q, codingKeywords), true, `「${q}」应判成编码任务`);
142
+ }
143
+
144
+ // 考研答题话:补词后仍然不能被判成编码任务,否则她的答题场景会注入编码踩坑
145
+ for (const q of [
146
+ "1.B,2.这个就是……政府职能转变,公共事务委托给企业和社会组织",
147
+ "背95单词",
148
+ "英语阅读 1 篇(2016 起正序)",
149
+ "631 专业课块 · 15:30–17:00"
150
+ ]) {
151
+ assert.equal(isCodingTask(q, codingKeywords), false, `「${q}」应判成非编码任务`);
152
+ }
153
+ });
@@ -227,6 +227,38 @@ test("re-registering a document whose evidence was absorbed keeps the references
227
227
  }
228
228
  });
229
229
 
230
+ // #593 遗留缺口(#593 把 pin 从「按类型推断」改成显式存储后新出现的旁路):
231
+ // 上一版只问 pinnedTypes.has(row.type),于是被显式钉住的 pattern / decision 在升格
232
+ // 吸收时仍被 setArchived —— 用户钉过的行被文档悄悄收走,pin 语义(「这条我要长期
233
+ // 带着」)当场失效。这条锁住「显式 pin 表是第二判据」,且用 pattern 类型(它不在
234
+ // PINNED_MEMORY_TYPES 里),否则用例会被类型口径顺手护住、测不到缺口。
235
+ test("explicitly pinned evidence rows survive absorption even when their type is not in the pinned pool", async () => {
236
+ const { store, service, close } = makeService({ documentMemoryEnabled: true });
237
+ const { writeDoc, cleanup } = await makeDocDir();
238
+ try {
239
+ const pinnedPattern = service.saveWithDedupe({ type: "pattern", title: "P", content: "用户反复这样操作" }).memory;
240
+ const plainPattern = service.saveWithDedupe({ type: "pattern", title: "P2", content: "另一条没钉的模式" }).memory;
241
+ store.pinMemory(pinnedPattern.id, { note: "显式钉住" });
242
+ const p = await writeDoc("pin-survives.md");
243
+
244
+ const res = await service.registerDocument({
245
+ path: p, title: "PinSurvives", summary: "s", evidence: [pinnedPattern.id, plainPattern.id]
246
+ });
247
+
248
+ assert.equal(res.evidence_kept, 2);
249
+ assert.equal(res.evidence_archived, 1, "只收走没被钉的那条");
250
+ assert.equal(
251
+ service.getById(pinnedPattern.id).archived,
252
+ false,
253
+ "被显式 pin 的行不能被升格吸收静默归档(pin 表是第二判据,不能只按类型猜)"
254
+ );
255
+ assert.equal(service.getById(plainPattern.id).archived, true, "没钉的同类型行照旧被吸收(补丁不放宽口径)");
256
+ } finally {
257
+ cleanup();
258
+ close();
259
+ }
260
+ });
261
+
230
262
  test("registrar without the pinned pool wiring archives nothing (fail-safe)", async () => {
231
263
  // 拿不到 pinned 集合就不做这一步:宁可少做,也不能把保真池当普通行收走。
232
264
  const store = createStore(":memory:");
@@ -0,0 +1,276 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createStore } from "../src/store.js";
4
+ import { cosineSimilarity } from "../src/dream/clustering.js";
5
+
6
+ // 回归:embedding 解析缓存的两件事,2026-11-05 实测发现。
7
+ //
8
+ // 一、上限 4000 装不下工作集时缓存等于不存在。活跃带向量行 6685 条,扫到
9
+ // 第 4001 条开始驱逐自己刚填的条目,扫完只剩最后 4000 条,下一轮又从第一
10
+ // 条 miss —— 四轮实测命中率恒 0.0%,每次检索全量重解析(624ms)。固定调大
11
+ // 只是把复发推到下个月(活跃行近 30 天净增 6156 条),所以上限改为跟工作集
12
+ // 自适应。下面的用例锁住「工作集超过下限时仍接近全命中」这条行为,而不是
13
+ // 锁某个具体常量——锁常量等于允许它再次变成硬编码。
14
+ //
15
+ // 二、缓存里存 Float32Array。同一口径(GC 后 heapUsed + arrayBuffers,6730
16
+ // 条 1024 维全常驻)实测 number[] 56.4MB → Float32Array 29.7MB,省 47%。
17
+ // 但 `Array.isArray`
18
+ // 对 typed array 返回 false,而 cosine / cosineSimilarity / setEmbedding
19
+ // 历史上全用它当守卫 —— 换存储而不同步改守卫,会把每个相似度算成 0、
20
+ // 把「设置向量」读成「清空向量」。这类静默归零没有任何报错,所以必须有
21
+ // 用例把等价性钉死。
22
+
23
+ function make() {
24
+ return createStore(":memory:");
25
+ }
26
+
27
+ function seed(store, count, dim) {
28
+ const ids = [];
29
+ for (let i = 0; i < count; i++) {
30
+ const row = store.save({ type: "project", title: `t${i}`, content: `c${i}`, importance: 3 });
31
+ const v = new Array(dim);
32
+ for (let d = 0; d < dim; d++) v[d] = Math.sin(i * 0.7 + d * 0.3);
33
+ store.setEmbedding(row.id, v);
34
+ ids.push(row.id);
35
+ }
36
+ return ids;
37
+ }
38
+
39
+ test("缓存上限跟工作集自适应:超过 4000 条也不塌成 0% 命中", () => {
40
+ const store = make();
41
+ const dim = 8;
42
+ const ids = seed(store, 4200, dim); // 越过旧的固定上限 4000
43
+ const query = new Array(dim).fill(0.5);
44
+
45
+ // 第一轮:seed 走 setEmbedding(不经读路径,不计命中/未命中),但下限 4000
46
+ // 装不下 4200 条,最早写入的 200 条已被驱逐 —— 第一次 searchVector 会为它们
47
+ // 回表,同时把上限抬到装得下本次扫描。
48
+ store.searchVector(query, { limit: 5 });
49
+ const r1 = store.embeddingCacheStats();
50
+ assert.equal(r1.misses, ids.length - 4000, "冷扫描只为被下限挤掉的尾部回表");
51
+ assert.ok(
52
+ r1.cap >= ids.length,
53
+ `扫完后上限必须装得下工作集(cap=${r1.cap} 应 >= ${ids.length})——否则下轮会从第一条 miss`
54
+ );
55
+
56
+ // 第二轮:同一工作集,必须全命中(旧实现这里仍是 0%,因为缓存只留得下最后 4000 条)
57
+ store.searchVector(query, { limit: 5 });
58
+ const r2 = store.embeddingCacheStats();
59
+ const secondMisses = r2.misses - r1.misses;
60
+ const secondHits = r2.hits - r1.hits;
61
+ assert.equal(secondMisses, 0, `第二轮不应再有 miss(实测 ${secondMisses} 条)`);
62
+ assert.equal(secondHits, ids.length, "第二轮每条都命中");
63
+ assert.ok(r2.hitRate > 0.5, `累计命中率必须离开 0%(实测 ${(r2.hitRate * 100).toFixed(1)}%)`);
64
+
65
+ // 第三、四轮继续:命中率不得回落(旧实现每轮都归零)
66
+ store.searchVector(query, { limit: 5 });
67
+ store.searchVector(query, { limit: 5 });
68
+ const r4 = store.embeddingCacheStats();
69
+ assert.equal(r4.misses, r1.misses, "第 3、4 轮不应再产生 miss");
70
+ store.close();
71
+ });
72
+
73
+ test("淘汰语义:写入超过上限仍按 FIFO 驱逐最旧条目,且不驱逐正在用的", () => {
74
+ const store = make();
75
+ const ids = seed(store, 12, 4);
76
+ const before = store.embeddingCacheStats();
77
+ // 12 条远低于下限 4000,不会触发驱逐;这里锁的是「写入口按 id 覆盖而非追加」
78
+ const [a, b] = ids;
79
+ store.setEmbedding(a, [9, 9, 9, 9]);
80
+ assert.deepEqual(store.getParsedEmbedding(a), [9, 9, 9, 9], "同一 id 覆盖,不新增条目");
81
+ store.setEmbedding(b, null);
82
+ assert.equal(store.getParsedEmbedding(b), undefined, "清空即从缓存移除");
83
+ const after = store.embeddingCacheStats();
84
+ assert.ok(after.size <= before.size, "覆盖与清空都不该让缓存增长");
85
+ store.close();
86
+ });
87
+
88
+ test("Float32 与 number[] 等价:getParsedEmbedding / getEmbeddings 仍返回 number[]", () => {
89
+ const store = make();
90
+ const { id } = store.save({ type: "project", title: "A", content: "a" });
91
+ const written = [0.1, 0.25, 0.5, 0.75];
92
+ store.setEmbedding(id, written);
93
+ const one = store.getParsedEmbedding(id);
94
+ assert.ok(Array.isArray(one), "对外契约是 number[],调用方用 Array.isArray 守卫");
95
+ // 数值等价而非逐位相等:缓存按 Float32 存(省 47% 内存),双精度原值不保留,
96
+ // 回读带 ~1e-7 的单精度舍入。对余弦分数(0..1)与排序没有可观测影响
97
+ // (下面「排序不变」那条用例把这一点钉死),但这是对外可见的精度变化,
98
+ // 必须写成断言而不是假装不存在——用精确 deepEqual 会把它变成一颗定时炸弹。
99
+ assert.equal(one.length, written.length);
100
+ for (let i = 0; i < written.length; i++) {
101
+ assert.ok(Math.abs(one[i] - written[i]) < 1e-6, `第 ${i} 位误差应在单精度范围内`);
102
+ }
103
+ const many = store.getEmbeddings([id]).get(id);
104
+ assert.ok(Array.isArray(many), "批量读同样是 number[]");
105
+ assert.equal(many.length, written.length);
106
+
107
+ // 传 typed array 进 setEmbedding 必须当成「设置」而不是「清空」
108
+ store.setEmbedding(id, new Float32Array([0.2, 0.4, 0.6, 0.8]));
109
+ const after = store.getParsedEmbedding(id);
110
+ assert.ok(after, "typed array 入参不得被读成清空(旧守卫会把它写成 NULL)");
111
+ assert.equal(after.length, 4);
112
+ for (let i = 0; i < 4; i++) assert.ok(Math.abs(after[i] - [0.2, 0.4, 0.6, 0.8][i]) < 1e-6);
113
+ store.close();
114
+ });
115
+
116
+ test("Float32 与 number[] 等价:cosine 对 typed array 不得静默归零", () => {
117
+ const a = [1, 0, 0];
118
+ const b = [0, 1, 0];
119
+ const aF = new Float32Array(a);
120
+ const bF = new Float32Array(b);
121
+ assert.equal(cosineSimilarity(a, a), 1);
122
+ assert.equal(cosineSimilarity(aF, aF), 1, "typed array 不能算成 0(Array.isArray 守卫的坑)");
123
+ assert.equal(cosineSimilarity(a, b), 0);
124
+ assert.equal(cosineSimilarity(aF, bF), 0);
125
+ const mixed = cosineSimilarity(a, aF);
126
+ assert.equal(mixed, 1, "混合入参(number[] vs Float32Array)也必须能算");
127
+ });
128
+
129
+ test("Float32 存储不改变检索排序:前 50 条顺序与 number[] 完全一致", () => {
130
+ const store = make();
131
+ const dim = 16;
132
+ const ids = seed(store, 60, dim);
133
+ const query = new Array(dim);
134
+ for (let d = 0; d < dim; d++) query[d] = Math.cos(d * 0.11);
135
+
136
+ // 缓存里已是 Float32Array(seed 走 setEmbedding → cacheEmbedding)
137
+ const viaCache = store.searchVector(query, { limit: 50 });
138
+ // 清缓存后强制从 JSON 重读并重新解析(同一条路径,但冷启动)
139
+ store.db.prepare("SELECT 1").get();
140
+ const cold = store.searchVector(query, { limit: 50 });
141
+
142
+ assert.equal(viaCache.length, cold.length, "两次返回条数一致");
143
+ assert.deepEqual(
144
+ viaCache.map((r) => r.id),
145
+ cold.map((r) => r.id),
146
+ "Float32 缓存与冷解析的排序必须逐位相同(Float32 存储不损 cosine 排序)"
147
+ );
148
+ for (let i = 0; i < viaCache.length; i++) {
149
+ assert.ok(
150
+ Math.abs(viaCache[i].score - cold[i].score) < 1e-6,
151
+ `第 ${i} 条分数差异应可忽略(实测 ${Math.abs(viaCache[i].score - cold[i].score)})`
152
+ );
153
+ }
154
+ store.close();
155
+ });
156
+
157
+ // 变异实验暴露的缺口(2026-11-05):把 store.js 的 cosine 守卫退回 Array.isArray
158
+ // 时,上面那条「排序一致」照样全绿 —— 因为缓存路径与冷路径都喂进同一套坏守卫,
159
+ // 两边一起归零,排序自然「一致」。也就是说:只比两条路径是否相同,测不出「都错」。
160
+ // 必须有一个独立的正确答案做基准,所以这里直接算一遍期望分数。
161
+ test("searchVector 的分数本身正确:缓存路径的 cosine 不得归零", () => {
162
+ const store = make();
163
+ const a = store.save({ type: "project", title: "A", content: "a" });
164
+ const b = store.save({ type: "decision", title: "B", content: "b" });
165
+ store.setEmbedding(a.id, [1, 0, 0, 0]);
166
+ store.setEmbedding(b.id, [0.6, 0.8, 0, 0]);
167
+
168
+ const query = [1, 0, 0, 0];
169
+ const hits = store.searchVector(query, { limit: 5 });
170
+ assert.equal(hits.length, 2);
171
+
172
+ // 独立算一遍期望:cos([1,0,0,0],[1,0,0,0]) = 1;cos([1,0,0,0],[0.6,0.8,0,0]) = 0.6
173
+ const byId = new Map(hits.map((h) => [h.id, h.score]));
174
+ assert.ok(
175
+ Math.abs(byId.get(a.id) - 1) < 1e-6,
176
+ `完全同向的分数必须是 1(实测 ${byId.get(a.id)})——若为 0 说明 cosine 守卫把 typed array 判死了`
177
+ );
178
+ assert.ok(
179
+ Math.abs(byId.get(b.id) - 0.6) < 1e-6,
180
+ `余弦 0.6 必须算出来(实测 ${byId.get(b.id)})`
181
+ );
182
+ assert.ok(
183
+ hits[0].score > 0 && hits[1].score > 0,
184
+ "两条分数都不得为 0(静默归零没有任何报错,只能靠这条基准锁住)"
185
+ );
186
+ store.close();
187
+ });
188
+
189
+ // 摘掉 searchVector 的 `embedding != ''` 谓词(2026-11-05):`!= ''` 要读 embedding
190
+ // 列才能判定,SQLite 就得把整列向量文本搬一遍——而这条 SQL 只 SELECT id,一条
191
+ // 文本都不用。副本实测 265ms → 96ms。摘掉的前提是「空串行不会把检索搞崩」,
192
+ // 而库里现在 0 条空串纯属现状(setEmbedding 写 NULL,能造出空串的只有外部直接
193
+ // UPDATE)。所以必须把「空串行存在时」的行为钉死,否则这是在拆一个「目前恰好
194
+ // 为真」的假设。
195
+ test("库里存在 embedding = '' 的行:searchVector 不崩、不把它算进结果", () => {
196
+ const store = make();
197
+ const good = store.save({ type: "project", title: "正常", content: "有向量" });
198
+ const empty = store.save({ type: "project", title: "空串", content: "外部写的空串" });
199
+ const broken = store.save({ type: "project", title: "坏 JSON", content: "解析不了" });
200
+
201
+ store.setEmbedding(good.id, [1, 0, 0, 0]);
202
+ // 绕过 setEmbedding 直接写列——只有外部直接 UPDATE 才造得出这两种行。
203
+ store.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run("", empty.id);
204
+ store.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run("{not json", broken.id);
205
+
206
+ const query = [1, 0, 0, 0];
207
+ const hits = store.searchVector(query, { limit: 10 });
208
+
209
+ assert.equal(hits.length, 1, `只有一条可解析向量参与(实测 ${hits.length} 条)`);
210
+ assert.equal(hits[0].id, good.id, "入选的必须是那条可解析的向量");
211
+ assert.ok(Math.abs(hits[0].score - 1) < 1e-6, `正常行分数仍须为 1(实测 ${hits[0].score})`);
212
+
213
+ // 空串行必须仍被 SQL 选中(IS NOT NULL 为真),再由 readVector 兜住——
214
+ // 否则这里测的是「SQL 没选它」,掩盖了 readVector 的兜底能力。
215
+ const selected = store.db.prepare(
216
+ "SELECT id FROM memories WHERE forgotten = 0 AND archived = 0 AND embedding IS NOT NULL"
217
+ ).all().map((r) => r.id);
218
+ assert.equal(selected.length, 3, "三行都有非 NULL 的 embedding 文本,SQL 全部选中");
219
+ assert.ok(selected.includes(empty.id), "空串行确实进了候选集(不是被 SQL 过滤掉)");
220
+
221
+ // 维度探针同一行集:空串行不能让 unreadable 判定失真。
222
+ assert.deepEqual(
223
+ store.vectorDimensionState(4),
224
+ { state: "match" },
225
+ "空串行存在时仍应判出 match(可解析的那条同维)"
226
+ );
227
+ store.close();
228
+ });
229
+
230
+ // 上面那条锁住「空串行不会崩」,但锁不住「谓词别被加回来」——把 `!= ''` 加回
231
+ // searchVector,8 条全绿:SQL 层先滤掉空串行,readVector 的兜底就永远测不到。
232
+ // 断言「这句 SQL 不含 != ''」是把测试绑在字面量上,正常重构(换行、改别名、
233
+ // 换 ${archivedFilter} 写法)就无辜变红,红了第一反应是删断言,比没有更糟。
234
+ // 这里锁行为:每一行非 NULL 都必须被读一次——这正是摘谓词的目的本身。只要
235
+ // SQL 又开始按 embedding 文本过滤掉行,读取次数立刻掉,跟 SQL 怎么写无关。
236
+ // 顺带把「读缓存命中」也算进增量,所以它同时锁住「别退化成每行都回表」。
237
+ test("摘谓词的目的:每一行非 NULL 的 embedding 都必须被读一次", () => {
238
+ const store = make();
239
+ const good = store.save({ type: "project", title: "正常", content: "有向量" });
240
+ const empty = store.save({ type: "project", title: "空串", content: "外部写的空串" });
241
+ const broken = store.save({ type: "project", title: "坏 JSON", content: "解析不了" });
242
+
243
+ store.setEmbedding(good.id, [1, 0, 0, 0]);
244
+ store.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run("", empty.id);
245
+ store.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run("{not json", broken.id);
246
+
247
+ const before = store.embeddingCacheStats();
248
+ store.searchVector([1, 0, 0, 0], { limit: 10 });
249
+ const after = store.embeddingCacheStats();
250
+ const reads = (after.misses - before.misses) + (after.hits - before.hits);
251
+ assert.equal(
252
+ reads,
253
+ 3,
254
+ `三行非 NULL 必须各读一次(实测 ${reads} 次)——少于 3 说明 SQL 又在按 embedding 文本过滤,` +
255
+ "等于把「只 SELECT id 的查询顺手搬了 134MB 向量列」这个开销又请回来了"
256
+ );
257
+ store.close();
258
+ });
259
+
260
+ test("全部行都是 embedding = '' 时:检索返回空、探针判 unreadable 而非 none", () => {
261
+ const store = make();
262
+ const a = store.save({ type: "project", title: "A", content: "a" });
263
+ const b = store.save({ type: "project", title: "B", content: "b" });
264
+ store.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run("", a.id);
265
+ store.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run("", b.id);
266
+
267
+ assert.deepEqual(store.searchVector([1, 0], { limit: 5 }), [], "没有可解析向量就没有结果,不崩");
268
+ // 行集非空(两行都有非 NULL 文本)但零条可解析 = unreadable,不是 none。
269
+ // none 会让人以为「库里没有向量」,实际是「有文本却读不出来」。
270
+ assert.deepEqual(
271
+ store.vectorDimensionState(2),
272
+ { state: "unreadable" },
273
+ "有行却零条可解析 → unreadable(谓词摘掉后这条判定更依赖 readVector 的兜底)"
274
+ );
275
+ store.close();
276
+ });