@modusensus/dsh-mneme 0.8.13 → 0.8.14

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.
@@ -316,6 +316,30 @@ test("entity: / attr: 的 scope 闸在触达之前:出局的行不被回温,
316
316
  assert.equal(store.getById(theirs.id).last_accessed_at ?? null, null, "出局行不该被回温");
317
317
  });
318
318
 
319
+ test("injectCandidates:strictScope 硬过滤必须晚于图召回合并(graphHint 不能把出局行带回来)", async () => {
320
+ // 回归锁(CodeRabbit on #371 的可达性分析,#17 A3 的图召回分支):entityRecall
321
+ // 本身不做 scope 门控,硬过滤原本长在图合并**之前**——过滤出局的显式他域行会
322
+ // 被图召回重新打上 graphHint 带回候选池,graphInjectHint 开时即作为线索行进入
323
+ // 注入块(宿主注入与 /context 同受影响)。硬过滤移到图合并之后、pin 池之前封死。
324
+ // 夹具注意:只挂 theirs 一条——挂满全 battery 会让每行都成 graphHint 行、被
325
+ // graphInjectBudget=1 的线索预算挤到失真(写这条测试时踩过)。
326
+ const { store, service } = setup({
327
+ scopeEnabled: true, strictScope: true, entityRecallEnabled: true, graphInjectHint: true
328
+ });
329
+ const rows = saveScopeBattery(store);
330
+ linkToEntity(store, "阿尔托", [rows.theirs]);
331
+
332
+ const asMe = service.injectCandidates({ query: "阿尔托", maxItems: 5, scope: { agent_scope: "me", workspace_scope: null } });
333
+ const titles = asMe.map((m) => m.title);
334
+ assert.ok(!titles.includes("theirs"), "图召回不得把显式他域行带进注入块");
335
+ assert.ok(titles.includes("global"), "未挂联的可见行照常经规则路进块(排除真空假绿)");
336
+
337
+ // 正向对照:原主人自己取,显式行仍在(作为线索行或普通行均可——否则是
338
+ // 「谁都看不到」的假绿)。
339
+ const asOther = service.injectCandidates({ query: "阿尔托", maxItems: 5, scope: { agent_scope: "other", workspace_scope: null } });
340
+ assert.ok(asOther.some((m) => m.title === "theirs"), "命中当前 agent 的显式行必须可见");
341
+ });
342
+
319
343
  test("scope 闸先于 topK 截断:出局的候选不占名额(topK=1 仍有可见结果)", async () => {
320
344
  // 回归锁(评审发现):先 slice 再 filter 的话,排在前面那条被闸掉的候选会占住唯一的
321
345
  // 槽位,明明还有可见匹配却返回空数组。用 topK=1 把次序钉死——排序不动,只是闸门要插
@@ -0,0 +1,121 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { spawn } from "node:child_process";
4
+ import { mkdtempSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { createStore } from "../src/store.js";
9
+ import { createService } from "../src/service.js";
10
+ import { createSettings } from "../src/settings.js";
11
+
12
+ // serve bin 冒烟 + 多进程共存锁(daemon 唯一的真新风险点):daemon 子进程与测试进程
13
+ // 同时打开同一 memory.db(WAL + busy_timeout 本就为此设计),互写互读。spawn 必须
14
+ // 异步(maintenance.test.js 的教训:同步 spawn 会堵住本进程事件循环);Windows 下
15
+ // child.kill() 是硬终止,不断言信号路径,只断言可达性与退出。
16
+
17
+ const BIN = fileURLToPath(new URL("../bin/dsh-mneme-serve.mjs", import.meta.url));
18
+
19
+ function wait(ms) {
20
+ return new Promise((r) => setTimeout(r, ms));
21
+ }
22
+
23
+ async function waitFor(fn, timeoutMs, what) {
24
+ const deadline = Date.now() + timeoutMs;
25
+ let lastErr;
26
+ while (Date.now() < deadline) {
27
+ try {
28
+ return await fn();
29
+ } catch (err) {
30
+ lastErr = err;
31
+ }
32
+ await wait(150);
33
+ }
34
+ throw new Error(`timeout waiting for ${what}: ${lastErr?.message ?? lastErr}`);
35
+ }
36
+
37
+ test("serve bin: 值旗标缺值直接报错退出(--port 后无值不能落成 Number(true)=1)", { timeout: 30000 }, async () => {
38
+ // CodeRabbit on #364:--port 紧跟另一个旗标或结束时,旧解析把它存成 true,
39
+ // Number(true)=1 通过校验 → 静默改绑端口 1(EACCES 误导排错方向)。
40
+ const child = spawn(process.execPath, [BIN, "--memory-dir", mkdtempSync(join(tmpdir(), "mneme-serve-arg-")), "--port"], {
41
+ stdio: ["ignore", "pipe", "pipe"]
42
+ });
43
+ let stderr = "";
44
+ child.stderr.on("data", (d) => { stderr += d; });
45
+ const [code] = await new Promise((resolve) => {
46
+ child.on("exit", (c) => resolve([c]));
47
+ });
48
+ assert.notEqual(code, 0, "missing value must exit non-zero");
49
+ assert.ok(stderr.includes("缺少参数值"), `stderr should name the missing value, got: ${stderr.slice(-200)}`);
50
+ });
51
+
52
+ test("serve bin: spawn 冒烟;daemon 与宿主进程同库互写互读", { timeout: 120000 }, async () => {
53
+ const dir = mkdtempSync(join(tmpdir(), "mneme-serve-bin-"));
54
+
55
+ // 预置 token:测试进程先开一次库写进 kv external_api,daemon 复用之 —— 同时验证
56
+ // 「与宿主面板/CLI 共用同一凭证」这条零配置承诺在真子进程里成立。
57
+ const seedStore = createStore(join(dir, "memory.db"));
58
+ const seedSettings = createSettings(seedStore.db);
59
+ const TOKEN = "serve-bin-test-token-0123456789abcdef";
60
+ seedSettings.setExternalApi({ token: TOKEN });
61
+ const peer = createService({ store: seedStore, mirror: null, config: {} });
62
+ peer.saveWithDedupe({ type: "project", title: "peer 进程直写", content: "测试进程经 createStore 写入", importance: 3 });
63
+
64
+ // --embed off:CI 无 runtime payload,绝不能触发取件;多进程共存与语义无关
65
+ const child = spawn(process.execPath, [BIN, "--memory-dir", dir, "--port", "0", "--embed", "off"], {
66
+ stdio: ["ignore", "pipe", "pipe"]
67
+ });
68
+ let stdout = "";
69
+ let stderr = "";
70
+ child.stdout.on("data", (d) => { stdout += d; });
71
+ child.stderr.on("data", (d) => { stderr += d; });
72
+
73
+ try {
74
+ // stdout 唯一机器可读行:listening 行(解析实际端口)
75
+ await waitFor(() => {
76
+ if (child.exitCode !== null) {
77
+ throw new Error(`daemon exited early (code ${child.exitCode}): ${stderr.slice(-400)}`);
78
+ }
79
+ if (!stdout.includes("listening on http://")) throw new Error("no listening line yet");
80
+ return true;
81
+ }, 20000, "listening line");
82
+ const port = Number(stdout.match(/:(\d+)/)[1]);
83
+ const base = `http://127.0.0.1:${port}`;
84
+ const auth = { authorization: `Bearer ${TOKEN}` };
85
+
86
+ await waitFor(async () => {
87
+ const res = await fetch(`${base}/health`);
88
+ assert.equal(res.status, 200);
89
+ return true;
90
+ }, 10000, "daemon /health");
91
+
92
+ // 双向可见性 ①:测试进程直写 → daemon HTTP 检索可见
93
+ await waitFor(async () => {
94
+ const res = await fetch(`${base}/search?q=${encodeURIComponent("peer 进程直写")}`, { headers: auth });
95
+ assert.equal(res.status, 200);
96
+ const body = await res.json();
97
+ assert.ok((body.items ?? []).some((m) => m.title === "peer 进程直写"));
98
+ return true;
99
+ }, 10000, "peer write visible via daemon");
100
+
101
+ // 双向可见性 ②:daemon HTTP 写 → 测试进程 service 同步检索可见
102
+ const save = await fetch(`${base}/memories`, {
103
+ method: "POST",
104
+ headers: { ...auth, "content-type": "application/json" },
105
+ body: JSON.stringify({ type: "project", title: "daemon HTTP 写入", content: "serve 进程经数据面写入", importance: 3 })
106
+ });
107
+ assert.ok(save.status === 201 || save.status === 200, `daemon save status ${save.status}`);
108
+ assert.ok(
109
+ peer.search("daemon HTTP 写入", { limit: 5 }).some((m) => m.title === "daemon HTTP 写入"),
110
+ "daemon-side write must be visible to the other process immediately"
111
+ );
112
+ } finally {
113
+ child.kill();
114
+ await new Promise((r) => {
115
+ if (child.exitCode !== null) r();
116
+ else child.on("exit", r);
117
+ });
118
+ try { seedStore.close(); } catch { /* 同上 */ }
119
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* win 文件锁偶发,残留临时目录无害 */ }
120
+ }
121
+ });
@@ -0,0 +1,196 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { createServeRuntime } from "../src/serve.js";
7
+
8
+ // daemon 装配面锁(test/serve-bin.test.js 另有真子进程 + 多进程共存):
9
+ // in-process 起真 HTTP(port 0,OS 分配),fetch 走 health / 401 / save / search /
10
+ // token 持久化复用全链路。锁的是「daemon 数据面 = api-standalone 同一工厂」这一契约。
11
+ // 基础用例一律 embed:"off"——不碰取件与模型 init,CI 确定性;向量走注入假 embedder。
12
+
13
+ function tmpDir() {
14
+ return mkdtempSync(join(tmpdir(), "mneme-serve-"));
15
+ }
16
+
17
+ /** 确定性 4 维伪嵌入:同词相关、异词近正交,足以让向量路径产生非零命中。 */
18
+ function fakeEmbedder() {
19
+ return {
20
+ ready: true,
21
+ modelHash: "fake-hash-1",
22
+ dimension: 4,
23
+ embedSingle: async (text) => {
24
+ const v = [0, 0, 0, 0];
25
+ for (let i = 0; i < text.length; i++) v[i % 4] += text.charCodeAt(i) % 7;
26
+ const n = Math.hypot(v[0], v[1], v[2], v[3]) || 1;
27
+ return v.map((x) => x / n);
28
+ }
29
+ };
30
+ }
31
+
32
+ async function waitFor(fn, timeoutMs, what) {
33
+ const deadline = Date.now() + timeoutMs;
34
+ let lastErr;
35
+ while (Date.now() < deadline) {
36
+ try { return await fn(); } catch (err) { lastErr = err; }
37
+ await new Promise((r) => setTimeout(r, 100));
38
+ }
39
+ throw new Error(`timeout waiting for ${what}: ${lastErr?.message ?? lastErr}`);
40
+ }
41
+
42
+ test("serve: /health 免鉴权,业务路由无 token 401", async () => {
43
+ const dir = tmpDir();
44
+ const rt = await createServeRuntime({ memoryDir: dir, port: 0, embed: "off" });
45
+ await rt.api.ready;
46
+ try {
47
+ const base = `http://127.0.0.1:${rt.api.port}`;
48
+ const health = await fetch(`${base}/health`);
49
+ assert.equal(health.status, 200);
50
+ assert.deepEqual(await health.json(), { ok: true });
51
+
52
+ const noAuth = await fetch(`${base}/search?q=x`);
53
+ assert.equal(noAuth.status, 401);
54
+ assert.deepEqual(await noAuth.json(), { error: "unauthorized" });
55
+ } finally {
56
+ rt.dispose();
57
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* win 文件锁偶发 */ }
58
+ }
59
+ });
60
+
61
+ test("serve: save → search 走通;token 持久化 kv;二次启动复用同一 token;检索回执落 recall_runs", async () => {
62
+ const dir = tmpDir();
63
+ const rt = await createServeRuntime({ memoryDir: dir, port: 0, embed: "off" });
64
+ await rt.api.ready;
65
+ const base = `http://127.0.0.1:${rt.api.port}`;
66
+ const auth = { authorization: `Bearer ${rt.api.token}` };
67
+
68
+ // 全新目录:首次启动生成 token
69
+ assert.equal(rt.tokenExisted, false);
70
+
71
+ const save = await fetch(`${base}/memories`, {
72
+ method: "POST",
73
+ headers: { ...auth, "content-type": "application/json" },
74
+ body: JSON.stringify({ type: "preference", title: "serve 冒烟偏好", content: "回复保持简短", importance: 4 })
75
+ });
76
+ assert.equal(save.status, 201);
77
+
78
+ const search = await fetch(`${base}/search?q=${encodeURIComponent("简短")}`, { headers: auth });
79
+ assert.equal(search.status, 200);
80
+ const searchBody = await search.json();
81
+ assert.ok((searchBody.items ?? []).some((m) => m.title === "serve 冒烟偏好"));
82
+
83
+ // token 持久化在 kv external_api(与 DSH 面板/CLI 共用同一凭证通道)
84
+ assert.equal(rt.settings.getExternalApi().token, rt.api.token);
85
+
86
+ // 检索回执:recorder 已接(index.js:294-309 同款),recall_runs 不缺数 —— #363 回帖
87
+ // 向 bridge 承诺过「第三方检索的复用统计不受影响」,这条就是该承诺的回归锁。
88
+ const recallRows = rt.store.db.prepare("SELECT COUNT(*) AS n FROM recall_runs").get();
89
+ assert.ok(recallRows.n >= 1, "recall_runs should record the search above");
90
+
91
+ rt.dispose();
92
+
93
+ // 二次启动同目录:token 复用不重新生成(firstBoot 提示只在真正首次出现)
94
+ const rt2 = await createServeRuntime({ memoryDir: dir, port: 0, embed: "off" });
95
+ await rt2.api.ready;
96
+ try {
97
+ assert.equal(rt2.tokenExisted, true);
98
+ assert.equal(rt2.api.token, rt.api.token);
99
+ } finally {
100
+ rt2.dispose();
101
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* 同上 */ }
102
+ }
103
+ });
104
+
105
+ test("serve: dispose 后端口可复用(同端口连起两轮不踩 strictPort)", async () => {
106
+ const dir = tmpDir();
107
+ const rt = await createServeRuntime({ memoryDir: dir, port: 0, embed: "off" });
108
+ await rt.api.ready;
109
+ const port = rt.api.port;
110
+ // dispose 现为 async:等 server.close 回调(在途请求排干)后再重绑
111
+ await rt.dispose();
112
+ // 端口释放可能有内核级迟滞,重试绑定而不是假设立即可用
113
+ let rebound = null;
114
+ for (let i = 0; i < 10 && !rebound; i++) {
115
+ try {
116
+ const rt2 = await createServeRuntime({ memoryDir: dir, port, embed: "off" });
117
+ await rt2.api.ready;
118
+ rebound = rt2;
119
+ } catch (err) {
120
+ if (err?.code !== "EADDRINUSE") throw err;
121
+ await new Promise((r) => setTimeout(r, 100));
122
+ }
123
+ }
124
+ try {
125
+ assert.ok(rebound, "port should be rebindable after dispose");
126
+ assert.equal(rebound.api.port, port);
127
+ } finally {
128
+ await rebound?.dispose();
129
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* 同上 */ }
130
+ }
131
+ });
132
+
133
+ test("serve: 注入假 embedder → 写入即嵌入,vector 轴接管检索;auto 检索可用", async () => {
134
+ const dir = tmpDir();
135
+ const fake = fakeEmbedder();
136
+ const rt = await createServeRuntime({ memoryDir: dir, port: 0, embed: "off", embedder: fake });
137
+ await rt.api.ready;
138
+ try {
139
+ // 覆盖注入生效:semantic 被绕过,embedder 直接挂 service
140
+ assert.equal(rt.semantic, null);
141
+
142
+ const base = `http://127.0.0.1:${rt.api.port}`;
143
+ const auth = { authorization: `Bearer ${rt.api.token}` };
144
+ const save = await fetch(`${base}/memories`, {
145
+ method: "POST",
146
+ headers: { ...auth, "content-type": "application/json" },
147
+ body: JSON.stringify({ type: "project", title: "向量注入冒烟", content: "daemon 侧写入即嵌入", importance: 3 })
148
+ });
149
+ assert.equal(save.status, 201);
150
+
151
+ // 写入侧嵌入是异步排队的:轮询到 vector 轴命中为止。auto 模式不断言 mode 字段 ——
152
+ // 标题同词命中时融合合并对象是 keyword 行,上游 mneme 的 auto 报告语义如此
153
+ // (vector:true 标记只在纯向量来源上保留),那是上游行为,不是 daemon 契约。
154
+ await waitFor(async () => {
155
+ const res = await fetch(`${base}/search?q=${encodeURIComponent("向量注入冒烟")}&mode=vector`, { headers: auth });
156
+ assert.equal(res.status, 200);
157
+ const body = await res.json();
158
+ assert.equal(body.mode, "vector", "mode=vector must report the vector axis");
159
+ assert.ok((body.items ?? []).some((m) => m.title === "向量注入冒烟"));
160
+ return true;
161
+ }, 8000, "vector-axis search");
162
+
163
+ const autoRes = await fetch(`${base}/search?q=${encodeURIComponent("向量注入冒烟")}&mode=auto`, { headers: auth });
164
+ assert.equal(autoRes.status, 200);
165
+ assert.ok(((await autoRes.json()).items ?? []).some((m) => m.title === "向量注入冒烟"));
166
+
167
+ // /context 全链路(issue #370):daemon 与宿主外部访问共用同一工厂,注入
168
+ // 候选端点一并带出——同一 embedder 句柄喂查询向量,injectCandidates 走通。
169
+ const ctxRes = await fetch(`${base}/context?q=${encodeURIComponent("向量注入冒烟")}`, { headers: auth });
170
+ assert.equal(ctxRes.status, 200);
171
+ const ctxBody = await ctxRes.json();
172
+ assert.ok((ctxBody.items ?? []).some((m) => m.title === "向量注入冒烟"));
173
+ // #217 注入分账:injectCandidates 的曝光型访问事件落 recall_runs
174
+ // (mode='inject'),第三方的注入统计与宿主注入同表同口径。
175
+ const injectRuns = rt.store.db.prepare("SELECT count(*) AS c FROM recall_runs WHERE mode = 'inject'").get().c;
176
+ assert.ok(injectRuns >= 1, "injection access must be receipted in recall_runs (#217)");
177
+ } finally {
178
+ rt.dispose();
179
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* 同上 */ }
180
+ }
181
+ });
182
+
183
+ test("serve: 非法 embed provider 拒装配;embed=off 时 semantic 为 null", async () => {
184
+ const dir = tmpDir();
185
+ await assert.rejects(
186
+ createServeRuntime({ memoryDir: dir, port: 0, embed: "bogus" }),
187
+ /invalid embed provider/
188
+ );
189
+ const rt = await createServeRuntime({ memoryDir: dir, port: 0, embed: "off" });
190
+ try {
191
+ assert.equal(rt.semantic, null, "off path must not assemble a semantic pipeline");
192
+ } finally {
193
+ rt.dispose();
194
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* 同上 */ }
195
+ }
196
+ });
@@ -4,14 +4,15 @@ import { createStore } from "../src/store.js";
4
4
  import { createService } from "../src/service.js";
