@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
@@ -49,11 +49,148 @@ function walkSchema(node, path, problems) {
49
49
  }
50
50
  }
51
51
 
52
- test("registers eleven tools with correct names", () => {
52
+ // memory_pin 的 injectable 回执必须跟注入侧同一个出口判定(#249 第二批)。
53
+ //
54
+ // 这条单独立在 tools 侧而不是注入侧:注入侧早已锁了「暂停作用域的行不进块」,
55
+ // 但回执那句话是另一段代码,它可以照样撒谎——描述文案与 README 都写着
56
+ // 「遗忘 / 归档 / 暂停作用域都优先于 pin」,而 execute 里只算了 forgotten/archived
57
+ // 两个标志位,暂停作用域被漏掉。于是同一条行在「真会注入」与「被作用域挡住」
58
+ // 两种完全相反的处境下都报 injectable: true,正是「返回成功不等于生效」那一类。
59
+ //
60
+ // 三档一起锁,是因为 filterPaused 的出口语义是分档的(见 src/scope-state.js:215):
61
+ // enforce 开 → 真正剔掉,回执必须 false;
62
+ // 只开 enabled(观察档)→ 不拦任何东西,回执必须 true(此时报 false 才是假警报);
63
+ // 两个都关 → 同样不拦,回执 true。
64
+ function setupPinReceipt(config) {
65
+ // pin 预算从 config 读(src/service.js:1885),不是 injectCandidates 的入参——
66
+ // 传进调用参数会被静默忽略,那样这几条测试就测不到 pin 池。
67
+ const cfg = { pinnedInjectBudget: 5, ...config };
68
+ const store = createStore(":memory:");
69
+ // 注意 config 必须同时喂 service 与 tools:作用域闸在 service 侧构造,
70
+ // 而 memory_pin 的 execute 走的是 service 暴露的那个出口。
71
+ const service = createService({ store, mirror: null, config: cfg });
72
+ const registered = [];
73
+ const ctx = { tools: { register(def) { registered.push(def); return () => {}; } } };
74
+ createTools(ctx, service, cfg, undefined);
75
+ return { store, service, byName: Object.fromEntries(registered.map((d) => [d.name, d])) };
76
+ }
77
+
78
+ test("memory_pin 的 injectable 如实报告暂停作用域(而不是只算遗忘与归档)", async () => {
79
+ const config = { scopeState: { enabled: true, enforce: true } };
80
+ const { store, service, byName } = setupPinReceipt(config);
81
+ const target = store.save({
82
+ type: "preference",
83
+ title: "暂停域里的偏好",
84
+ content: "这一路现在被按下了。",
85
+ workspace_scope: "proj-a",
86
+ importance: 5
87
+ });
88
+ await service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
89
+
90
+ const receipt = await byName.memory_pin.execute({ id: target.id }, undefined);
91
+ // 钉是钉上了(存储确实写了),但它在当前状态下不会被注入——两件事分开报。
92
+ assert.equal(receipt.pinned, true);
93
+ assert.equal(receipt.injectable, false, "暂停作用域里的行必须报 injectable: false");
94
+
95
+ // 回执与行为必须同向:真的注入一次,这条不许出现。
96
+ const pinnedStats = {};
97
+ const injected = service.injectCandidates({
98
+ query: "随便问个问题",
99
+ maxItems: 5,
100
+ pinnedStats
101
+ });
102
+ assert.equal(pinnedStats.source, "explicit", "这一条本身钉住了,口径应留在 explicit");
103
+ assert.ok(
104
+ !injected.some((m) => m.id === target.id),
105
+ "被暂停作用域挡住的行不该出现在注入块里(回执说 false,行为也必须一致)"
106
+ );
107
+ });
108
+
109
+ test("暂停作用域只开观察档(enforce 关)时,injectable 仍报 true", async () => {
110
+ // 观察档的语义是「记录本应被拦下多少,但一条都不拦」。此时那条行确实会进上下文,
111
+ // 所以回执必须报 true;报 false 会让用户以为它被挡了,比漏掉暂停作用域更难查。
112
+ const config = { scopeState: { enabled: true, enforce: false } };
113
+ const { store, service, byName } = setupPinReceipt(config);
114
+ const target = store.save({
115
+ type: "preference",
116
+ title: "观察档下的偏好",
117
+ content: "按下但未生效。",
118
+ workspace_scope: "proj-a",
119
+ importance: 5
120
+ });
121
+ await service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
122
+
123
+ const receipt = await byName.memory_pin.execute({ id: target.id }, undefined);
124
+ assert.equal(receipt.injectable, true, "观察档不拦任何东西,回执必须报 true");
125
+
126
+ const pinnedStats = {};
127
+ const injected = service.injectCandidates({
128
+ query: "随便问个问题",
129
+ maxItems: 5,
130
+ pinnedStats
131
+ });
132
+ assert.ok(
133
+ injected.some((m) => m.id === target.id),
134
+ "观察档下这条应当照常注入,回执与行为必须同向"
135
+ );
136
+ });
137
+
138
+ test("pinnedInjectBudget=0(默认,pin 池整体关闭)时 injectable 必须报 false", async () => {
139
+ // #593 遗留缺口:pin 池由 pinnedInjectBudget 开关(src/service.js:1885 的
140
+ // pinnedBudget > 0 才读表选路),默认 0。上一版只看遗忘/归档/暂停作用域,
141
+ // 预算为 0 的一条照样报 true——钉住成功但一条都不会被注入,正是「返回成功
142
+ // 不等于生效」。这里把默认档(不传任何 config)与显式 0 两档一起锁。
143
+ const { store, service, byName } = setupPinReceipt({ pinnedInjectBudget: 0 });
144
+ const target = store.save({ type: "preference", title: "预算关着", content: "x", importance: 5 });
145
+ const receipt = await byName.memory_pin.execute({ id: target.id }, undefined);
146
+ assert.equal(receipt.pinned, true, "钉的动作确实成功了,两件事分开报");
147
+ assert.equal(receipt.injectable, false, "预算 0 = pin 池关闭,此刻不会经 pin 通道注入,回执必须报 false");
148
+
149
+ // 这里不断言「注入序列里一定没有它」:预算 0 关掉的是 **pin 通道**,不是这条记忆
150
+ // 本身——它仍可能靠自己的重要性走常规候选路进块(既有用例
151
+ // inject-pin-explicit.test.js:176 锁的是「注入序列与改前逐字节一致」,不是「不出现」)。
152
+ });
153
+
154
+ test("autoInject=false(注入器未注册)时 injectable 必须报 false", async () => {
155
+ // #596 评审指出的第二处:autoInject 是 pin 通道的总闸,关着时 src/index.js:617
156
+ // 根本不注册注入器——pin 段一行都跑不到,回执照样报 true 就是同一个「返回成功
157
+ // 不等于生效」。判据按「只有显式 false 才算关」写,故这里显式传 false。
158
+ const { store, byName } = setupPinReceipt({ autoInject: false });
159
+ const target = store.save({ type: "preference", title: "注入器没挂", content: "x", importance: 5 });
160
+ const receipt = await byName.memory_pin.execute({ id: target.id }, undefined);
161
+ assert.equal(receipt.pinned, true, "钉的动作照样成功,两件事分开报");
162
+ assert.equal(receipt.injectable, false, "autoInject 关 = 注入器不存在,此刻不可能经 pin 通道注入");
163
+
164
+ // 反例:不显式传 autoInject 时不能因为 config 里没这个键就误报 false
165
+ // (createTools 的测试直连场景拿不到已填 defaults 的 config)。
166
+ const on = setupPinReceipt({});
167
+ const t2 = on.store.save({ type: "preference", title: "默认开", content: "y", importance: 5 });
168
+ assert.equal(
169
+ (await on.byName.memory_pin.execute({ id: t2.id }, undefined)).injectable,
170
+ true,
171
+ "未显式关 autoInject 时必须报 true(预算 5 已满足)"
172
+ );
173
+ });
174
+
175
+ test("遗忘或归档的 pin:injectable 报 false(原有两项不能被这次改动弄丢)", async () => {
176
+ const { store, byName } = setupPinReceipt({});
177
+ const target = store.save({ type: "preference", title: "会被归档", content: "x", importance: 5 });
178
+ assert.equal((await byName.memory_pin.execute({ id: target.id }, undefined)).injectable, true);
179
+ store.setArchived(target.id, true);
180
+ const archived = await byName.memory_pin.execute({ id: target.id }, undefined);
181
+ assert.equal(archived.pinned, true);
182
+ assert.equal(archived.injectable, false);
183
+ store.setArchived(target.id, false);
184
+ store.setForget(target.id, true);
185
+ const forgotten = await byName.memory_pin.execute({ id: target.id }, undefined);
186
+ assert.equal(forgotten.injectable, false);
187
+ });
188
+
189
+ test("registers twelve tools with correct names", () => {
53
190
  const { registered } = setup();
54
191
  const names = registered.map((t) => t.name).sort();
55
192
  // #230 新增 memory_register_document(agent 产长文档的指针行铸造口)。
56
- assert.deepEqual(names, ["memory_archive", "memory_delete", "memory_forget", "memory_get", "memory_list", "memory_register_document", "memory_runtime", "memory_save", "memory_search", "memory_update", "set_scope_state"]);
193
+ assert.deepEqual(names, ["memory_archive", "memory_delete", "memory_forget", "memory_get", "memory_list", "memory_pin", "memory_register_document", "memory_runtime", "memory_save", "memory_search", "memory_update", "set_scope_state"]);
57
194
  });
58
195
 
59
196
  // v0.8.5 tool-exposure gate: disabling a tool hides it from the model entirely
@@ -62,19 +199,19 @@ test("registers eleven tools with correct names", () => {
62
199
  test("disableMemorySearch hides only memory_search", () => {
63
200
  const { registered } = setup({}, { disableMemorySearch: true });
64
201
  const names = registered.map((t) => t.name).sort();
65
- assert.deepEqual(names, ["memory_archive", "memory_delete", "memory_forget", "memory_get", "memory_list", "memory_register_document", "memory_runtime", "memory_save", "memory_update", "set_scope_state"]);
202
+ assert.deepEqual(names, ["memory_archive", "memory_delete", "memory_forget", "memory_get", "memory_list", "memory_pin", "memory_register_document", "memory_runtime", "memory_save", "memory_update", "set_scope_state"]);
66
203
  });
67
204
 
68
205
  test("disableMemoryArchive hides only memory_archive", () => {
69
206
  const { registered } = setup({}, { disableMemoryArchive: true });
70
207
  const names = registered.map((t) => t.name).sort();
71
- assert.deepEqual(names, ["memory_delete", "memory_forget", "memory_get", "memory_list", "memory_register_document", "memory_runtime", "memory_save", "memory_search", "memory_update", "set_scope_state"]);
208
+ assert.deepEqual(names, ["memory_delete", "memory_forget", "memory_get", "memory_list", "memory_pin", "memory_register_document", "memory_runtime", "memory_save", "memory_search", "memory_update", "set_scope_state"]);
72
209
  });
73
210
 
74
211
  test("both disable flags hide both tools", () => {
75
212
  const { registered } = setup({}, { disableMemorySearch: true, disableMemoryArchive: true });
76
213
  const names = registered.map((t) => t.name).sort();
77
- assert.deepEqual(names, ["memory_delete", "memory_forget", "memory_get", "memory_list", "memory_register_document", "memory_runtime", "memory_save", "memory_update", "set_scope_state"]);
214
+ assert.deepEqual(names, ["memory_delete", "memory_forget", "memory_get", "memory_list", "memory_pin", "memory_register_document", "memory_runtime", "memory_save", "memory_update", "set_scope_state"]);
78
215
  });
79
216
 
80
217
  // Description discipline: with all tools exposed, the descriptions must steer a
@@ -90,12 +227,12 @@ test("memory_search/archive descriptions carry tool-use discipline", () => {
90
227
 
91
228
  test("compiled schemas pass the enforced DSH subset (defineTool projection)", () => {
92
229
  const { registered } = setup();
93
- assert.equal(registered.length, 11);
230
+ assert.equal(registered.length, 12);
94
231
  // opt-in 暴露的工具在默认配置下不注册(#231 的 memory_organize),所以本用例还得
95
232
  // 把暴露闸打开再走一遍——否则新工具的编译 schema 从来没被这条护栏看过,
96
233
  // 「子集不支持的写法」要到运行时才炸。
97
234
  const { registered: exposed } = setup(undefined, { organizeEnabled: true });
98
- assert.equal(exposed.length, 12, "organizeEnabled 只多带一个工具,不许顺手改在场的别的东西");
235
+ assert.equal(exposed.length, 13, "organizeEnabled 只多带一个工具,不许顺手改在场的别的东西");
99
236
  for (const tool of [...registered, ...exposed]) {
100
237
  assertSupportedJsonSchema(tool.parameters);
101
238
  assertSupportedJsonSchema(tool.output.schema);
@@ -5,7 +5,7 @@
5
5
  import test from "node:test";
6
6
  import assert from "node:assert/strict";
7
7
  import { EventEmitter } from "node:events";
8
- import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
8
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { execFileSync } from "node:child_process";
@@ -50,6 +50,18 @@ async function invoke(route, path) {
50
50
  return { status: res.statusCode, body: JSON.parse(res.body || "{}") };
51
51
  }
52
52
 
53
+ // POST 版:带 JSON body(readBody 走 data/end 事件)
54
+ async function invokePost(route, path, body) {
55
+ const r = req(path, "POST");
56
+ process.nextTick(() => {
57
+ r.emit("data", Buffer.from(JSON.stringify(body ?? {})));
58
+ r.emit("end");
59
+ });
60
+ const res = new FakeRes();
61
+ await route.handler(r, res);
62
+ return { status: res.statusCode, body: JSON.parse(res.body || "{}") };
63
+ }
64
+
53
65
  function makeRepo(store) {
54
66
  const root = mkdtempSync(join(tmpdir(), "choro-api-"));
55
67
  writeFileSync(join(root, "package.json"), JSON.stringify({ name: "apifix", scripts: { test: "node --test" } }, null, 2) + "\n", "utf8");
@@ -151,3 +163,111 @@ test("repo 解析失败各归各位:未知 → 404;歧义 → 400 带清单
151
163
  rmTempBestEffort(root);
152
164
  }
153
165
  });
166
+
167
+ test("地志页路由区分缺失文件和不可读文件", async () => {
168
+ const store = createStore(":memory:");
169
+ const root = makeRepo(store);
170
+ try {
171
+ const { routes } = setup({ config: { chorographyEnabled: true }, choro: createChoroQueries({ store }) });
172
+ const pageRoute = handler(routes, "/api/dsh-mneme/choro/page");
173
+ const url = "/api/dsh-mneme/choro/page?slug=src";
174
+ const file = join(root, ".chorography", "modules", "src.md");
175
+ unlinkSync(file);
176
+ const missing = await invoke(pageRoute, url);
177
+ assert.equal(missing.status, 409);
178
+ assert.equal(missing.body.error, "page-missing");
179
+ assert.match(missing.body.hint, /dsh-mneme-choro/);
180
+ mkdirSync(file);
181
+ const unreadable = await invoke(pageRoute, url);
182
+ assert.equal(unreadable.status, 409);
183
+ assert.equal(unreadable.body.error, "page-unreadable");
184
+ assert.match(unreadable.body.hint, /permissions/);
185
+ } finally {
186
+ store.close();
187
+ rmTempBestEffort(root);
188
+ }
189
+ });
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // 收尾档:POST /choro/run(面板重建入口,P34)+ /choro/search 的 semantic 路(P37–P39)
193
+ // ---------------------------------------------------------------------------
194
+ test("/choro/run:policy token-write,GET 405,POST 快乐路径重跑 T0", async () => {
195
+ const store = createStore(":memory:");
196
+ const root = makeRepo(store);
197
+ try {
198
+ const { routes } = setup({
199
+ config: { chorographyEnabled: true },
200
+ choro: {
201
+ ...createChoroQueries({ store }),
202
+ // 与 index.js 的装配同形:runT0Apply 闭包住 store(P35:写路径与读侧分开装配)
203
+ runT0Apply: ({ repoRoot }) => runChoroT0({ repoRoot, store, apply: true }),
204
+ },
205
+ });
206
+ const route = handler(routes, "/api/dsh-mneme/choro/run");
207
+ assert.equal(route.policy, "token-write", "写仓库磁盘的口归 token-write");
208
+
209
+ const get = await invoke(route, "/api/dsh-mneme/choro/run");
210
+ assert.equal(get.status, 405, "只收 POST");
211
+
212
+ // 破坏页面文件后重建:POST 走真 T0,页回来
213
+ rmSync(join(root, ".chorography", "modules", "src.md"));
214
+ const ok = await invokePost(route, "/api/dsh-mneme/choro/run", { repo: 1 });
215
+ assert.equal(ok.status, 200);
216
+ assert.equal(ok.body.ok, true);
217
+ assert.ok(ok.body.receipt.moduleCount >= 2, "receipt 带划分计数");
218
+ assert.ok(existsSync(join(root, ".chorography", "modules", "src.md")), "重建把页写了回来");
219
+ } finally {
220
+ rmTempBestEffort(root);
221
+ }
222
+ });
223
+
224
+ test("/choro/run:开关关 409;repo 未知 404(与读路由同一道闸)", async () => {
225
+ const store = createStore(":memory:");
226
+ const root = makeRepo(store);
227
+ try {
228
+ const off = setup({
229
+ config: { chorographyEnabled: false },
230
+ choro: { ...createChoroQueries({ store }), runT0Apply: () => { throw new Error("不该被调到"); } },
231
+ });
232
+ const a = await invokePost(handler(off.routes, "/api/dsh-mneme/choro/run"), "/api/dsh-mneme/choro/run", { repo: 1 });
233
+ assert.equal(a.status, 409);
234
+
235
+ const on = setup({
236
+ config: { chorographyEnabled: true },
237
+ choro: { ...createChoroQueries({ store }), runT0Apply: () => { throw new Error("不该被调到") } },
238
+ });
239
+ const b = await invokePost(handler(on.routes, "/api/dsh-mneme/choro/run"), "/api/dsh-mneme/choro/run", { repo: 99 });
240
+ assert.equal(b.status, 404);
241
+ } finally {
242
+ rmTempBestEffort(root);
243
+ }
244
+ });
245
+
246
+ test("/choro/search:embedder 在场 → 惰性物化 + semantic 数组;缺席 → semantic null", async () => {
247
+ const store = createStore(":memory:");
248
+ const root = makeRepo(store);
249
+ try {
250
+ // stub embedder:向量按内容方向分类(src 相关 → [1,0]),保证可排序断言
251
+ const embedder = {
252
+ model: "stub-1",
253
+ embedSingle: async (text) => (/src|b\.js/.test(text) ? [1, 0] : [0, 1]),
254
+ embed: async (text) => (/src|b\.js/.test(text) ? [1, 0] : [0, 1]),
255
+ };
256
+ const { routes } = setup({
257
+ config: { chorographyEnabled: true },
258
+ choro: { ...createChoroQueries({ store }), embedder },
259
+ });
260
+ const s = await invoke(handler(routes, "/api/dsh-mneme/choro/search"), "/api/dsh-mneme/choro/search?repo=1&q=b.js");
261
+ assert.equal(s.status, 200);
262
+ assert.ok(Array.isArray(s.body.hits) && s.body.hits.length >= 1, "keyword hits 照旧");
263
+ assert.ok(Array.isArray(s.body.semantic) && s.body.semantic.length >= 1, "semantic 数组在场(惰性物化发生在这次请求里)");
264
+ assert.equal(s.body.semantic[0].slug, "src", "与查询同向的页排前");
265
+ assert.equal(typeof s.body.semantic[0].score, "number");
266
+
267
+ const noEmbedder = setup({ config: { chorographyEnabled: true }, choro: createChoroQueries({ store }) });
268
+ const t = await invoke(handler(noEmbedder.routes, "/api/dsh-mneme/choro/search"), "/api/dsh-mneme/choro/search?repo=1&q=b.js");
269
+ assert.equal(t.body.semantic, null, "embedder 缺席 → null(调用方退纯关键词)");
270
+ } finally {
271
+ rmTempBestEffort(root);
272
+ }
273
+ });
@@ -4,7 +4,7 @@
4
4
  // 盘上缺页如实报 page-missing、检索分节与行号、repo 解析的歧义处理。
5
5
  import test from "node:test";
6
6
  import assert from "node:assert/strict";
7
- import { mkdtempSync, rmSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs";
7
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync, unlinkSync, readFileSync } from "node:fs";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join } from "node:path";
10
10
  import { execFileSync } from "node:child_process";
@@ -46,6 +46,89 @@ function setup() {
46
46
  return { root, store, q: createChoroQueries({ store }) };
47
47
  }
48
48
 
49
+ // stub embedder:文本含 "src" → [1,0],否则 [0,1](固定二维,可断言方向与失配)
50
+ function stubEmbedder(model = "stub-1") {
51
+ return {
52
+ model,
53
+ embedSingle: async (text) => (/src/.test(text) ? [1, 0] : [0, 1]),
54
+ embed: async (text) => (/src/.test(text) ? [1, 0] : [0, 1]),
55
+ };
56
+ }
57
+
58
+ // --- 收尾档 P37–P39:向量惰性物化与检索 -------------------------------------
59
+
60
+ test("embedPageVectors:首次全嵌,generated_at 没变就跳过,变了重嵌(staleness 两维之一)", async () => {
61
+ const { root, store, q } = setup();
62
+ try {
63
+ const repo = q.resolveRepo(undefined);
64
+ const emb = stubEmbedder();
65
+ const r1 = await q.embedPageVectors(repo.id, emb);
66
+ assert.equal(r1.embedded, 2, "两页全嵌");
67
+ const r2 = await q.embedPageVectors(repo.id, emb);
68
+ assert.equal(r2.embedded, 0);
69
+ assert.equal(r2.skipped, 2, "generated_at 没变 → 全跳过");
70
+ // 页面内容变了(generated_at 推进)→ 只重嵌变的那页
71
+ const pagePath = join(root, ".chorography", "modules", "src.md");
72
+ const before = readFileSync(pagePath, "utf8");
73
+ writeFileSync(pagePath, `${before}<!-- CHORO:HUMAN:START -->\n人工补充\n<!-- CHORO:HUMAN:END -->\n`, "utf8");
74
+ store.db.prepare(
75
+ "UPDATE choro_pages SET generated_at = ? WHERE generated_at IS NOT NULL AND id IN (SELECT p.id FROM choro_pages p JOIN choro_modules m ON p.module_id = m.id WHERE m.slug = 'src')"
76
+ ).run("2026-11-06T00:00:00.000Z");
77
+ const r3 = await q.embedPageVectors(repo.id, emb);
78
+ assert.equal(r3.embedded, 1, "只有 src 页重嵌");
79
+ assert.equal(r3.stale, 1, "旧向量按 stale 记账");
80
+ } finally {
81
+ store.close();
82
+ rmTempBestEffort(root);
83
+ }
84
+ });
85
+
86
+ test("dim 失配(换 embedder 模型)→ 惰性全量重嵌(P38:不锁死,代价只是几十页)", async () => {
87
+ const { root, store, q } = setup();
88
+ try {
89
+ const repo = q.resolveRepo(undefined);
90
+ await q.embedPageVectors(repo.id, stubEmbedder("stub-1"));
91
+ // 换模型:维度从 2 → 3。expectDim 来自查询向量(路由先嵌查询再物化)
92
+ const emb3 = {
93
+ model: "stub-2",
94
+ embedSingle: async () => [1, 0, 0],
95
+ embed: async () => [1, 0, 0],
96
+ };
97
+ const r = await q.embedPageVectors(repo.id, emb3, { expectDim: 3 });
98
+ assert.equal(r.embedded, 2, "维度变了 → 全部重嵌(fresh 检查被 dimDrift 压过)");
99
+ assert.equal(r.dimMismatch, true, "失配被记账");
100
+ const rows = store.getChoroPageVectors(repo.id);
101
+ assert.ok(rows.every((row) => row.dim === 3 && row.model === "stub-2"), "旧维度行不复存");
102
+ // 不给 expectDim(无查询场景)→ fresh 行照旧跳过
103
+ const r2 = await q.embedPageVectors(repo.id, emb3);
104
+ assert.equal(r2.embedded, 0);
105
+ assert.equal(r2.skipped, 2);
106
+ } finally {
107
+ store.close();
108
+ rmTempBestEffort(root);
109
+ }
110
+ });
111
+
112
+ test("searchSemantic:cosine 排序 + dim 失配行被过滤 + embedder 缺席返回 null", async () => {
113
+ const { root, store, q } = setup();
114
+ try {
115
+ const repo = q.resolveRepo(undefined);
116
+ await q.embedPageVectors(repo.id, stubEmbedder("stub-1"));
117
+ const hits = await q.searchSemantic(repo.id, stubEmbedder("stub-1"), "src 相关的页");
118
+ assert.ok(Array.isArray(hits) && hits.length >= 1);
119
+ assert.equal(hits[0].slug, "src", "查询向量 [1,0] 与 src 页同向 → 排前");
120
+ // 查询向量三维:与所有行 dim 失配 → 无可比行,返回空数组而不是抛
121
+ const dim3 = { embedSingle: async () => [1, 0, 0], embed: async () => [1, 0, 0] };
122
+ const empty = await q.searchSemantic(repo.id, dim3, "x");
123
+ assert.deepEqual(empty, []);
124
+ assert.equal(await q.searchSemantic(repo.id, null, "x"), null);
125
+ assert.deepEqual(await q.searchSemantic(repo.id, stubEmbedder(), " "), [], "空查询不嵌入、返回空数组");
126
+ } finally {
127
+ store.close();
128
+ rmTempBestEffort(root);
129
+ }
130
+ });
131
+
49
132
  test("listRepos / resolveRepo:单仓自动收敛,多仓歧义要显式给键", () => {
50
133
  const { root, store, q } = setup();
51
134
  try {