@modusensus/dsh-mneme 0.8.15 → 0.9.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.
- package/README.md +21 -3
- package/assets/icon.svg +2 -2
- package/bin/cli.mjs +4 -2
- package/docs/CONFIGURATION.md +16 -3
- package/docs/CONTINUITY.md +18 -3
- package/docs/DISTILL.md +162 -0
- package/docs/STORAGE.md +11 -2
- package/docs/WORKFLOWS.md +299 -0
- package/lib/api-standalone.js +22 -39
- package/lib/api.js +294 -10
- package/lib/candidates.js +454 -0
- package/lib/capabilities.js +819 -0
- package/lib/client.js +2767 -337
- package/lib/config.js +99 -6
- package/lib/continuity.js +35 -6
- package/lib/dream/decisions.js +37 -1
- package/lib/dream/sleep.js +10 -2
- package/lib/dream.js +7 -1
- package/lib/entities/extractor.js +4 -0
- package/lib/hot-memory.js +62 -0
- package/lib/index.js +72 -18
- package/lib/inject.js +33 -64
- package/lib/maintenance.js +25 -5
- package/lib/pipeline.js +534 -0
- package/lib/recall-stats.js +22 -7
- package/lib/reclaim-http.js +150 -0
- package/lib/safe-slice.js +18 -0
- package/lib/scope.js +16 -4
- package/lib/serve.js +1 -1
- package/lib/service.js +83 -17
- package/lib/session-usage.js +350 -0
- package/lib/settings.js +19 -2
- package/lib/store.js +251 -10
- package/lib/summarize.js +239 -60
- package/lib/tools.js +11 -2
- package/package.json +1 -1
- package/scripts/publish-gate.mjs +197 -0
- package/src/api-standalone.js +22 -39
- package/src/api.js +294 -10
- package/src/candidates.js +454 -0
- package/src/capabilities.js +819 -0
- package/src/config.js +99 -6
- package/src/continuity.js +35 -6
- package/src/dream/decisions.js +37 -1
- package/src/dream/sleep.js +10 -2
- package/src/dream.js +7 -1
- package/src/entities/extractor.js +4 -0
- package/src/hot-memory.js +62 -0
- package/src/index.js +72 -18
- package/src/inject.js +33 -64
- package/src/maintenance.js +25 -5
- package/src/pipeline.js +534 -0
- package/src/recall-stats.js +22 -7
- package/src/reclaim-http.js +150 -0
- package/src/safe-slice.js +18 -0
- package/src/scope.js +16 -4
- package/src/serve.js +1 -1
- package/src/service.js +83 -17
- package/src/session-usage.js +350 -0
- package/src/settings.js +19 -2
- package/src/store.js +251 -10
- package/src/summarize.js +239 -60
- package/src/tools.js +11 -2
- package/test/api-routes-gaps.test.js +681 -0
- package/test/api.test.js +171 -8
- package/test/candidates.test.js +345 -0
- package/test/capabilities.test.js +577 -0
- package/test/client.test.js +715 -4
- package/test/continuity.test.js +84 -0
- package/test/contrast.test.js +314 -0
- package/test/dream-merge-guardrail.test.js +219 -0
- package/test/embed-ready-poll.test.js +94 -0
- package/test/entities.test.js +13 -2
- package/test/failure-query.test.js +147 -0
- package/test/failure-retention.test.js +105 -0
- package/test/graph-api.test.js +36 -2
- package/test/hot-memory.test.js +67 -1
- package/test/inject-recall.test.js +68 -1
- package/test/inject.test.js +23 -0
- package/test/llm-audit.test.js +190 -1
- package/test/maintenance.test.js +112 -2
- package/test/mint-source.test.js +202 -0
- package/test/pipeline.test.js +505 -0
- package/test/placement.test.js +1 -1
- package/test/pre-inject-gate.test.js +10 -0
- package/test/publish-gate.test.js +272 -0
- package/test/recall-retention.test.js +128 -0
- package/test/recall-stats.test.js +89 -4
- package/test/reclaim-http.test.js +209 -0
- package/test/release-workflow.test.js +77 -0
- package/test/scope-distill-stamp.test.js +135 -0
- package/test/serve.test.js +1 -1
- package/test/service.test.js +7 -2
- package/test/session-usage.test.js +308 -0
- package/test/settings.test.js +8 -0
- package/test/store-write-path-columns.test.js +191 -0
- package/test/store.test.js +108 -0
- package/test/summarize.test.js +350 -0
- package/test/supersede-state-tracking.test.js +4 -4
- package/test/tools.test.js +16 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createStore } from "../src/store.js";
|
|
4
|
+
import { createService } from "../src/service.js";
|
|
5
|
+
|
|
6
|
+
// 这条锁防的是一类「上限形同不存在」的回归:上限判断如果排在会抛错的那次读取
|
|
7
|
+
// 后面,轮询就永远不会停手。
|
|
8
|
+
//
|
|
9
|
+
// service.js 的 setEmbedder 为异步初始化的 embedder 起了个轮询:每 100ms 读一次
|
|
10
|
+
// `emb.ready`,读到 true 或撞上 EMBED_READY_POLL_LIMIT(=30) 就停
|
|
11
|
+
// (注释原文 "~3s ceiling; never poll forever")。但 `ready` 是个 getter
|
|
12
|
+
// —— embedding.js 的 OpenAI embedder 直接读库 —— 于是写成
|
|
13
|
+
// if (emb.ready === true || attempts >= EMBED_READY_POLL_LIMIT) { stop(); }
|
|
14
|
+
// 的整条 `||` 会在左半边当场中断,右半边的天花板永远轮不到:stopEmbedReadyPolling()
|
|
15
|
+
// 不执行,定时器活过上限,并且每 100ms 抛一次未捕获异常。
|
|
16
|
+
//
|
|
17
|
+
// 实测现场(2026-10-14,加蒸馏留存的装配级用例时撞上):fiber.dispose() 走完
|
|
18
|
+
// index.js 的 disposer(末尾一句就是 store.close())之后,每次读 ready 都抛
|
|
19
|
+
// "database is not open" [ERR_INVALID_STATE],5s 内读了 46 次 —— 天花板 31 被
|
|
20
|
+
// 击穿,node --test 进程因此不再退出。一个只在启动时读一次库的 getter,能把
|
|
21
|
+
// 宿主的事件循环钉死;而现有测试全都「装配完不拆」,所以从 v0.7.1 起一直没人碰到。
|
|
22
|
+
//
|
|
23
|
+
// 时序照真实卸载顺序复现:安装时未就绪(轮询启动)→ store.close()(此后每次读
|
|
24
|
+
// ready 都抛)→ 等超过天花板 → 看它停没停手、报了几次。
|
|
25
|
+
const POLL_LIMIT = 30; // 与 src/service.js 的 EMBED_READY_POLL_LIMIT 同值,改那边要改这里
|
|
26
|
+
const POLL_MS = 100;
|
|
27
|
+
const CEILING_READS = 1 + POLL_LIMIT; // setEmbedder 自己读一次,之后每个 tick 一次
|
|
28
|
+
const SETTLE_MS = 400; // 静默这么久没新读取,就认为定时器已经停手
|
|
29
|
+
const DEADLINE_MS = 30000; // 上限:等到这里还没静默就按超时处理
|
|
30
|
+
|
|
31
|
+
// 等「读取次数不再增长」。不能用「睡够 POLL_MS*30 + 600ms 再断言」——
|
|
32
|
+
// 全量 npm test 是多文件并行跑的,CPU 一挤,30 个 tick 未必在 3.6s 内跑完,
|
|
33
|
+
// 断言就会读到 30 而不是 31(2026-10-15 rebase 后在全量跑里实测撞到一次,
|
|
34
|
+
// 单跑 3/3 通过、紧接着全量跑两遍也全绿——纯粹的负载抖动)。
|
|
35
|
+
// 静默判定是可靠的:定时器周期 100ms,而越过上限后 stopEmbedReadyPolling()
|
|
36
|
+
// 会 clearInterval,此后 reads 恒不再变,两者不会混淆。
|
|
37
|
+
async function waitUntilSettled(getReads) {
|
|
38
|
+
const started = Date.now();
|
|
39
|
+
let last = getReads();
|
|
40
|
+
let lastChange = Date.now();
|
|
41
|
+
while (Date.now() - started < DEADLINE_MS) {
|
|
42
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
43
|
+
const now = getReads();
|
|
44
|
+
if (now !== last) {
|
|
45
|
+
last = now;
|
|
46
|
+
lastChange = Date.now();
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (Date.now() - lastChange >= SETTLE_MS) return now;
|
|
50
|
+
}
|
|
51
|
+
return getReads();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test("the embedder ready poll stops at its ceiling even when reading ready throws", async () => {
|
|
55
|
+
const warns = [];
|
|
56
|
+
const store = createStore(":memory:");
|
|
57
|
+
const service = createService({
|
|
58
|
+
store,
|
|
59
|
+
mirror: null,
|
|
60
|
+
config: {},
|
|
61
|
+
logger: { warn: (...args) => warns.push(args) }
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
let reads = 0;
|
|
65
|
+
let closed = false;
|
|
66
|
+
const embedder = {
|
|
67
|
+
get ready() {
|
|
68
|
+
reads++;
|
|
69
|
+
if (closed) {
|
|
70
|
+
// 与 embedding.js 的 OpenAI embedder 同形:getter 内部经 settings 读库。
|
|
71
|
+
store.db.prepare("SELECT count(*) AS c FROM memories").get();
|
|
72
|
+
}
|
|
73
|
+
return false; // 始终「未就绪」:轮询会一直跑下去,除非天花板真的生效
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
service.setEmbedder(embedder);
|
|
78
|
+
assert.equal(reads, 1, "setEmbedder 先读一次 ready,决定要不要起轮询");
|
|
79
|
+
|
|
80
|
+
closed = true;
|
|
81
|
+
store.close(); // 卸载路径的最后一步
|
|
82
|
+
|
|
83
|
+
await waitUntilSettled(() => reads);
|
|
84
|
+
const atCeiling = reads;
|
|
85
|
+
|
|
86
|
+
assert.equal(
|
|
87
|
+
atCeiling,
|
|
88
|
+
CEILING_READS,
|
|
89
|
+
`读 ready 抛错也必须按上限停手(实际读了 ${atCeiling} 次,上限 ${CEILING_READS})`
|
|
90
|
+
);
|
|
91
|
+
assert.equal(reads, atCeiling, "越过天花板之后不许再有读取:定时器必须已经清掉");
|
|
92
|
+
assert.equal(warns.length, 1, "读不出来只报一次,别把 3s 上限变成 30 条刷屏");
|
|
93
|
+
assert.match(String(warns[0][0]), /embedder ready probe failed/);
|
|
94
|
+
});
|
package/test/entities.test.js
CHANGED
|
@@ -533,7 +533,9 @@ test("extractor passes provider/model/reasoningEffort through to callLLM options
|
|
|
533
533
|
const config = { entityExtractionProvider: "openai", entityExtractionModel: "gpt-x", entityExtractionReasoning: "high" };
|
|
534
534
|
const result = await extractEntities({ id: "m1", content: "Vite 是前端构建工具" }, { store, config, callLLM });
|
|
535
535
|
assert.equal(result.ok, true);
|
|
536
|
-
|
|
536
|
+
// memoryId 是 #405 加的归属通道(审计行靠它记 related_memory_ids),与 #109 的
|
|
537
|
+
// provider/model/reasoningEffort 走同一条 options 缝;形状锁保持精确相等。
|
|
538
|
+
assert.deepEqual(captured, { provider: "openai", model: "gpt-x", reasoningEffort: "high", memoryId: "m1" });
|
|
537
539
|
store.close();
|
|
538
540
|
});
|
|
539
541
|
|
|
@@ -542,7 +544,16 @@ test("extractor omits empty provider/model and 'none' reasoning from options", a
|
|
|
542
544
|
let captured = "unset";
|
|
543
545
|
const callLLM = async (_m, options) => { captured = options; return JSON.stringify({ entities: [], relations: [] }); };
|
|
544
546
|
await extractEntities({ id: "m2", content: "空文本" }, { store, config: {}, callLLM });
|
|
545
|
-
assert.deepEqual(captured, {}, "no provider/model/reasoning keys when unset");
|
|
547
|
+
assert.deepEqual(captured, { memoryId: "m2" }, "no provider/model/reasoning keys when unset");
|
|
548
|
+
store.close();
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("#405: extractor omits memoryId when the memory has no id (no fabricated attribution)", async () => {
|
|
552
|
+
const store = openStore();
|
|
553
|
+
let captured = "unset";
|
|
554
|
+
const callLLM = async (_m, options) => { captured = options; return JSON.stringify({ entities: [], relations: [] }); };
|
|
555
|
+
await extractEntities({ content: "无 id 的记忆" }, { store, config: {}, callLLM });
|
|
556
|
+
assert.deepEqual(captured, {}, "an id-less memory leaves the attribution channel empty");
|
|
546
557
|
store.close();
|
|
547
558
|
});
|
|
548
559
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createStore } from "../src/store.js";
|
|
4
|
+
import { createService } from "../src/service.js";
|
|
5
|
+
import { createTools } from "../src/tools.js";
|
|
6
|
+
|
|
7
|
+
// v0.9.0:failure_memories.query 的起点是 2,367 行里只有 69 行非空(2.9%)。
|
|
8
|
+
// 但那个分母误导——按「expected 逐字落在某次 dream_runs.decisions 里」归属后
|
|
9
|
+
// (2026-10-14 实测,D:/DSH/.dsh/memory/memory.db):
|
|
10
|
+
// dream 侧 2,118 行,query 非空率 0.0%——机器巩固没有用户在场,本该为空;
|
|
11
|
+
// 入口侧 249 行,query 非空率 27.7%——模型自愿填 reason 的那部分。
|
|
12
|
+
// 所以要修的是入口侧,不是 dream 侧。
|
|
13
|
+
//
|
|
14
|
+
// 修法:工具层是唯一同时握着「改写动作」与「本轮会话」的入口(service 层只认
|
|
15
|
+
// ctx.query,见 src/service.js updateMemory 内的 saveFailure 调用)。调用方没给
|
|
16
|
+
// reason 时,工具自己去会话里取当轮用户原话——与注入侧共用同一份实现
|
|
17
|
+
// (src/inject.js 的 lastUserQuery),不复制第二份。
|
|
18
|
+
//
|
|
19
|
+
// 本文件锁三件事:① 没给 reason 时真的落到原话;② 给了 reason 不被覆盖;
|
|
20
|
+
// ③ 两边都取不到时写 NULL 而不是空串——空串会假装「有意图」,NULL 才说实话。
|
|
21
|
+
|
|
22
|
+
function setup(config = {}) {
|
|
23
|
+
const store = createStore(":memory:");
|
|
24
|
+
const service = createService({
|
|
25
|
+
store,
|
|
26
|
+
mirror: null,
|
|
27
|
+
config: { reflectionFailureTracking: true, ...config }
|
|
28
|
+
});
|
|
29
|
+
const registered = [];
|
|
30
|
+
const ctx = { tools: { register(def) { registered.push(def); return () => {}; } } };
|
|
31
|
+
createTools(ctx, service, config, null);
|
|
32
|
+
const tool = (name) => registered.find((def) => def.name === name);
|
|
33
|
+
return { store, service, tool };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const seed = (store) => store.save({ type: "preference", title: "语言", content: "喜欢 Python", importance: 3 });
|
|
37
|
+
|
|
38
|
+
/** exec 形状与宿主一致:工具 execute 的第二个参数(src/tools.js 里的 arguments[1])。 */
|
|
39
|
+
const execWith = (events, extra = {}) => ({
|
|
40
|
+
agent: { session: { id: "s-failure-query", snapshotEvents: () => events, ...extra } }
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const userMessage = (text, seq) => ({
|
|
44
|
+
type: "user/message",
|
|
45
|
+
...(seq === undefined ? {} : { seq }),
|
|
46
|
+
data: { source: { kind: "user" }, content: [{ type: "text", text }] }
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const assistantMessage = (text, seq) => ({
|
|
50
|
+
type: "assistant/message",
|
|
51
|
+
...(seq === undefined ? {} : { seq }),
|
|
52
|
+
data: { message: { content: [{ type: "text", text }] } }
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const run = (def, args, exec) => def.execute.bind(def)(args, exec);
|
|
56
|
+
|
|
57
|
+
test("memory_update 没给 reason 时,query 落当轮用户原话", async () => {
|
|
58
|
+
const { store, tool } = setup();
|
|
59
|
+
const mem = seed(store);
|
|
60
|
+
await run(
|
|
61
|
+
tool("memory_update"),
|
|
62
|
+
{ id: mem.id, content: "喜欢 Rust" },
|
|
63
|
+
execWith([userMessage("不对,我喜欢的是 Rust,不是 Python")])
|
|
64
|
+
);
|
|
65
|
+
const [row] = store.listFailures();
|
|
66
|
+
assert.equal(row.query, "不对,我喜欢的是 Rust,不是 Python");
|
|
67
|
+
// expected/actual 语义未变:expected = 改成了什么,actual = 原本是什么。
|
|
68
|
+
assert.equal(row.expected, "喜欢 Rust");
|
|
69
|
+
assert.equal(row.actual, "喜欢 Python");
|
|
70
|
+
assert.equal(row.failure_type, "user_correction");
|
|
71
|
+
assert.equal(row.memory_id, mem.id);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("memory_update 给了 reason 就用 reason,不被会话原话顶掉", async () => {
|
|
75
|
+
const { store, tool } = setup();
|
|
76
|
+
const mem = seed(store);
|
|
77
|
+
await run(
|
|
78
|
+
tool("memory_update"),
|
|
79
|
+
{ id: mem.id, content: "喜欢 Rust", reason: "用户说:换成 Rust" },
|
|
80
|
+
execWith([userMessage("这句是当轮原话,但调用方已经说清了理由")])
|
|
81
|
+
);
|
|
82
|
+
assert.equal(store.listFailures()[0].query, "用户说:换成 Rust");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("reason 是空白串时按「没给」处理,退回会话原话", async () => {
|
|
86
|
+
const { store, tool } = setup();
|
|
87
|
+
const mem = seed(store);
|
|
88
|
+
// 与 api-standalone 的 PUT /memories/:id 同口径(body.reason.trim() 为空即视作未提供)。
|
|
89
|
+
await run(
|
|
90
|
+
tool("memory_update"),
|
|
91
|
+
{ id: mem.id, content: "喜欢 Rust", reason: " " },
|
|
92
|
+
execWith([userMessage("换成 Rust 吧")])
|
|
93
|
+
);
|
|
94
|
+
assert.equal(store.listFailures()[0].query, "换成 Rust 吧");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("两边都取不到时写 NULL,不写空串(空串会假装有意图)", async () => {
|
|
98
|
+
const { store, tool } = setup();
|
|
99
|
+
const mem = seed(store);
|
|
100
|
+
// 会话里只有助手消息 → 取不到「用户原话」。
|
|
101
|
+
await run(tool("memory_update"), { id: mem.id, content: "喜欢 Rust" }, execWith([assistantMessage("好的")]));
|
|
102
|
+
assert.equal(store.listFailures()[0].query, null);
|
|
103
|
+
|
|
104
|
+
// 完全没有 exec(老宿主 / 直调)→ 同样 NULL,不抛。
|
|
105
|
+
const mem2 = store.save({ type: "preference", title: "编辑器", content: "vim", importance: 3 });
|
|
106
|
+
await run(tool("memory_update"), { id: mem2.id, content: "emacs" });
|
|
107
|
+
assert.equal(store.listFailures()[1].query, null);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("被撤回/压缩遮蔽的用户消息不算当轮原话(复用注入侧同一份实现)", async () => {
|
|
111
|
+
const { store, tool } = setup();
|
|
112
|
+
const mem = seed(store);
|
|
113
|
+
// seq 1 被 rewind 遮蔽(surface.nodes 只暴露 2)——同 inject.js visibleSeqsOf 口径。
|
|
114
|
+
// 这条同时锁住「没有抄第二份实现」:naive 的副本会漏掉可见面过滤。
|
|
115
|
+
const events = [userMessage("我想撤回的那句话", 1), assistantMessage("好", 2)];
|
|
116
|
+
await run(
|
|
117
|
+
tool("memory_update"),
|
|
118
|
+
{ id: mem.id, content: "喜欢 Rust" },
|
|
119
|
+
execWith(events, { surface: { nodes: [2] } })
|
|
120
|
+
);
|
|
121
|
+
assert.equal(store.listFailures()[0].query, null, "被遮蔽的消息不许当成原话");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("会话内部读不出来时退化为 NULL,不让改写本身失败", async () => {
|
|
125
|
+
const { store, tool } = setup();
|
|
126
|
+
const mem = seed(store);
|
|
127
|
+
// 宿主 getter 抛错(cordis Proxy 裸读未 inject 属性)——lastUserQuery 的 catch 兜住。
|
|
128
|
+
const exec = {
|
|
129
|
+
agent: {
|
|
130
|
+
get session() {
|
|
131
|
+
throw new Error("not injected");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
await run(tool("memory_update"), { id: mem.id, content: "喜欢 Rust" }, exec);
|
|
136
|
+
const [row] = store.listFailures();
|
|
137
|
+
assert.equal(row.query, null);
|
|
138
|
+
assert.equal(row.expected, "喜欢 Rust", "改写本身照常完成");
|
|
139
|
+
assert.equal(store.getById(mem.id).content, "喜欢 Rust");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("过长的原话按注入侧同一上限截断(不是各截各的)", async () => {
|
|
143
|
+
const { store, tool } = setup();
|
|
144
|
+
const mem = seed(store);
|
|
145
|
+
await run(tool("memory_update"), { id: mem.id, content: "喜欢 Rust" }, execWith([userMessage("长".repeat(900))]));
|
|
146
|
+
assert.equal(store.listFailures()[0].query.length, 500, "safeSlice(…, 500) 与注入侧一致");
|
|
147
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { Config } from "../src/config.js";
|
|
7
|
+
import { createStore } from "../src/store.js";
|
|
8
|
+
|
|
9
|
+
// 与 test/recall-retention.test.js 同款,这组测试故意分两半,因为弄丢的正是「两半
|
|
10
|
+
// 都在」:
|
|
11
|
+
// 1. 行为锁——deleteOldFailures 真的删对行、比较方向正确、空表不炸。
|
|
12
|
+
// 2. 接线锁——src/index.js 的启动块真的调它,且读的是装配后的 cfg。第二半用源码
|
|
13
|
+
// 文本断言,理由与 test/client.test.js 的 bilingual 锁同款:启动期清理只在运维
|
|
14
|
+
// 时才会跑到,运行时冒烟测不到「这一行被挪回装配之前了」。
|
|
15
|
+
//
|
|
16
|
+
// 这一批补的是「写死」这条欠账:failure_memories 的清理此前排在 createStore 之后、
|
|
17
|
+
// cfg 装配之前,天数硬编码 90,是四张启动期清理表里唯一没有键可陈述的一张。
|
|
18
|
+
|
|
19
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const srcText = (name) => readFileSync(join(here, "..", "src", name), "utf8");
|
|
21
|
+
const daysAgo = (n) => new Date(Date.now() - n * 86400000).toISOString();
|
|
22
|
+
|
|
23
|
+
// saveFailure 只认当前时刻,所以插进去之后把 created_at 改写成指定时刻。
|
|
24
|
+
function seedFailure(store, createdAt, id) {
|
|
25
|
+
store.saveFailure({ id, failure_type: "user_correction", expected: "新值", actual: "旧值" });
|
|
26
|
+
store.db.prepare("UPDATE failure_memories SET created_at = ? WHERE id = ?").run(createdAt, id);
|
|
27
|
+
return id;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("deleteOldFailures 删掉窗口外的行、留下窗口内的行", () => {
|
|
31
|
+
const store = createStore(":memory:");
|
|
32
|
+
seedFailure(store, daysAgo(200), "old-1");
|
|
33
|
+
seedFailure(store, daysAgo(120), "old-2");
|
|
34
|
+
seedFailure(store, daysAgo(10), "fresh-1");
|
|
35
|
+
seedFailure(store, daysAgo(1), "fresh-2");
|
|
36
|
+
|
|
37
|
+
assert.equal(store.listFailures().length, 4);
|
|
38
|
+
const removed = store.deleteOldFailures(daysAgo(90));
|
|
39
|
+
assert.equal(removed, 2);
|
|
40
|
+
// assert 的是留下了哪两行,不是物理顺序
|
|
41
|
+
assert.deepEqual(store.listFailures().map((r) => r.id).sort(), ["fresh-1", "fresh-2"]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("边界两侧各留 1 小时裕量:锁的是比较方向不是毫秒级巧合", () => {
|
|
45
|
+
const store = createStore(":memory:");
|
|
46
|
+
const boundary = 90 * 86400000;
|
|
47
|
+
// cutoff 是在清理那一刻现算的,所以不骑刀刃——两侧各让开一小时。
|
|
48
|
+
seedFailure(store, new Date(Date.now() - boundary + 3600000).toISOString(), "just-inside");
|
|
49
|
+
seedFailure(store, new Date(Date.now() - boundary - 3600000).toISOString(), "just-outside");
|
|
50
|
+
|
|
51
|
+
const removed = store.deleteOldFailures(new Date(Date.now() - boundary).toISOString());
|
|
52
|
+
assert.equal(removed, 1, "只有 created_at < cutoff 的那一行该走");
|
|
53
|
+
assert.deepEqual(store.listFailures().map((r) => r.id), ["just-inside"]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("空表返回 0 且不抛", () => {
|
|
57
|
+
const store = createStore(":memory:");
|
|
58
|
+
assert.equal(store.deleteOldFailures(daysAgo(90)), 0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("配置键 reflectionFailureRetentionDays:默认 90、单位是天、边界钉死", () => {
|
|
62
|
+
assert.equal(Config({}).reflectionFailureRetentionDays, 90, "默认值必须与旧行为一致");
|
|
63
|
+
assert.equal(Config({ reflectionFailureRetentionDays: 30 }).reflectionFailureRetentionDays, 30);
|
|
64
|
+
assert.equal(Config({ reflectionFailureRetentionDays: 3650 }).reflectionFailureRetentionDays, 3650);
|
|
65
|
+
// 0 天会把这张表压成「只留最近一次」,等于废掉「挖掘重复纠正模式」的用途
|
|
66
|
+
assert.throws(() => Config({ reflectionFailureRetentionDays: 0 }));
|
|
67
|
+
assert.throws(() => Config({ reflectionFailureRetentionDays: 3651 }));
|
|
68
|
+
assert.throws(() => Config({ reflectionFailureRetentionDays: 2.5 }));
|
|
69
|
+
|
|
70
|
+
// 形状锁:与 recallRetentionDays 一族同形,别各自演化
|
|
71
|
+
assert.match(
|
|
72
|
+
srcText("config.js"),
|
|
73
|
+
/reflectionFailureRetentionDays: z\.natural\(\)\.min\(1\)\.max\(3650\)\.default\(90\)/
|
|
74
|
+
);
|
|
75
|
+
// 文档面同步:README 的键表与 docs/CONFIGURATION.md 的键表是用户唯一能读到
|
|
76
|
+
// 这个键的地方。用布尔断言而不是 assert.match——断言失败时 node:test 会把整个
|
|
77
|
+
// 文件正文打进 diff,几百行文档刷满输出,反而看不见失败点。
|
|
78
|
+
for (const doc of ["README.md", "docs/CONFIGURATION.md"]) {
|
|
79
|
+
const text = readFileSync(join(here, "..", doc), "utf8");
|
|
80
|
+
assert.ok(
|
|
81
|
+
text.includes("`reflectionFailureRetentionDays`"),
|
|
82
|
+
`${doc} 必须列出 reflectionFailureRetentionDays —— 加了键不改文档正是这一族欠账的来源`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("接线锁:启动期失败表清理排在 cfg 装配之后,读键而不是写死的 90 天", () => {
|
|
88
|
+
const index = srcText("index.js");
|
|
89
|
+
const callAt = index.indexOf("store.deleteOldFailures(");
|
|
90
|
+
assert.ok(callAt > 0, "index.js 必须调用 store.deleteOldFailures");
|
|
91
|
+
|
|
92
|
+
// 装配前读 rawCfg 会漏掉面板/预设合进来的值;失败表此前正好排在装配之前。
|
|
93
|
+
const cfgAt = index.indexOf("const cfg = { ...applyLightModePreset(");
|
|
94
|
+
assert.ok(cfgAt > 0 && cfgAt < callAt, "清理必须排在 cfg 装配之后,否则读不到配置值");
|
|
95
|
+
|
|
96
|
+
const block = index.slice(callAt - 700, callAt + 200);
|
|
97
|
+
assert.match(block, /cfg\.reflectionFailureRetentionDays/, "天数必须取自配置键");
|
|
98
|
+
assert.match(block, /Number\.isInteger\(/, "非法值要能落回默认,别让 NaN 传给 SQL");
|
|
99
|
+
assert.match(block, /failureRetentionDays \* 86400000/, "键的单位是天,换算在这儿");
|
|
100
|
+
|
|
101
|
+
assert.ok(
|
|
102
|
+
!/Date\.now\(\) - 90 \* 86400000/.test(index),
|
|
103
|
+
"写死的 90 天不能回来——这正是本批在修的那条欠账"
|
|
104
|
+
);
|
|
105
|
+
});
|
package/test/graph-api.test.js
CHANGED
|
@@ -151,7 +151,7 @@ test("entity-attrs returns current valid attrs only", async () => {
|
|
|
151
151
|
assert.equal(r2.statusCode, 400);
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
-
test("ego 2-hop
|
|
154
|
+
test("ego 2-hop traversal work is bounded by limit, not by graph size (#409)", async () => {
|
|
155
155
|
const { routes, service } = setup();
|
|
156
156
|
// star-of-stars: hub → 20 spokes, each spoke → 5 leaves = 121 nodes, 120 edges
|
|
157
157
|
const hub = service.createEntity({ name: "hub", type: "project" });
|
|
@@ -164,6 +164,13 @@ test("ego 2-hop over 100+ nodes stays under 50ms (spec §7)", async () => {
|
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
166
|
const route = graphRoute(routes, "/api/dsh-mneme/semantic/graph/ego");
|
|
167
|
+
// #409:这条用例原先断言墙钟 < 50ms,而那个「spec §7」预算在仓库文档里找不到出处。
|
|
168
|
+
// 全量并行跑实测最坏 60.4ms,于是它偶发翻车。ego 路由真正要钉住的性质是「遍历工作量
|
|
169
|
+
// 由 limit 卡死、不随图规模增长」——那是个确定性事实:请求内 getRelations 的唯一调用方
|
|
170
|
+
// 就是这次遍历,所以计数即工作量。墙钟只保留一条离群告警(放到最后)。
|
|
171
|
+
const realGetRelations = service.getRelations.bind(service);
|
|
172
|
+
let lookups = 0;
|
|
173
|
+
service.getRelations = (id) => { lookups++; return realGetRelations(id); };
|
|
167
174
|
const res = new FakeRes();
|
|
168
175
|
const start = process.hrtime.bigint();
|
|
169
176
|
await route.handler(req("/api/dsh-mneme/semantic/graph/ego?entity=hub&depth=2&limit=100"), res);
|
|
@@ -171,5 +178,32 @@ test("ego 2-hop over 100+ nodes stays under 50ms (spec §7)", async () => {
|
|
|
171
178
|
const data = JSON.parse(res.body);
|
|
172
179
|
assert.equal(data.nodes.length, 100, "limit caps the traversal");
|
|
173
180
|
assert.ok(data.edges.length > 0);
|
|
174
|
-
|
|
181
|
+
// 两趟(BFS + 边收集)各至多 limit 次,BFS 还会为「已满之后剩下的 frontier」多问几次,
|
|
182
|
+
// 所以上界留到 3×limit;数量级级别的退化会立刻越界。
|
|
183
|
+
assert.ok(lookups <= 300, `traversal work must be bounded by the limit, saw ${lookups} adjacency lookups`);
|
|
184
|
+
|
|
185
|
+
// 同一个查询打到约 13× 大的图上:limit 没变,该做的活就不该变。这一条不含时钟。
|
|
186
|
+
const big = setup();
|
|
187
|
+
const bigHub = big.service.createEntity({ name: "hub", type: "project" });
|
|
188
|
+
for (let i = 0; i < 40; i++) {
|
|
189
|
+
const spoke = big.service.createEntity({ name: `s-${i}`, type: "technology" });
|
|
190
|
+
big.service.saveRelation({ from_entity: bigHub.id, to_entity: spoke.id, relation_type: "uses" });
|
|
191
|
+
for (let j = 0; j < 15; j++) {
|
|
192
|
+
const leaf = big.service.createEntity({ name: `l-${i}-${j}`, type: "concept" });
|
|
193
|
+
big.service.saveRelation({ from_entity: spoke.id, to_entity: leaf.id, relation_type: "related_to" });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const bigReal = big.service.getRelations.bind(big.service);
|
|
197
|
+
let bigLookups = 0;
|
|
198
|
+
big.service.getRelations = (id) => { bigLookups++; return bigReal(id); };
|
|
199
|
+
const bigRes = new FakeRes();
|
|
200
|
+
await graphRoute(big.routes, "/api/dsh-mneme/semantic/graph/ego")
|
|
201
|
+
.handler(req("/api/dsh-mneme/semantic/graph/ego?entity=hub&depth=2&limit=100"), bigRes);
|
|
202
|
+
assert.equal(JSON.parse(bigRes.body).nodes.length, 100, "the same limit caps the bigger graph too");
|
|
203
|
+
assert.ok(bigLookups <= lookups + 100,
|
|
204
|
+
`${1 + 40 + 600} nodes cost ${bigLookups} lookups vs ${lookups} on 121 — work must track the limit, not the graph`);
|
|
205
|
+
|
|
206
|
+
// 墙钟只留一条离群告警,不再是规格线:它抓不到的那类退化已由上面的工作量断言确定性钉死,
|
|
207
|
+
// 250ms(≈4× 实测最坏值)只为兜住数量级级别的性能崩塌(#409)。
|
|
208
|
+
assert.ok(ms < 250, `2-hop query took ${ms.toFixed(1)}ms — the work bound above is the lock; 250ms is only an outlier guard`);
|
|
175
209
|
});
|
package/test/hot-memory.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { createHotMemory, estimateTokens } from "../src/hot-memory.js";
|
|
3
|
+
import { createHotMemory, estimateTokens, roundsFromEvents } from "../src/hot-memory.js";
|
|
4
4
|
import { createStore } from "../src/store.js";
|
|
5
5
|
import { createService } from "../src/service.js";
|
|
6
6
|
import { createVectorIndex } from "../src/vector-index.js";
|
|
@@ -143,3 +143,69 @@ test("selective injection re-orders candidates by query similarity", () => {
|
|
|
143
143
|
assert.equal(picked[0].id, thesis.id);
|
|
144
144
|
assert.ok(picked.some((m) => m.id === plugin.id));
|
|
145
145
|
});
|
|
146
|
+
|
|
147
|
+
// --- roundsFromEvents:surface 投影(rewind 撤回内容不得回流热记忆) ---
|
|
148
|
+
|
|
149
|
+
function userEvent(seq, text, kind) {
|
|
150
|
+
return { seq, type: "user/message", data: { source: kind ? { kind } : undefined, content: [text] } };
|
|
151
|
+
}
|
|
152
|
+
function assistantEvent(seq, text) {
|
|
153
|
+
return { seq, type: "assistant/message", data: { message: { content: [{ type: "text", text }] } } };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
test("roundsFromEvents pairs user/assistant rounds and keeps the trailing pending query", () => {
|
|
157
|
+
const events = [
|
|
158
|
+
userEvent(1, "第一问"),
|
|
159
|
+
assistantEvent(2, "第一答"),
|
|
160
|
+
userEvent(3, "第二问")
|
|
161
|
+
];
|
|
162
|
+
const rounds = roundsFromEvents(events, 5);
|
|
163
|
+
assert.equal(rounds.length, 2);
|
|
164
|
+
assert.equal(rounds[0].query, "第一问");
|
|
165
|
+
assert.equal(rounds[0].response, "第一答");
|
|
166
|
+
assert.equal(rounds[1].query, "第二问");
|
|
167
|
+
assert.equal(rounds[1].response, "");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("roundsFromEvents excludes events shadowed off the surface (rewind/compact)", () => {
|
|
171
|
+
// append-only 日志里被撤回的轮次仍在,但 surface 只剩可见 seq——
|
|
172
|
+
// 热记忆必须与模型同源,被遮蔽的轮次不得回流。
|
|
173
|
+
const events = [
|
|
174
|
+
userEvent(1, "已被撤回的问题"),
|
|
175
|
+
assistantEvent(2, "已被撤回的回答"),
|
|
176
|
+
{ seq: 3, type: "user/message", data: { source: { kind: "dsh-rewind" }, content: [""] } },
|
|
177
|
+
userEvent(4, "撤回后的新问题"),
|
|
178
|
+
assistantEvent(5, "撤回后的新回答")
|
|
179
|
+
];
|
|
180
|
+
const visible = new Set([3, 4, 5]);
|
|
181
|
+
const rounds = roundsFromEvents(events, 5, visible);
|
|
182
|
+
assert.equal(rounds.length, 1);
|
|
183
|
+
assert.equal(rounds[0].query, "撤回后的新问题");
|
|
184
|
+
assert.equal(rounds[0].response, "撤回后的新回答");
|
|
185
|
+
assert.ok(!JSON.stringify(rounds).includes("已被撤回"));
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("roundsFromEvents with no surface info keeps the legacy full-log behavior", () => {
|
|
189
|
+
const events = [userEvent(1, "旧宿主问题"), assistantEvent(2, "旧宿主回答")];
|
|
190
|
+
const rounds = roundsFromEvents(events, 5, null);
|
|
191
|
+
assert.equal(rounds.length, 1);
|
|
192
|
+
assert.equal(rounds[0].query, "旧宿主问题");
|
|
193
|
+
assert.equal(rounds[0].response, "旧宿主回答");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("roundsFromEvents skips rewind markers as queries even when on the surface", () => {
|
|
197
|
+
const events = [
|
|
198
|
+
{ seq: 1, type: "user/message", data: { source: { kind: "dsh-rewind" }, content: [""] } },
|
|
199
|
+
userEvent(2, "标记后的第一问"),
|
|
200
|
+
assistantEvent(3, "标记后的第一答")
|
|
201
|
+
];
|
|
202
|
+
const visible = new Set([1, 2, 3]);
|
|
203
|
+
const rounds = roundsFromEvents(events, 5, visible);
|
|
204
|
+
assert.equal(rounds.length, 1);
|
|
205
|
+
assert.equal(rounds[0].query, "标记后的第一问");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("roundsFromEvents with an empty surface yields no rounds (model sees nothing)", () => {
|
|
209
|
+
const events = [userEvent(1, "问题"), assistantEvent(2, "回答")];
|
|
210
|
+
assert.equal(roundsFromEvents(events, 5, new Set()).length, 0);
|
|
211
|
+
});
|
|
@@ -37,12 +37,38 @@ test("注入落账:终选集写一行 mode='inject',candidates 为实际注
|
|
|
37
37
|
const row = injectRow(store);
|
|
38
38
|
assert.ok(row, "注入应落一行审计");
|
|
39
39
|
assert.equal(row.mode, "inject");
|
|
40
|
-
// recall_runs.query 是 NOT NULL(store.js:
|
|
40
|
+
// recall_runs.query 是 NOT NULL(store.js 的 recall_runs 建表语句,2026-10-14 在 :75):
|
|
41
|
+
// 调用方没给查询词时用空串而非 null
|
|
41
42
|
assert.equal(row.query, "");
|
|
42
43
|
assert.deepEqual(row.candidates.map((c) => c.id), selected.map((m) => m.id));
|
|
43
44
|
store.close();
|
|
44
45
|
});
|
|
45
46
|
|
|
47
|
+
// v0.9.0:注入行此前恒写空串(2026-10-13 快照 18,231/18,231 全空,即 100%),把「当时想干什么」
|
|
48
|
+
// 从审计里抹掉了 —— 而 inject.js 本来就带着 lastUserQuery 传进来。这两条锁住
|
|
49
|
+
// 「传了要记下来」与「没传仍是空串」,同时确认下游 recallStats 按 mode 分账不受影响。
|
|
50
|
+
test("注入落账:调用方给了查询词就记真值(不再恒空串)", () => {
|
|
51
|
+
const { store, service } = setup();
|
|
52
|
+
service.saveWithDedupe({ type: "decision", title: "甲", content: "内容甲", importance: 4 });
|
|
53
|
+
const selected = service.injectCandidates({ query: "夜航App 令牌桶怎么设计的", maxItems: 5 });
|
|
54
|
+
assert.ok(selected.length >= 1);
|
|
55
|
+
const row = injectRow(store);
|
|
56
|
+
assert.ok(row, "注入应落一行审计");
|
|
57
|
+
assert.equal(row.query, "夜航App 令牌桶怎么设计的");
|
|
58
|
+
// 下游安全:口径按 mode 分账,不看 query
|
|
59
|
+
const stats = service.recallStats({ windowDays: 30, exemptDays: 0 });
|
|
60
|
+
assert.equal(stats.injection.runs, 1);
|
|
61
|
+
store.close();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("注入落账:查询词两侧空白按 trim 后入账", () => {
|
|
65
|
+
const { store, service } = setup();
|
|
66
|
+
service.saveWithDedupe({ type: "decision", title: "甲", content: "内容甲", importance: 4 });
|
|
67
|
+
service.injectCandidates({ query: " 终端 ", maxItems: 5 });
|
|
68
|
+
assert.equal(injectRow(store).query, "终端");
|
|
69
|
+
store.close();
|
|
70
|
+
});
|
|
71
|
+
|
|
46
72
|
test("recallRecordDefault=false 不落账(与检索侧同门)", () => {
|
|
47
73
|
const { store, service } = setup({ recallRecordDefault: false });
|
|
48
74
|
service.saveWithDedupe({ type: "decision", title: "甲", content: "内容甲", importance: 4 });
|
|
@@ -103,3 +129,44 @@ test("slotFillRate = 注入条数 / (轮数 × 槽位数)", () => {
|
|
|
103
129
|
assert.equal(stats.injection.slotFillRate, 3 / 4);
|
|
104
130
|
store.close();
|
|
105
131
|
});
|
|
132
|
+
|
|
133
|
+
// #369:history 曾被注入白名单挡在门外(蒸馏产得出、注入收不进)。这两条断言分
|
|
134
|
+
// 别锁「收编生效」与「重要度闸门对 history 不豁免」——后者一旦失效,短流水账会
|
|
135
|
+
// 直接挤掉决策类条目,那才是这个改动真正的风险面。
|
|
136
|
+
test("#369: history 进注入候选池", () => {
|
|
137
|
+
const { service } = setup();
|
|
138
|
+
service.saveWithDedupe({ type: "history", title: "切到暗色主题", content: "用户本次会话把界面切成暗色主题", importance: 4 });
|
|
139
|
+
const titles = service.injectCandidates({ maxItems: 5 }).map((m) => m.title);
|
|
140
|
+
assert.ok(titles.includes("切到暗色主题"), "importance>=3 的 history 应进注入候选");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("#369: 低于重要度闸门的 history 不得例外放行", () => {
|
|
144
|
+
const { service } = setup();
|
|
145
|
+
service.saveWithDedupe({ type: "history", title: "打开过一次终端", content: "用户中途打开过一次终端", importance: 2 });
|
|
146
|
+
const titles = service.injectCandidates({ maxItems: 5 }).map((m) => m.title);
|
|
147
|
+
assert.ok(!titles.includes("打开过一次终端"), "history 不是 summary/preference,闸门照样生效");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// pattern 早已被 dreamMergeGuard 列为长保留受保护类型,却一度被当成「只由手工写入」
|
|
151
|
+
// (那个判据是错的,实际是 sleep 的 phasePatterns 在 mint——见 src/service.js 的
|
|
152
|
+
// INJECT_TYPES 注释)。巩固层当资产护住、注入面一次不给看是同一条口径的两头
|
|
153
|
+
// 不一致,与作者是谁无关。这条锁它进池。
|
|
154
|
+
test("#369: pattern 进注入候选池(与作者是谁无关)", () => {
|
|
155
|
+
const { service } = setup();
|
|
156
|
+
service.saveWithDedupe({ type: "pattern", title: "重复字符串先抽共用模块", content: "多处重复的截断逻辑先收拢成共用模块,再改调用方", importance: 5 });
|
|
157
|
+
const titles = service.injectCandidates({ maxItems: 5 }).map((m) => m.title);
|
|
158
|
+
assert.ok(titles.includes("重复字符串先抽共用模块"), "importance>=3 的 pattern 应进注入候选");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// CodeRabbit on PR #404:语义缓存回填分支曾独漏重要度门槛(向量路与 BM25 路都带)。
|
|
162
|
+
// 一正一反:反向锁「低于闸门的行走缓存也不许进池」,正向锁「达标的照常进池」,
|
|
163
|
+
// 免得断言退化成常量真;缓存没被灌进来时正向那条会先红,用例不会空压。
|
|
164
|
+
test("#369: 语义缓存回填同样受重要度闸门约束", async () => {
|
|
165
|
+
const { service } = setup();
|
|
166
|
+
const low = service.saveWithDedupe({ type: "history", title: "终端开了一次", content: "用户中途打开过一次终端做检查", importance: 2 });
|
|
167
|
+
const high = service.saveWithDedupe({ type: "history", title: "终端方案定案", content: "用户终端接入方案已定案并验证", importance: 4 });
|
|
168
|
+
await service.searchMemories("终端", { mode: "keyword", useRerank: false, recordRecall: false });
|
|
169
|
+
const ids = service.injectCandidates({ query: "终端", maxItems: 5 }).map((m) => m.id);
|
|
170
|
+
assert.ok(ids.includes(high.memory.id), "i4 的 history 应经缓存回填进池(正向对照)");
|
|
171
|
+
assert.equal(ids.includes(low.memory.id), false, "低于闸门的行不得借语义缓存绕开门槛");
|
|
172
|
+
});
|
package/test/inject.test.js
CHANGED
|
@@ -418,3 +418,26 @@ test("issue #334: snapshot query carries no lone surrogate (query path cut at ex
|
|
|
418
418
|
assert.ok(snap.query.length > 0, "query recorded");
|
|
419
419
|
assert.ok(!hasLoneSurrogate(snap.query), "snapshot query is surrogate-clean");
|
|
420
420
|
});
|
|
421
|
+
|
|
422
|
+
// surface.nodes 是宿主 getter:消息投影所属插件被移除时,dsh-session 的
|
|
423
|
+
// _assertProjections 直接 throw。读取不设防的话,异常会冒到调用方的 try,表现为
|
|
424
|
+
// 热记忆整块不渲染 + 语义查询为空——比「沿用可见面失效前的旧行为」更糟。
|
|
425
|
+
// 夹具用宿主真实的抛错文案,锁住「拿不到可见面就退回全量事件」这条退化路径。
|
|
426
|
+
test("throwing surface getter degrades to full-log hot memory instead of a blank block", () => {
|
|
427
|
+
const { contexts } = setup();
|
|
428
|
+
const events = [
|
|
429
|
+
{ type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "把投影插件卸了会怎样" }] } },
|
|
430
|
+
{ type: "assistant/message", data: { message: { content: [{ type: "text", text: "热记忆不该因此整块消失" }] } } }
|
|
431
|
+
];
|
|
432
|
+
const session = {
|
|
433
|
+
snapshotEvents: () => events,
|
|
434
|
+
get surface() {
|
|
435
|
+
throw new Error('session message projection "dsh-rewind" was removed or replaced; restore the session with its owning plugin');
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
const text = contexts[0].text({ agent: { session } });
|
|
439
|
+
assert.ok(text.includes("[短期上下文]"), "热记忆块仍应渲染");
|
|
440
|
+
assert.ok(text.includes("把投影插件卸了会怎样"), "轮次提问应照旧进入");
|
|
441
|
+
assert.ok(text.includes("热记忆不该因此整块消失"), "轮次回复应照旧进入");
|
|
442
|
+
assert.equal(getInjectionSnapshot().query, "把投影插件卸了会怎样", "语义查询不受 getter 抛错影响");
|
|
443
|
+
});
|