5
5
  import { createSettings } from "../src/settings.js";
6
6
  import { createStandaloneApi } from "../src/api-standalone.js";
7
+ import { PACKAGE_VERSION } from "../src/version-check.js";
7
8
  import { Config, applyLightModePreset } from "../src/config.js";
8
9
 
9
10
  // Real HTTP server on an OS-assigned port (port: 0), driven with fetch.
10
- async function setup() {
11
+ async function setup({ config = {}, embedder = null, embedTimeoutMs } = {}) {
11
12
  const store = createStore(":memory:");
12
- const service = createService({ store, mirror: null, config: {} });
13
+ const service = createService({ store, mirror: null, config });
13
14
  const settings = createSettings(store.db);
14
- const api = createStandaloneApi({ service, store, config: {}, settings, logger: null, port: 0 });
15
+ const api = createStandaloneApi({ service, store, config, settings, logger: null, port: 0, embedder, embedTimeoutMs });
15
16
  await api.ready;
16
17
  const base = `http://127.0.0.1:${api.port}`;
17
18
  const auth = { authorization: `Bearer ${api.token}` };
@@ -40,7 +41,7 @@ test("GET /health is open without a token; every other route 401s", async () =>
40
41
  assert.equal(health.status, 200);
41
42
  assert.deepEqual(await health.json(), { ok: true });
42
43
 
43
- for (const path of ["/status", "/profile", "/rules", "/memories", "/memories/x", "/search?q=x"]) {
44
+ for (const path of ["/status", "/profile", "/rules", "/memories", "/memories/x", "/search?q=x", "/context"]) {
44
45
  const res = await fetch(`${base}${path}`);
45
46
  assert.equal(res.status, 401, `${path} requires a token`);
46
47
  assert.deepEqual(await res.json(), { error: "unauthorized" });
@@ -249,7 +250,9 @@ test("GET /status reports version, per-type totals, entities and uptime", async
249
250
  const res = await fetch(`${base}/status`, { headers: auth });
250
251
  assert.equal(res.status, 200);
251
252
  const body = await res.json();
252
- assert.equal(body.version, "0.7.12");
253
+ // 回归锁:/status 的版本必须来自 package.json(PACKAGE_VERSION),不是散落的
254
+ // 字面量——曾经此处硬编码常量在发版时漏 bump,运行 0.8.13 却对外报 0.7.12。
255
+ assert.equal(body.version, PACKAGE_VERSION);
253
256
  assert.equal(body.memories.total, 3);
254
257
  assert.equal(body.memories.byType.preference, 1);
255
258
  assert.equal(body.memories.byType.project, 2);
@@ -542,3 +545,207 @@ test("GET /search honors the occurred_at window", async () => {
542
545
  close();
543
546
  }
544
547
  });
548
+
549
+ // --- GET /context(issue #370,#363 承诺)---------------------------------------
550
+
551
+ /** /context 用例的省事入库:走 POST /memories,与第三方调用同路径。 */
552
+ async function saveMemory(base, auth, body) {
553
+ const res = await fetch(`${base}/memories`, {
554
+ method: "POST",
555
+ headers: { ...auth, "content-type": "application/json" },
556
+ body: JSON.stringify(body)
557
+ });
558
+ assert.ok(res.status === 201 || res.status === 200, `save ${body.title} → ${res.status}`);
559
+ return res.json();
560
+ }
561
+
562
+ test("GET /search passes a declared scope through (strictScope hard wall on the search face too)", async () => {
563
+ // issue #370:searchMemories 本就收 scope,8790 此前没有入口——透传后第三方
564
+ // 声明身份,A2 软加权与 strictScope 硬过滤在检索面同样成立;不传参数行为
565
+ // 与既往逐字节一致(scopeFromSearchParams 返回 undefined → 不进 options)。
566
+ const { base, auth, close } = await setup({ config: { strictScope: true } });
567
+ try {
568
+ await saveMemory(base, auth, { type: "decision", title: "他域检索", content: "外部线索 abc", importance: 5, agent_scope: "other-agent" });
569
+
570
+ const scoped = await (await fetch(`${base}/search?q=${encodeURIComponent("外部线索")}&agent_scope=me`, { headers: auth })).json();
571
+ assert.ok(!scoped.items.some((m) => m.title === "他域检索"), "explicit foreign agent_scope row is hard-filtered");
572
+
573
+ const unscoped = await (await fetch(`${base}/search?q=${encodeURIComponent("外部线索")}`, { headers: auth })).json();
574
+ assert.ok(unscoped.items.some((m) => m.title === "他域检索"), "no scope declared → gate dormant, behavior unchanged");
575
+ } finally {
576
+ close();
577
+ }
578
+ });
579
+
580
+ test("GET /context returns the injection-shaped aggregate; empty q still serves the rule tier", async () => {
581
+ const { base, auth, settings, close } = await setup();
582
+ try {
583
+ settings.setProfile("我是后端工程师");
584
+ settings.setRules(["先验证再修改"]);
585
+ await saveMemory(base, auth, { type: "decision", title: "核心决策", content: "选定 SQLite 单文件库", importance: 5 });
586
+ await saveMemory(base, auth, { type: "preference", title: "默认中文", content: "回复默认用中文", importance: 4 });
587
+
588
+ const res = await fetch(`${base}/context`, { headers: auth });
589
+ assert.equal(res.status, 200);
590
+ const body = await res.json();
591
+ // 形状锁:四键响应——bridge 等调用方按这四个键渲染,漂移(加塞/漏带)在此变红。
592
+ assert.deepEqual(Object.keys(body).sort(), ["items", "pinnedCount", "profile", "rules"]);
593
+ assert.equal(body.profile, "我是后端工程师");
594
+ assert.deepEqual(body.rules, ["先验证再修改"]);
595
+ assert.equal(body.pinnedCount, 0, "pinnedInjectBudget 默认 0(opt-in 纪律,issue #370 取舍 3)");
596
+ const titles = body.items.map((m) => m.title);
597
+ assert.ok(titles.includes("核心决策") && titles.includes("默认中文"), "empty q → pure rule tier, high-importance rows still served");
598
+ for (const item of body.items) {
599
+ assert.ok(item.id && item.type && item.title && typeof item.content === "string" && Number.isInteger(item.importance));
600
+ }
601
+ } finally {
602
+ close();
603
+ }
604
+ });
605
+
606
+ test("GET /context threshold is an importance gate (not /search's similarity threshold)", async () => {
607
+ // 同名不同义锁(issue #370 点名的文档区分):/context 的 threshold 走
608
+ // injectCandidates 的 importance 门(summary/preference 豁免),与相似度无关
609
+ // ——本用例全程无 embedder/向量,纯规则档即可验证。
610
+ const { base, auth, close } = await setup();
611
+ try {
612
+ await saveMemory(base, auth, { type: "decision", title: "低重要", content: "小事一桩", importance: 3 });
613
+ await saveMemory(base, auth, { type: "decision", title: "高重要", content: "大事记", importance: 5 });
614
+
615
+ const open = await (await fetch(`${base}/context`, { headers: auth })).json();
616
+ const openTitles = open.items.map((m) => m.title);
617
+ assert.ok(openTitles.includes("低重要") && openTitles.includes("高重要"), "default threshold=3 admits both");
618
+
619
+ const gated = await (await fetch(`${base}/context?threshold=4`, { headers: auth })).json();
620
+ const gatedTitles = gated.items.map((m) => m.title);
621
+ assert.ok(gatedTitles.includes("高重要"), "importance 5 survives threshold=4");
622
+ assert.ok(!gatedTitles.includes("低重要"), "importance 3 is gated out at threshold=4");
623
+ } finally {
624
+ close();
625
+ }
626
+ });
627
+
628
+ test("GET /context pins preference rows ahead when pinnedInjectBudget is on", async () => {
629
+ // #249 pin 池经 8790 透出。用 preference 而非 constraint:constraint 属编码
630
+ // 记忆,非编码查询被 codingGate 排除在候选池外,pin 池也够不着它。
631
+ const { base, auth, close } = await setup({ config: { pinnedInjectBudget: 2 } });
632
+ try {
633
+ await saveMemory(base, auth, { type: "preference", title: "pin-偏好", content: "永远先讲结论", importance: 5 });
634
+ await saveMemory(base, auth, { type: "decision", title: "普通决策", content: "先做 A 再做 B", importance: 5 });
635
+
636
+ const body = await (await fetch(`${base}/context`, { headers: auth })).json();
637
+ assert.equal(body.items[0].title, "pin-偏好", "pinned row leads the block");
638
+ assert.equal(body.pinnedCount, 1);
639
+ } finally {
640
+ close();
641
+ }
642
+ });
643
+
644
+ test("GET /context honors a declared scope under strictScope; undeclared stays unfiltered", async () => {
645
+ // scope 语义锁(issue #370):声明身份 → strictScope 硬过滤按 isVisibleInScope
646
+ // 生效;不声明 → scope=null,门不触发(与宿主「会话身份取不到时两维为 null」
647
+ // 同构)。任一维声明而另一维缺省时,缺维按解析不到走 fail-closed。
648
+ const { base, auth, close } = await setup({ config: { strictScope: true } });
649
+ try {
650
+ await saveMemory(base, auth, { type: "decision", title: "他域决策", content: "别的 agent 的事", importance: 5, agent_scope: "other-agent" });
651
+ await saveMemory(base, auth, { type: "decision", title: "全域决策", content: "谁都可见", importance: 5 });
652
+
653
+ const declared = await (await fetch(`${base}/context?agent_scope=me`, { headers: auth })).json();
654
+ const declaredTitles = declared.items.map((m) => m.title);
655
+ assert.ok(!declaredTitles.includes("他域决策"), "explicit foreign agent_scope row is hard-filtered");
656
+ assert.ok(declaredTitles.includes("全域决策"));
657
+
658
+ const undeclared = await (await fetch(`${base}/context`, { headers: auth })).json();
659
+ const undeclaredTitles = undeclared.items.map((m) => m.title);
660
+ assert.ok(undeclaredTitles.includes("他域决策"), "no declared scope → no gate");
661
+ } finally {
662
+ close();
663
+ }
664
+ });
665
+
666
+ test("GET /context embeds the query via the injected embedder (wiring lock)", async () => {
667
+ // embedder 接线锁(issue #370):createStandaloneApi 新增的可选 embedder 必须
668
+ // 真被 /context 用上——宿主与 daemon 传的都是 semantic 装配的同一实例。
669
+ const calls = [];
670
+ const fake = {
671
+ ready: true,
672
+ modelHash: "fake-hash-1",
673
+ dimension: 4,
674
+ embedSingle: async (text) => {
675
+ calls.push(text);
676
+ return [1, 0, 0, 0];
677
+ }
678
+ };
679
+ const { base, auth, close } = await setup({ embedder: fake });
680
+ try {
681
+ const res = await fetch(`${base}/context?q=${encodeURIComponent("语义查询")}`, { headers: auth });
682
+ assert.equal(res.status, 200);
683
+ await res.json();
684
+ assert.deepEqual(calls, ["语义查询"], "the route must hand the query to the injected embedder");
685
+ } finally {
686
+ close();
687
+ }
688
+ });
689
+
690
+ test("GET /context survives a throwing embedder (rule-tier degradation, never 500)", async () => {
691
+ const { base, auth, close } = await setup({
692
+ embedder: { ready: true, embedSingle: async () => { throw new Error("model offline"); } }
693
+ });
694
+ try {
695
+ await saveMemory(base, auth, { type: "decision", title: "降级存照", content: "嵌入挂了也要有注入", importance: 5 });
696
+ const res = await fetch(`${base}/context?q=任意`, { headers: auth });
697
+ assert.equal(res.status, 200, "embed failure must degrade to the rule tier");
698
+ const body = await res.json();
699
+ assert.ok(body.items.some((m) => m.title === "降级存照"));
700
+ } finally {
701
+ close();
702
+ }
703
+ });
704
+
705
+ test("GET /context times out a hung embedder and degrades (embedTimeoutMs guard)", async () => {
706
+ // CodeRabbit on #371:embedder 挂起(不抛错,典型为第三方 ollama/openai 端点
707
+ // 无响应)不能占住 HTTP 连接——超时按嵌入缺失降级规则档。测试用 50ms 短超时
708
+ // 避免慢测试;生产默认 3000 由工厂选项约束(同 strictPort,不进 config schema)。
709
+ const { base, auth, close } = await setup({
710
+ embedTimeoutMs: 50,
711
+ embedder: { ready: true, embedSingle: () => new Promise(() => {}) }
712
+ });
713
+ try {
714
+ await saveMemory(base, auth, { type: "decision", title: "挂起存照", content: "嵌入悬挂也要有注入", importance: 5 });
715
+ const started = Date.now();
716
+ const res = await fetch(`${base}/context?q=任意`, { headers: auth });
717
+ assert.equal(res.status, 200, "hung embedder must not hang the route");
718
+ const body = await res.json();
719
+ assert.ok(body.items.some((m) => m.title === "挂起存照"), "timed-out embed falls back to the rule tier");
720
+ assert.ok(Date.now() - started < 3000, "response must not wait on the hung embedder");
721
+ } finally {
722
+ close();
723
+ }
724
+ });
725
+
726
+ test("strictPort: busy port rejects instead of hopping; default path still hops", async () => {
727
+ // daemon 语义锁(#363):strictPort 下配置端口被占 = 配置错误,明确失败 ——
728
+ // 顺延会让把 URL 写死的第三方客户端静默打到错误端口。占口用裸 net server
729
+ // (OS 分配,不与并行测试抢固定号)。
730
+ const { default: net } = await import("node:net");
731
+ const blocker = net.createServer();
732
+ await new Promise((resolve) => blocker.listen(0, "127.0.0.1", resolve));
733
+ const busyPort = blocker.address().port;
734
+
735
+ const store = createStore(":memory:");
736
+ const service = createService({ store, mirror: null, config: {} });
737
+ const settings = createSettings(store.db);
738
+ try {
739
+ const strict = createStandaloneApi({ service, store, config: {}, settings, logger: null, port: busyPort, strictPort: true });
740
+ await assert.rejects(strict.ready, (err) => err?.code === "EADDRINUSE");
741
+
742
+ // 默认路径(不传 strictPort)行为不变:顺延成功;具体端口不断言(避免抢号)。
743
+ const hopping = createStandaloneApi({ service, store, config: {}, settings, logger: null, port: busyPort });
744
+ await hopping.ready;
745
+ assert.notEqual(hopping.port, busyPort, "default policy hops off a busy port");
746
+ hopping.server.close();
747
+ } finally {
748
+ blocker.close();
749
+ store.close();
750
+ }
751
+ });