@modusensus/dsh-mneme 0.8.16 → 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 +16 -3
- package/assets/icon.svg +2 -2
- package/docs/CONFIGURATION.md +2 -1
- package/docs/CONTINUITY.md +18 -3
- package/docs/WORKFLOWS.md +299 -0
- package/lib/api.js +124 -8
- package/lib/candidates.js +454 -0
- package/lib/capabilities.js +819 -0
- package/lib/client.js +1703 -182
- package/lib/config.js +31 -5
- package/lib/continuity.js +35 -6
- package/lib/dream/decisions.js +17 -1
- package/lib/dream/sleep.js +10 -2
- package/lib/dream.js +5 -1
- package/lib/entities/extractor.js +4 -0
- package/lib/index.js +49 -12
- package/lib/inject.js +6 -1
- package/lib/pipeline.js +534 -0
- package/lib/scope.js +16 -4
- package/lib/serve.js +1 -1
- package/lib/service.js +59 -18
- package/lib/session-usage.js +350 -0
- package/lib/settings.js +7 -1
- package/lib/store.js +55 -3
- package/lib/summarize.js +31 -0
- package/lib/tools.js +11 -2
- package/package.json +1 -1
- package/scripts/publish-gate.mjs +197 -0
- package/src/api.js +124 -8
- package/src/candidates.js +454 -0
- package/src/capabilities.js +819 -0
- package/src/config.js +31 -5
- package/src/continuity.js +35 -6
- package/src/dream/decisions.js +17 -1
- package/src/dream/sleep.js +10 -2
- package/src/dream.js +5 -1
- package/src/entities/extractor.js +4 -0
- package/src/index.js +49 -12
- package/src/inject.js +6 -1
- package/src/pipeline.js +534 -0
- package/src/scope.js +16 -4
- package/src/serve.js +1 -1
- package/src/service.js +59 -18
- package/src/session-usage.js +350 -0
- package/src/settings.js +7 -1
- package/src/store.js +55 -3
- package/src/summarize.js +31 -0
- package/src/tools.js +11 -2
- package/test/api.test.js +147 -7
- package/test/candidates.test.js +345 -0
- package/test/capabilities.test.js +577 -0
- package/test/client.test.js +361 -2
- package/test/continuity.test.js +84 -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/inject-recall.test.js +32 -4
- package/test/llm-audit.test.js +92 -1
- package/test/mint-source.test.js +202 -0
- package/test/pipeline.test.js +505 -0
- package/test/placement.test.js +1 -1
- package/test/publish-gate.test.js +272 -0
- package/test/recall-retention.test.js +128 -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/session-usage.test.js +308 -0
- package/test/store-write-path-columns.test.js +191 -0
- package/test/summarize.test.js +176 -0
- package/test/supersede-state-tracking.test.js +4 -4
- package/test/tools.test.js +16 -1
|
@@ -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
|
});
|
|
@@ -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 });
|
|
@@ -121,9 +147,11 @@ test("#369: 低于重要度闸门的 history 不得例外放行", () => {
|
|
|
121
147
|
assert.ok(!titles.includes("打开过一次终端"), "history 不是 summary/preference,闸门照样生效");
|
|
122
148
|
});
|
|
123
149
|
|
|
124
|
-
// pattern
|
|
125
|
-
//
|
|
126
|
-
|
|
150
|
+
// pattern 早已被 dreamMergeGuard 列为长保留受保护类型,却一度被当成「只由手工写入」
|
|
151
|
+
// (那个判据是错的,实际是 sleep 的 phasePatterns 在 mint——见 src/service.js 的
|
|
152
|
+
// INJECT_TYPES 注释)。巩固层当资产护住、注入面一次不给看是同一条口径的两头
|
|
153
|
+
// 不一致,与作者是谁无关。这条锁它进池。
|
|
154
|
+
test("#369: pattern 进注入候选池(与作者是谁无关)", () => {
|
|
127
155
|
const { service } = setup();
|
|
128
156
|
service.saveWithDedupe({ type: "pattern", title: "重复字符串先抽共用模块", content: "多处重复的截断逻辑先收拢成共用模块,再改调用方", importance: 5 });
|
|
129
157
|
const titles = service.injectCandidates({ maxItems: 5 }).map((m) => m.title);
|
package/test/llm-audit.test.js
CHANGED
|
@@ -2,7 +2,7 @@ import test from "node:test";
|
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { EventEmitter } from "node:events";
|
|
4
4
|
import { DatabaseSync } from "node:sqlite";
|
|
5
|
-
import { mkdtempSync } from "node:fs";
|
|
5
|
+
import { mkdtempSync, readFileSync } from "node:fs";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { Context } from "@deepseek-ai/cordis";
|
|
@@ -622,6 +622,97 @@ test("entity extraction writes no audit row when llmAudit.enabled === false", as
|
|
|
622
622
|
store.close();
|
|
623
623
|
});
|
|
624
624
|
|
|
625
|
+
// #405:实体抽取的审计行此前把失败原因压成一句常量、related_memory_ids 恒空,于是
|
|
626
|
+
// 429 / 配额 / 鉴权 / 超时在库里长得一模一样(主库 735 条 entity_extract error 行全
|
|
627
|
+
// 是「llm stream aborted or errored」),也查不出是哪条记忆没抽到实体。四条锁:原因
|
|
628
|
+
// 落进 error_message(复用 dream 的 describeStreamFailure,全仓归一只此一份)、无
|
|
629
|
+
// 细节时退回旧字面量、归属 id 传得进来且不凭空造、抛错路径同样带归属。
|
|
630
|
+
test("#405: entity extraction audit records the stream failure cause, not a constant", async () => {
|
|
631
|
+
const store = createStore(":memory:");
|
|
632
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
633
|
+
const streamEntityText = createEntityStreamAdapter({
|
|
634
|
+
llm: {
|
|
635
|
+
async *stream() {
|
|
636
|
+
yield {
|
|
637
|
+
type: "finish",
|
|
638
|
+
reason: { kind: "error", failure: { code: "rate_limited", message: "429 Too Many Requests", status: 429 } }
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
},
|
|
642
|
+
agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
|
|
643
|
+
logger: { warn: () => {} },
|
|
644
|
+
service
|
|
645
|
+
});
|
|
646
|
+
const text = await streamEntityText(
|
|
647
|
+
[{ role: "user", content: [{ type: "text", text: "记忆内容" }] }],
|
|
648
|
+
{ memoryId: "mem-1" }
|
|
649
|
+
);
|
|
650
|
+
assert.equal(text, undefined, "a failed stream yields no text");
|
|
651
|
+
const row = service.listLlmAudits({ source: "entityExtract" })[0];
|
|
652
|
+
assert.equal(row.status, "error");
|
|
653
|
+
assert.equal(row.error_message, "rate_limited: 429 Too Many Requests",
|
|
654
|
+
"code + message from chunk.reason.failure survive into the audit row");
|
|
655
|
+
assert.notEqual(row.error_message, "llm stream aborted or errored",
|
|
656
|
+
"the constant that made every provider failure look identical is gone");
|
|
657
|
+
assert.deepEqual(row.related_memory_ids, ["mem-1"],
|
|
658
|
+
"the row is attributable to the memory that was being extracted");
|
|
659
|
+
store.close();
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
test("#405: entity extraction audit keeps the legacy wording when the failure has no detail", async () => {
|
|
663
|
+
const store = createStore(":memory:");
|
|
664
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
665
|
+
const streamEntityText = createEntityStreamAdapter({
|
|
666
|
+
llm: {
|
|
667
|
+
async *stream() {
|
|
668
|
+
// 老形态:只有 kind,既没有 failure 也没有 message(dsh-llm 早期版本、或
|
|
669
|
+
// 替身)。这时退回旧字面量——把 error_message 写成空串在库里比一句常量更
|
|
670
|
+
// 难查,那等于用另一种方式把原因丢掉。
|
|
671
|
+
yield { type: "finish", reason: { kind: "aborted" } };
|
|
672
|
+
}
|
|
673
|
+
},
|
|
674
|
+
agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
|
|
675
|
+
logger: { warn: () => {} },
|
|
676
|
+
service
|
|
677
|
+
});
|
|
678
|
+
await streamEntityText([{ role: "user", content: [{ type: "text", text: "记忆内容" }] }], {});
|
|
679
|
+
const row = service.listLlmAudits({ source: "entityExtract" })[0];
|
|
680
|
+
assert.equal(row.error_message, "llm stream aborted or errored");
|
|
681
|
+
assert.deepEqual(row.related_memory_ids, [], "no memoryId → still empty, never fabricated");
|
|
682
|
+
store.close();
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
test("#405: entity extraction audit attributes a thrown stream error to the memory too", async () => {
|
|
686
|
+
const store = createStore(":memory:");
|
|
687
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
688
|
+
const streamEntityText = createEntityStreamAdapter({
|
|
689
|
+
llm: {
|
|
690
|
+
async *stream() {
|
|
691
|
+
throw new Error("socket hang up");
|
|
692
|
+
}
|
|
693
|
+
},
|
|
694
|
+
agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
|
|
695
|
+
logger: { warn: () => {} },
|
|
696
|
+
service
|
|
697
|
+
});
|
|
698
|
+
await streamEntityText([{ role: "user", content: [{ type: "text", text: "记忆内容" }] }], { memoryId: "mem-9" });
|
|
699
|
+
const row = service.listLlmAudits({ source: "entityExtract" })[0];
|
|
700
|
+
assert.equal(row.status, "error");
|
|
701
|
+
assert.equal(row.error_message, "socket hang up");
|
|
702
|
+
assert.deepEqual(row.related_memory_ids, ["mem-9"], "the throw path attributes too");
|
|
703
|
+
store.close();
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
test("#405: the failure-cause formatter stays a single implementation", async () => {
|
|
707
|
+
const indexSrc = readFileSync(new URL("../src/index.js", import.meta.url), "utf8");
|
|
708
|
+
const dreamSrc = readFileSync(new URL("../src/dream.js", import.meta.url), "utf8");
|
|
709
|
+
assert.match(indexSrc, /describeStreamFailure/, "index.js reuses dream.js's formatter");
|
|
710
|
+
// 有人在这里就地重写 code/message 拼接,就又多出一份口径——#411 的教训是「复制
|
|
711
|
+
// 粘贴漂移不是设计,是漂移」。锁死两端:归一逻辑只许在 dream.js 里存在一次。
|
|
712
|
+
assert.equal(indexSrc.match(/failure\.code/g), null, "index.js must not re-format the failure code itself");
|
|
713
|
+
assert.equal(dreamSrc.match(/function describeStreamFailure\(/g).length, 1, "exactly one definition");
|
|
714
|
+
});
|
|
715
|
+
|
|
625
716
|
// 启动期保留清理(Bug8)必须与写入侧读**同一份**配置。面板把 llmAudit.enabled 写进
|
|
626
717
|
// kv、经 nestedFlags 合进 cfg,bundle 里可能是关的:清理若读 rawCfg,就会出现「面板
|
|
627
718
|
// 说开 → 各写入点照写,raw 说关 → 不清理」这种无保留期的增长(写入准入 #254 也是读
|