@modusensus/dsh-mneme 0.12.0 → 0.13.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 +48 -19
- package/docs/CONFIGURATION.md +43 -4
- package/docs/DIRECTORIES.md +114 -0
- package/docs/DISTILL.md +64 -0
- package/docs/DSH-GO.md +97 -0
- package/docs/ENTITIES.md +5 -3
- package/docs/FORGET.md +77 -0
- package/docs/KB.md +110 -0
- package/docs/SLEEP.md +28 -3
- package/docs/STORAGE.md +23 -0
- package/lib/api-standalone.js +4 -1
- package/lib/api.js +581 -29
- package/lib/attachment-text.js +122 -0
- package/lib/choro-command.js +9 -2
- package/lib/client.js +3537 -1205
- package/lib/config.js +139 -1
- package/lib/continuity.js +7 -6
- package/lib/diary/auto.js +190 -0
- package/lib/diary/compose.js +440 -0
- package/lib/diary/dir.js +59 -0
- package/lib/diary/export.js +97 -0
- package/lib/diary/index.js +26 -0
- package/lib/diary/library.js +189 -0
- package/lib/diary/qq-inbox.js +142 -0
- package/lib/diary/rollover.js +61 -0
- package/lib/diary/session-provider.js +122 -0
- package/lib/dream/decisions.js +56 -5
- package/lib/dream/sleep.js +616 -15
- package/lib/dream.js +45 -13
- package/lib/dsh-go/host.js +162 -0
- package/lib/dsh-go/panel.js +2091 -0
- package/lib/dsh-go/text.js +342 -0
- package/lib/dsh-go/views/common.js +68 -0
- package/lib/dsh-go/views/entity.js +48 -0
- package/lib/dsh-go/views/memory.js +198 -0
- package/lib/dsh-go/views/root.js +30 -0
- package/lib/dsh-go/views/settings.js +398 -0
- package/lib/dsh-go/views/status.js +51 -0
- package/lib/embedding.js +42 -8
- package/lib/entities/extractor.js +36 -7
- package/lib/hot-memory.js +8 -9
- package/lib/index.js +92 -2
- package/lib/inject.js +189 -18
- package/lib/kb/index.js +309 -0
- package/lib/kb/routes.js +220 -0
- package/lib/kb/tools.js +148 -0
- package/lib/lang.js +87 -4
- package/lib/peak-hours.js +57 -2
- package/lib/semantic.js +347 -60
- package/lib/service.js +193 -66
- package/lib/settings.js +112 -14
- package/lib/store.js +347 -21
- package/lib/summarize.js +38 -11
- package/lib/tools.js +35 -4
- package/lib/user-settings.js +128 -32
- package/lib/vector-index.js +76 -6
- package/lib/wiki/cli-hint.js +90 -0
- package/lib/wiki/queries.js +116 -2
- package/lib/wiki/t1-jobs.js +232 -0
- package/lib/wiki/t1.js +7 -1
- package/package.json +25 -1
- package/src/api-standalone.js +4 -1
- package/src/api.js +581 -29
- package/src/attachment-text.js +122 -0
- package/src/choro-command.js +9 -2
- package/src/config.js +139 -1
- package/src/continuity.js +7 -6
- package/src/diary/auto.js +190 -0
- package/src/diary/compose.js +440 -0
- package/src/diary/dir.js +59 -0
- package/src/diary/export.js +97 -0
- package/src/diary/index.js +26 -0
- package/src/diary/library.js +189 -0
- package/src/diary/qq-inbox.js +142 -0
- package/src/diary/rollover.js +61 -0
- package/src/diary/session-provider.js +122 -0
- package/src/dream/decisions.js +56 -5
- package/src/dream/sleep.js +616 -15
- package/src/dream.js +45 -13
- package/src/dsh-go/host.js +162 -0
- package/src/dsh-go/panel.js +2091 -0
- package/src/dsh-go/text.js +342 -0
- package/src/dsh-go/views/common.js +68 -0
- package/src/dsh-go/views/entity.js +48 -0
- package/src/dsh-go/views/memory.js +198 -0
- package/src/dsh-go/views/root.js +30 -0
- package/src/dsh-go/views/settings.js +398 -0
- package/src/dsh-go/views/status.js +51 -0
- package/src/embedding.js +42 -8
- package/src/entities/extractor.js +36 -7
- package/src/hot-memory.js +8 -9
- package/src/index.js +92 -2
- package/src/inject.js +189 -18
- package/src/kb/index.js +309 -0
- package/src/kb/routes.js +220 -0
- package/src/kb/tools.js +148 -0
- package/src/lang.js +87 -4
- package/src/peak-hours.js +57 -2
- package/src/semantic.js +347 -60
- package/src/service.js +193 -66
- package/src/settings.js +112 -14
- package/src/store.js +347 -21
- package/src/summarize.js +38 -11
- package/src/tools.js +35 -4
- package/src/user-settings.js +128 -32
- package/src/vector-index.js +76 -6
- package/src/wiki/cli-hint.js +90 -0
- package/src/wiki/queries.js +116 -2
- package/src/wiki/t1-jobs.js +232 -0
- package/src/wiki/t1.js +7 -1
- package/test/api-routes-gaps.test.js +63 -0
- package/test/api.test.js +375 -4
- package/test/attachment-text.test.js +140 -0
- package/test/bm25-corpus-cache.test.js +145 -0
- package/test/capacity-warning.test.js +262 -0
- package/test/client.test.js +779 -154
- package/test/conflict-freeze.test.js +45 -1
- package/test/continuity.test.js +8 -1
- package/test/diary-library.test.js +187 -0
- package/test/diary.test.js +576 -0
- package/test/dream-maxtokens-fallback.test.js +3 -3
- package/test/dream.test.js +62 -1
- package/test/dsh-go-host.test.js +134 -0
- package/test/dsh-go-panel-actions.test.js +854 -0
- package/test/dsh-go-panel.test.js +251 -0
- package/test/embedding-ready.test.js +33 -3
- package/test/entities.test.js +73 -0
- package/test/forget-corrections.test.js +70 -0
- package/test/image-memory.test.js +171 -0
- package/test/issue-617-step1-query-and-fusion.test.js +361 -0
- package/test/kb-api-wiring.test.js +128 -0
- package/test/kb-collections.test.js +272 -0
- package/test/kb-routes.test.js +379 -0
- package/test/mint-source.test.js +26 -0
- package/test/peak-hours-observability.test.js +70 -0
- package/test/persona-api.test.js +3 -3
- package/test/persona-by-session.test.js +568 -0
- package/test/persona-settings.test.js +7 -7
- package/test/reflection-update-gate.test.js +129 -0
- package/test/reindex-backfill.test.js +334 -0
- package/test/reindex-scheduler.test.js +369 -0
- package/test/scope-candidates.test.js +249 -0
- package/test/sleep-action-set.test.js +14 -0
- package/test/sleep-heat.test.js +49 -0
- package/test/sleep.test.js +598 -0
- package/test/standalone-api.test.js +24 -0
- package/test/store.test.js +20 -0
- package/test/summarize.test.js +26 -0
- package/test/user-settings-segments.test.js +528 -0
- package/test/vector-index.test.js +120 -6
- package/test/wiki-api.test.js +355 -0
- package/test/wiki-cli-hint.test.js +118 -0
- package/test/wiki-queries.test.js +7 -0
- package/test/wiki-t1-jobs.test.js +125 -0
|
@@ -0,0 +1,251 @@
|
|
|
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 { buildView } from "../src/dsh-go/views/root.js";
|
|
7
|
+
|
|
8
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const PKG_ROOT = join(HERE, "..");
|
|
10
|
+
|
|
11
|
+
// 这组守的是**视图契约**,不是像素:dsh-go 只认 JSON,越界(节点数 / 层数 / 字节)
|
|
12
|
+
// 是会在用户手机上直接报错的硬失败,所以每加一屏都要过一遍预算。
|
|
13
|
+
// 取数(src/dsh-go/host.js)与动作(src/dsh-go/panel.js)需要真宿主,端到端在
|
|
14
|
+
// dsh-go-bridge 那一侧验证;这里只锁得到「视图能拼出来、拼得合规」。
|
|
15
|
+
|
|
16
|
+
const LIMITS = { nodes: 120, depth: 8, bytes: 32768 };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 与 bridge 的 viewStats 同口径:节点 = 带 `type` 的组件对象(bindings / options /
|
|
20
|
+
* actions 里的参数对象不算节点),层数 = 组件 children 的递归深度。
|
|
21
|
+
*/
|
|
22
|
+
function stats(node, depth = 1) {
|
|
23
|
+
if (node === null || typeof node !== "object") return { nodes: 0, depth };
|
|
24
|
+
if (Array.isArray(node)) {
|
|
25
|
+
let nodes = 0;
|
|
26
|
+
let deepest = depth;
|
|
27
|
+
for (const child of node) {
|
|
28
|
+
const s = stats(child, depth);
|
|
29
|
+
nodes += s.nodes;
|
|
30
|
+
deepest = Math.max(deepest, s.depth);
|
|
31
|
+
}
|
|
32
|
+
return { nodes, depth: deepest };
|
|
33
|
+
}
|
|
34
|
+
const isComponent = typeof node.type === "string";
|
|
35
|
+
let nodes = isComponent ? 1 : 0;
|
|
36
|
+
let deepest = isComponent ? depth : depth;
|
|
37
|
+
for (const value of Object.values(node)) {
|
|
38
|
+
if (value === null || typeof value !== "object") continue;
|
|
39
|
+
const s = stats(value, isComponent ? depth + 1 : depth);
|
|
40
|
+
nodes += s.nodes;
|
|
41
|
+
deepest = Math.max(deepest, s.depth);
|
|
42
|
+
}
|
|
43
|
+
return { nodes, depth: deepest };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 面板状态的默认形状(与 src/dsh-go/panel.js 的 state 对齐,覆盖视图读到的每个字段)。 */
|
|
47
|
+
function state(over = {}) {
|
|
48
|
+
return {
|
|
49
|
+
page: "list",
|
|
50
|
+
flagGroup: "basic",
|
|
51
|
+
settingsView: "settings",
|
|
52
|
+
sPanel: "menu",
|
|
53
|
+
profileTab: "profile",
|
|
54
|
+
q: "",
|
|
55
|
+
type: "all",
|
|
56
|
+
range: "all",
|
|
57
|
+
minImportance: 0,
|
|
58
|
+
scope: "all",
|
|
59
|
+
semantic: false,
|
|
60
|
+
viewMode: "flat",
|
|
61
|
+
heatSort: false,
|
|
62
|
+
filtersOpen: false,
|
|
63
|
+
offset: 0,
|
|
64
|
+
size: 20,
|
|
65
|
+
current: null,
|
|
66
|
+
form: {},
|
|
67
|
+
entityTotal: 12,
|
|
68
|
+
entityQuery: "",
|
|
69
|
+
entityName: "",
|
|
70
|
+
entityDepth: 1,
|
|
71
|
+
entityRelationCount: 0,
|
|
72
|
+
entityMemoryCount: 0,
|
|
73
|
+
flags: {},
|
|
74
|
+
flagOverrides: {},
|
|
75
|
+
pendingRestart: [],
|
|
76
|
+
pendingConflicts: 0,
|
|
77
|
+
pendingIds: new Set(),
|
|
78
|
+
rowsCount: 0,
|
|
79
|
+
monthGroups: [],
|
|
80
|
+
detailEntities: [],
|
|
81
|
+
detailEntityCount: 0,
|
|
82
|
+
detailConflict: false,
|
|
83
|
+
provenanceText: "",
|
|
84
|
+
conflictCount: 0,
|
|
85
|
+
conflictId: "",
|
|
86
|
+
conflictPick: "",
|
|
87
|
+
conflictA: null,
|
|
88
|
+
conflictB: null,
|
|
89
|
+
conflictReady: false,
|
|
90
|
+
wbKv: [],
|
|
91
|
+
wbDeposited: [],
|
|
92
|
+
wbArchived: [],
|
|
93
|
+
commands: [],
|
|
94
|
+
cmdCount: 0,
|
|
95
|
+
sCmdPick: "",
|
|
96
|
+
sCmdName: "",
|
|
97
|
+
sCmdDesc: "",
|
|
98
|
+
sCmdIns: "",
|
|
99
|
+
vector: { enabled: false, baseUrl: "", model: "" },
|
|
100
|
+
external: { enabled: false, port: 8790, host: "127.0.0.1" },
|
|
101
|
+
versionNote: "",
|
|
102
|
+
extractText: "",
|
|
103
|
+
injectPreviewText: "",
|
|
104
|
+
tokenCategories: [],
|
|
105
|
+
tokenSeries: [],
|
|
106
|
+
tokenKv: [],
|
|
107
|
+
heatSeries: [],
|
|
108
|
+
heatNote: "",
|
|
109
|
+
reclaimText: "",
|
|
110
|
+
reclaimConfirm: false,
|
|
111
|
+
notice: "",
|
|
112
|
+
noticeTone: "ok",
|
|
113
|
+
err: "",
|
|
114
|
+
...over,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const SCREENS = [
|
|
119
|
+
["记忆列表", state()],
|
|
120
|
+
["记忆列表·月视图", state({ viewMode: "month", monthGroups: [{ label: "2026-11", rows: [] }] })],
|
|
121
|
+
["记忆详情", state({ page: "detail", current: { id: "m1", archived: false } })],
|
|
122
|
+
["记忆编辑", state({ page: "editor", form: { id: "m1", type: "project" } })],
|
|
123
|
+
["记忆新建", state({ page: "editor", form: {} })],
|
|
124
|
+
["冲突队列", state({ page: "conflict" })],
|
|
125
|
+
["冲突对照", state({ page: "conflict", conflictReady: true, conflictPick: "a", conflictCount: 1 })],
|
|
126
|
+
["实体", state({ entityTotal: 0 })],
|
|
127
|
+
["实体详情", state({ page: "entity" })],
|
|
128
|
+
["设置首页", state()],
|
|
129
|
+
["状态", state({ settingsView: "status" })],
|
|
130
|
+
["画像", state({ page: "profile" })],
|
|
131
|
+
["人格", state({ page: "profile", profileTab: "persona" })],
|
|
132
|
+
["规则", state({ page: "profile", profileTab: "rules" })],
|
|
133
|
+
["工作动态", state({ sPanel: "workbench" })],
|
|
134
|
+
["自定义指令", state({ sPanel: "commands", cmdCount: 1, commands: [{ id: "c1", name: "x" }], sCmdPick: "c1" })],
|
|
135
|
+
["向量检索", state({ sPanel: "vector" })],
|
|
136
|
+
["外部访问", state({ sPanel: "external" })],
|
|
137
|
+
["关于", state({ sPanel: "help" })],
|
|
138
|
+
["Agent 眼中的你", state({ sPanel: "facts" })],
|
|
139
|
+
["代码库地志", state({ sPanel: "choro" })],
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
for (const [name, fixture] of SCREENS) {
|
|
143
|
+
test(`dsh-go 视图在预算内:${name}`, () => {
|
|
144
|
+
const view = buildView(fixture);
|
|
145
|
+
assert.equal(view.v, 2);
|
|
146
|
+
const s = stats(view);
|
|
147
|
+
assert.ok(s.nodes <= LIMITS.nodes, `${name} 节点 ${s.nodes} > ${LIMITS.nodes}`);
|
|
148
|
+
assert.ok(s.depth <= LIMITS.depth, `${name} 层 ${s.depth} > ${LIMITS.depth}`);
|
|
149
|
+
const bytes = Buffer.byteLength(JSON.stringify(view), "utf8");
|
|
150
|
+
assert.ok(bytes <= LIMITS.bytes, `${name} 体积 ${bytes} > ${LIMITS.bytes}`);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
test("dsh-go 顶层是三个原生页签(App 页签条每格 80dp,第四格会折行)", () => {
|
|
155
|
+
const view = buildView(state());
|
|
156
|
+
const tabs = view.children.find((node) => node.type === "tabs");
|
|
157
|
+
assert.ok(tabs, "顶层没有 tabs");
|
|
158
|
+
assert.deepEqual(
|
|
159
|
+
tabs.items.map((item) => item.label),
|
|
160
|
+
["记忆", "实体", "设置"],
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("功能开关铺在设置页正文里,不是子页面(#dsh-go 对齐电脑端分组)", () => {
|
|
165
|
+
const view = buildView(state());
|
|
166
|
+
const flat = JSON.stringify(view);
|
|
167
|
+
assert.ok(flat.includes("功能开关"), "缺少功能开关标题");
|
|
168
|
+
assert.ok(!flat.includes("功能开关(分组)"), "标题仍带着「(分组)」");
|
|
169
|
+
assert.ok(flat.includes('"bind":"data.flagGroup"'), "分组选择器没有绑定");
|
|
170
|
+
assert.ok(/"bind":"data\.flags\./.test(flat), "没有开关绑定到 data.flags.*");
|
|
171
|
+
assert.ok(!flat.includes('"k":"flags"'), "还有指向 flags 子页面的入口");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
function walk(node, out = []) {
|
|
175
|
+
if (node === null || typeof node !== "object") return out;
|
|
176
|
+
if (Array.isArray(node)) {
|
|
177
|
+
for (const child of node) walk(child, out);
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
out.push(node);
|
|
181
|
+
for (const value of Object.values(node)) walk(value, out);
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
test("设置页里不再出现自造的解释性小字(只留 mneme 原文案)", () => {
|
|
186
|
+
const flat = JSON.stringify(buildView(state()));
|
|
187
|
+
const invented = [
|
|
188
|
+
"38 项开关",
|
|
189
|
+
"写成斜杠命令,随时调用",
|
|
190
|
+
"跨会话记住你是谁",
|
|
191
|
+
"接口地址、模型、重建索引",
|
|
192
|
+
"版本检测与数据来源",
|
|
193
|
+
"轻量模式少做后台整理",
|
|
194
|
+
"沉淀 =",
|
|
195
|
+
];
|
|
196
|
+
for (const phrase of invented) assert.ok(!flat.includes(phrase), `自造文案还在:${phrase}`);
|
|
197
|
+
assert.ok(flat.includes("控制插件在后台做哪些事"), "缺少 mneme 原文案(features.desc)");
|
|
198
|
+
assert.ok(flat.includes("轻量模式只保留核心能力"), "缺少 mneme 原文案(mode.desc)");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("dsh-go 清单合法:一个面板、placement 只落 session.tab / features", () => {
|
|
202
|
+
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
|
|
203
|
+
const manifest = pkg.dshGo;
|
|
204
|
+
assert.equal(manifest.v, 1);
|
|
205
|
+
const panels = manifest.contributes.panels;
|
|
206
|
+
assert.equal(panels.length, 1);
|
|
207
|
+
const [panel] = panels;
|
|
208
|
+
assert.equal(panel.id, "mneme");
|
|
209
|
+
assert.ok(["session.tab", "features"].includes(panel.placement[0]));
|
|
210
|
+
// 面板里不出现密钥 / 令牌字样:清单是 App 侧可读的静态文件
|
|
211
|
+
assert.ok(!JSON.stringify(manifest).includes("token"));
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("记忆页与实体页的树不随数据变:首屏和拿到数据后是同一棵树", () => {
|
|
215
|
+
// 面板的 view() 在 data() 之前就要交出整棵树,所以按数据决定「要不要这块」
|
|
216
|
+
// 的地方,首屏会先按默认值定形,之后数据推来了也只是填绑定——空状态块就会
|
|
217
|
+
// 和有数据的列表同时挂在屏幕上。这里把两棵树直接比对,锁住结构稳定。
|
|
218
|
+
const empty = JSON.stringify(buildView(state({ rowsCount: 0, entityTotal: 0 })));
|
|
219
|
+
const filled = JSON.stringify(buildView(state({ rowsCount: 20, entityTotal: 12 })));
|
|
220
|
+
assert.equal(empty, filled, "视图结构仍然跟着数据走,首屏会留下残影");
|
|
221
|
+
assert.ok(!empty.includes('"type":"emptyState"'), "记忆 / 实体页不再用数据决定空状态块");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("翻页是 App 原生的两段 segmented:一行铺满,不折行也不叠按钮", () => {
|
|
225
|
+
const nodes = walk(buildView(state()));
|
|
226
|
+
const pager = nodes.find((node) => node.type === "segmented" && node.bind === "data.pagePick");
|
|
227
|
+
assert.ok(pager, "缺少翻页 segmented");
|
|
228
|
+
assert.deepEqual(
|
|
229
|
+
pager.options.map((option) => option.label),
|
|
230
|
+
["上一页", "下一页"],
|
|
231
|
+
);
|
|
232
|
+
assert.deepEqual(
|
|
233
|
+
pager.options.map((option) => option.value),
|
|
234
|
+
["prev", "next"],
|
|
235
|
+
);
|
|
236
|
+
assert.equal(pager.onChange.id, "page");
|
|
237
|
+
// 页码文案挂在 label 上(必绘),不再单独占一行。
|
|
238
|
+
assert.deepEqual(pager.label, { $: "data.pageText" });
|
|
239
|
+
|
|
240
|
+
// 回归:曾经用两个 stack + chips 做左右对齐,右列在真机上渲染失败
|
|
241
|
+
// (App 显示「这部分无法显示」),所以翻页只能是单个节点。
|
|
242
|
+
assert.equal(
|
|
243
|
+
nodes.filter((node) => node.type === "chips" && node.bind === "data.pagePick").length,
|
|
244
|
+
0,
|
|
245
|
+
"翻页又回到了 chips 方案",
|
|
246
|
+
);
|
|
247
|
+
const folded = nodes.filter(
|
|
248
|
+
(node) => node.type === "actions" && (node.items ?? []).some((item) => item.action?.id === "page"),
|
|
249
|
+
);
|
|
250
|
+
assert.equal(folded.length, 0, "翻页按钮还在 actions 里:窄屏会被折成上下两行");
|
|
251
|
+
});
|
|
@@ -159,10 +159,12 @@ test("reindexMissing: 逐行回填并统计 indexed/skipped,有成功才记模
|
|
|
159
159
|
test("reindexMissing: 未启用时直接返回 0/0,不碰 store", async () => {
|
|
160
160
|
const { embedder } = make({ ...FULL, enabled: false });
|
|
161
161
|
const result = await embedder.reindexMissing(50);
|
|
162
|
-
assert.deepEqual(result, { indexed: 0, skipped: 0 });
|
|
162
|
+
assert.deepEqual(result, { indexed: 0, failed: 0, skipped: 0 });
|
|
163
163
|
});
|
|
164
164
|
|
|
165
|
-
test("reindexMissing: 嵌入失败的行走
|
|
165
|
+
test("reindexMissing: 嵌入失败的行走 failed,不算 indexed、也不算 skipped", async () => {
|
|
166
|
+
// #604:`skipped = rows.length - indexed` 把「试过没拿到向量」和「压根没碰」
|
|
167
|
+
// 混成一个数。每一行都被尝试过,所以 skipped 必须是 0,失败单列。
|
|
166
168
|
const writes = [];
|
|
167
169
|
const embedder = createEmbedder({
|
|
168
170
|
store: {
|
|
@@ -174,7 +176,35 @@ test("reindexMissing: 嵌入失败的行走 skipped,不算 indexed", async ()
|
|
|
174
176
|
await withFetch(async () => ({ ok: false }), async () => {
|
|
175
177
|
const result = await embedder.reindexMissing(50);
|
|
176
178
|
assert.equal(result.indexed, 0);
|
|
177
|
-
assert.equal(result.
|
|
179
|
+
assert.equal(result.failed, 1, "试过但没拿到向量 → failed");
|
|
180
|
+
assert.equal(result.skipped, 0, "选择器选中了它,所以它没有被跳过");
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("reindexMissing: 一行写入抛错不中断其余行,且计入 failed", async () => {
|
|
185
|
+
// 注意:这里抛错的不能是 embedding 请求 —— embedTextDetailed 把网络错误吞成
|
|
186
|
+
// request-failed:network,永远不抛。真正会让这个 catch 跑起来的是写库失败
|
|
187
|
+
// (约束冲突、磁盘满),所以用 setEmbedding 抛。
|
|
188
|
+
const writes = [];
|
|
189
|
+
const embedder = createEmbedder({
|
|
190
|
+
store: {
|
|
191
|
+
setEmbedding: (id, v) => {
|
|
192
|
+
if (id === "m1") throw new Error("SQLITE_CONSTRAINT");
|
|
193
|
+
writes.push({ id, v });
|
|
194
|
+
},
|
|
195
|
+
needsEmbedding: () => [
|
|
196
|
+
{ id: "m1", title: "t1", content: "a" },
|
|
197
|
+
{ id: "m2", title: "t2", content: "b" }
|
|
198
|
+
]
|
|
199
|
+
},
|
|
200
|
+
settings: { getVectorConfig: () => FULL },
|
|
201
|
+
logger: { info() {}, warn() {} }
|
|
202
|
+
});
|
|
203
|
+
await withFetch(async () => okResponse([1, 0]), async () => {
|
|
204
|
+
const result = await embedder.reindexMissing(50);
|
|
205
|
+
assert.equal(result.failed, 1, "写库失败的那行算 failed");
|
|
206
|
+
assert.equal(result.indexed, 1, "后面那行仍然被处理,循环没有中断");
|
|
207
|
+
assert.equal(writes.length, 1);
|
|
178
208
|
});
|
|
179
209
|
});
|
|
180
210
|
|
package/test/entities.test.js
CHANGED
|
@@ -296,6 +296,79 @@ test("extractor writes entities, attrs and relations from mock LLM output", asyn
|
|
|
296
296
|
store.close();
|
|
297
297
|
});
|
|
298
298
|
|
|
299
|
+
test("extractor refuses to write affective_context, which belongs to the sleep phase", async () => {
|
|
300
|
+
// #619:affective_context 只有一个写入者——sleep 的 affective 相位。实体抽取发生
|
|
301
|
+
// 在写入当下,手里没有会话边界,判不了「一次场合」和「反复出现的特质」,写进去的
|
|
302
|
+
// 只会是一条没有 emotion/attribution/sessionCount 的半成品边,同一张表两种 schema。
|
|
303
|
+
// 所以抽取器把它当未知类型拒收,而不是当一等公民放行。
|
|
304
|
+
const store = openStore();
|
|
305
|
+
const memory = store.save({ type: "history", title: "答辩前一晚", content: "答辩前一晚他很焦虑", importance: 3 });
|
|
306
|
+
const warnings = [];
|
|
307
|
+
const logger = { warn: (msg) => warnings.push(String(msg)), info: () => {} };
|
|
308
|
+
const callLLM = jsonLLM({
|
|
309
|
+
entities: [
|
|
310
|
+
{ name: "他", type: "person", attrs: [] },
|
|
311
|
+
{ name: "答辩", type: "concept", attrs: [] }
|
|
312
|
+
],
|
|
313
|
+
relations: [{ from: "他", to: "答辩", type: "affective_context" }]
|
|
314
|
+
});
|
|
315
|
+
const result = await extractEntities(memory, { store, config: {}, callLLM, logger });
|
|
316
|
+
assert.equal(result.ok, true);
|
|
317
|
+
assert.equal(result.relations.length, 0);
|
|
318
|
+
assert.deepEqual(result.rejectedRelationTypes, ["affective_context"]);
|
|
319
|
+
assert.equal(warnings.length, 1);
|
|
320
|
+
assert.match(warnings[0], /affective_context/);
|
|
321
|
+
assert.equal(store.getRelations(store.findEntityByName("他").id).length, 0);
|
|
322
|
+
store.close();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("extractor rejects unknown relation types instead of silently downgrading to related_to", async () => {
|
|
326
|
+
// 变异自证:把 sanitizeRelationType 改回 `return "related_to"` 兜底,这条必红
|
|
327
|
+
// ——既会多落一条假边,rejectedRelationTypes 也会变空、warn 不再触发。
|
|
328
|
+
const store = openStore();
|
|
329
|
+
const memory = store.save({ type: "project", title: "混入未知类型", content: "A 对 B 有种奇怪的关系", importance: 3 });
|
|
330
|
+
const warnings = [];
|
|
331
|
+
const logger = { warn: (msg) => warnings.push(String(msg)), info: () => {} };
|
|
332
|
+
const callLLM = jsonLLM({
|
|
333
|
+
entities: [
|
|
334
|
+
{ name: "A", type: "concept", attrs: [] },
|
|
335
|
+
{ name: "B", type: "concept", attrs: [] }
|
|
336
|
+
],
|
|
337
|
+
relations: [
|
|
338
|
+
{ from: "A", to: "B", type: "feels_weird_about" },
|
|
339
|
+
{ from: "A", to: "B", type: "related_to" }
|
|
340
|
+
]
|
|
341
|
+
});
|
|
342
|
+
const result = await extractEntities(memory, { store, config: {}, callLLM, logger });
|
|
343
|
+
assert.equal(result.ok, true);
|
|
344
|
+
assert.deepEqual(result.rejectedRelationTypes, ["feels_weird_about"]);
|
|
345
|
+
assert.equal(warnings.length, 1);
|
|
346
|
+
assert.match(warnings[0], /feels_weird_about/);
|
|
347
|
+
const rels = store.getRelations(store.findEntityByName("A").id);
|
|
348
|
+
assert.equal(rels.length, 1, "only the legitimate relation lands");
|
|
349
|
+
assert.equal(rels[0].relation_type, "related_to");
|
|
350
|
+
store.close();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("extractor drops a relation whose type is not even a string", async () => {
|
|
354
|
+
const store = openStore();
|
|
355
|
+
const memory = store.save({ type: "project", title: "空类型", content: "A 和 B 有联系", importance: 3 });
|
|
356
|
+
const callLLM = jsonLLM({
|
|
357
|
+
entities: [
|
|
358
|
+
{ name: "A", type: "concept", attrs: [] },
|
|
359
|
+
{ name: "B", type: "concept", attrs: [] }
|
|
360
|
+
],
|
|
361
|
+
relations: [{ from: "A", to: "B" }]
|
|
362
|
+
});
|
|
363
|
+
const result = await extractEntities(memory, { store, config: {}, callLLM });
|
|
364
|
+
assert.equal(result.ok, true);
|
|
365
|
+
assert.equal(result.relations.length, 0);
|
|
366
|
+
assert.deepEqual(result.rejectedRelationTypes, ["undefined"]);
|
|
367
|
+
assert.deepEqual(store.getRelations(store.findEntityByName("A").id), []);
|
|
368
|
+
store.close();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
|
|
299
372
|
test("resolveEntity dedups: re-extracting the same name bumps mention_count", async () => {
|
|
300
373
|
const store = openStore();
|
|
301
374
|
const memory = store.save({ type: "preference", title: "工具", content: "用 Vite", importance: 3 });
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// #532 选择性遗忘 × 事实更正的结构性回归锁。
|
|
2
|
+
//
|
|
3
|
+
// 现象(议题主楼):更正两个事实 → 只忘一个 → 保留的那个答回更正前的旧值。
|
|
4
|
+
// 根因是三段契约的接缝(docs/FORGET.md §1):更正由蒸馏落成新行(三元组去重不
|
|
5
|
+
// 命中、dream 冲突裁决阈值门控未运行)→ 旧行与新行之间没有持久失效关系 →
|
|
6
|
+
// memory_forget 按行抑制,合流在同一行里的其他事实连带退出可见面,旧值行成了
|
|
7
|
+
// 唯一幸存载体。
|
|
8
|
+
//
|
|
9
|
+
// 本文件锁两件事:
|
|
10
|
+
// ① 存储层合成轨迹(零 LLM):两行并存、遗忘新行、旧行原样幸存——这是现象的
|
|
11
|
+
// 存储层等价物。**机制修复落地时 ⑤ 的断言应当翻转**:更正行写入后旧行要么
|
|
12
|
+
// 被记上失效关系、要么退出活跃面;注释里写明翻转条件,防止它被当成「永远
|
|
13
|
+
// 该绿」的现状锁读。
|
|
14
|
+
// ② memory_forget 的工具描述必须带行粒度警示:模型无从得知「请求的语义粒度
|
|
15
|
+
// (一个事实)」与「工具的能力粒度(一行)」之间的落差,除非描述告诉它。
|
|
16
|
+
import test from "node:test";
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { createStore } from "../src/store.js";
|
|
19
|
+
import { createService } from "../src/service.js";
|
|
20
|
+
import { createTools } from "../src/tools.js";
|
|
21
|
+
|
|
22
|
+
test("#532 合成轨迹:更正落新行、遗忘新行、旧行原样幸存(机制修复落地时 ⑤ 翻转)", () => {
|
|
23
|
+
const store = createStore(":memory:");
|
|
24
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
25
|
+
try {
|
|
26
|
+
// ① 原始陈述(对话 A 的蒸馏产物:颜色 + 时间合流一行)
|
|
27
|
+
const r1 = service.saveWithDedupe({
|
|
28
|
+
type: "preference", title: "纸模工具的收纳与整理",
|
|
29
|
+
content: "纸模工具放在雾蓝色收纳盒,每周三晚上八点整理一次", importance: 3
|
|
30
|
+
});
|
|
31
|
+
// ② 更正(对话 B:蒸馏器另起标题 → (type,title,scope) 三元组不命中 → 新行)
|
|
32
|
+
const r2 = service.saveWithDedupe({
|
|
33
|
+
type: "preference", title: "收纳盒颜色与整理时间的更正",
|
|
34
|
+
content: "收纳盒换成浅橙色,整理时间改为每周六上午九点", importance: 3
|
|
35
|
+
});
|
|
36
|
+
assert.notEqual(r1.memory.id, r2.memory.id, "标题不同 → 去重不命中,更正落成新行");
|
|
37
|
+
// ③ 结构断言:两行同时活跃,二者之间没有任何失效链接(本车道基线未含 #275
|
|
38
|
+
// 的 capacityStats,直接查列)
|
|
39
|
+
const activeCount = () => store.db.prepare("SELECT COUNT(*) AS n FROM memories WHERE forgotten = 0 AND archived = 0").get().n;
|
|
40
|
+
assert.equal(activeCount(), 2, "更正不淘汰旧行(dream 未运行的阈值下无失效关系)");
|
|
41
|
+
// ④ 「忘掉颜色」=按行遗忘新值行(模型在可见行里看到的颜色载体)
|
|
42
|
+
service.setForget(r2.memory.id, true);
|
|
43
|
+
// ⑤ 现象复现:新值行退出可见面后,旧值行仍是唯一幸存载体——这正是 #532
|
|
44
|
+
// 「保留的更正事实答回旧值」的存储层等价物。机制修复(写侧更正失效关系 /
|
|
45
|
+
// dream 触发前移,见 docs/FORGET.md §3)落地时翻转:旧行应被记上失效关系
|
|
46
|
+
// 或退出活跃面,断言改为不存在「幸存的旧值行」。
|
|
47
|
+
assert.equal(store.getById(r1.memory.id).forgotten, false, "旧行从未被任何机制波及(toRow 把列归一成 boolean)");
|
|
48
|
+
assert.match(store.getById(r1.memory.id).content, /每周三晚上八点/, "旧值仍在召回面");
|
|
49
|
+
// 被抑制的行可恢复(接口契约:行粒度遗忘不是删除)——恢复后两行回到并存
|
|
50
|
+
// 状态:失效关系的缺位不因遗忘/恢复而改变。
|
|
51
|
+
service.setForget(r2.memory.id, false);
|
|
52
|
+
assert.equal(activeCount(), 2);
|
|
53
|
+
} finally {
|
|
54
|
+
store.close();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("memory_forget 的工具描述必须带行粒度警示(#532 对话 5 的模型误用直接对策)", () => {
|
|
59
|
+
const registered = [];
|
|
60
|
+
const ctx = { tools: { register(def) { registered.push(def); return () => {}; } } };
|
|
61
|
+
createTools(ctx, { getById: () => undefined }, {}, undefined);
|
|
62
|
+
const forget = registered.find((d) => d.name === "memory_forget");
|
|
63
|
+
assert.ok(forget, "memory_forget 必须注册");
|
|
64
|
+
// 锁「语义」不锁逐字:描述必须让模型知道抑制按行、合流事实连带退出、
|
|
65
|
+
// 请求只针对一个事实时先看行内容或拆分。防的回归是有人把描述改回
|
|
66
|
+
// 「只说单条抑制」,模型在「忘掉 X」请求下重新对合流行整条下手。
|
|
67
|
+
const desc = String(forget.description || "");
|
|
68
|
+
assert.match(desc, /per ROW/i, "描述必须写明抑制粒度是行");
|
|
69
|
+
assert.match(desc, /memory_update|inspect|say in your reply/i, "描述必须给出合流行下的替代动作");
|
|
70
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Issue #611:图片记忆端到端——一张图真的走进蒸馏转录。
|
|
2
|
+
//
|
|
3
|
+
// 单元用例(attachment-text.test.js)只证明「块 → 文本」这一步是对的。这一份锁的是
|
|
4
|
+
// 另一半:**collectMessages 真的会把它拼进发给模型的那条消息里**,并且这个数会落进
|
|
5
|
+
// 审计 metadata。改动前三处 textOf 都返回空串,图-only 消息连一行都不会产出——所以
|
|
6
|
+
// 这条用例在旧代码上必然红,这是它存在的理由。
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { createStore } from "../src/store.js";
|
|
10
|
+
import { createService } from "../src/service.js";
|
|
11
|
+
import { createSummarizer } from "../src/summarize.js";
|
|
12
|
+
|
|
13
|
+
function setup(over = {}) {
|
|
14
|
+
const store = createStore(":memory:");
|
|
15
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
16
|
+
const events = [];
|
|
17
|
+
const calls = [];
|
|
18
|
+
const ctx = {
|
|
19
|
+
on(name, fn) {
|
|
20
|
+
events.push({ name, fn });
|
|
21
|
+
return () => {};
|
|
22
|
+
},
|
|
23
|
+
llm: {
|
|
24
|
+
stream(options) {
|
|
25
|
+
calls.push(options);
|
|
26
|
+
const json = JSON.stringify([
|
|
27
|
+
{ type: "history", title: "看过一张截图", content: "用户发了一张 1543x983 的截图并问数据对比", importance: 3 }
|
|
28
|
+
]);
|
|
29
|
+
return (async function* () {
|
|
30
|
+
yield { type: "block-start", block: { type: "text" } };
|
|
31
|
+
yield { type: "text-delta", delta: json };
|
|
32
|
+
yield { type: "block-end", block: { type: "text" } };
|
|
33
|
+
yield { type: "finish", kind: "ok" };
|
|
34
|
+
})();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const config = { autoSummarize: true, distillRateLimitIntervalMs: 0, ...over };
|
|
39
|
+
const summarizer = createSummarizer(ctx, service, config, {});
|
|
40
|
+
return { store, service, events, calls, summarizer };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function imageMessage(seq, text) {
|
|
44
|
+
const content = [];
|
|
45
|
+
if (text) content.push({ type: "text", text });
|
|
46
|
+
content.push({
|
|
47
|
+
type: "image",
|
|
48
|
+
attachment: {
|
|
49
|
+
attachmentId: "sha256:abc123",
|
|
50
|
+
mediaType: "image/png",
|
|
51
|
+
width: 1543,
|
|
52
|
+
height: 983,
|
|
53
|
+
name: "屏幕截图 2026-10-19 072000.png"
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
return { seq, type: "user/message", data: { source: { kind: "user" }, content } };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
test("#611 图+文消息:蒸馏转录里同时有文字与图片占位", async () => {
|
|
61
|
+
const { events, calls } = setup();
|
|
62
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
63
|
+
const session = {
|
|
64
|
+
id: "s611-mixed",
|
|
65
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
66
|
+
events: [
|
|
67
|
+
imageMessage(1, "能有那种数据对比吗"),
|
|
68
|
+
{ seq: 2, type: "turn/end" }
|
|
69
|
+
]
|
|
70
|
+
};
|
|
71
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
72
|
+
|
|
73
|
+
assert.equal(calls.length, 1, "这一窗应当发起一次蒸馏");
|
|
74
|
+
const transcript = calls[0].messages.find((m) => m.role === "user").content[0].text;
|
|
75
|
+
assert.ok(transcript.includes("能有那种数据对比吗"), "文字照旧进转录");
|
|
76
|
+
assert.ok(transcript.includes("[图:"), "图片必须留下占位,而不是消失");
|
|
77
|
+
assert.ok(transcript.includes("屏幕截图 2026-10-19 072000.png"), "文件名要能对上原始附件");
|
|
78
|
+
assert.ok(transcript.includes("1543x983"), "尺寸要留下");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("#611 图-only 消息不再被整条丢弃(改动前窗口里一行都没有)", async () => {
|
|
82
|
+
const { events, calls } = setup();
|
|
83
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
84
|
+
const session = {
|
|
85
|
+
id: "s611-image-only",
|
|
86
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
87
|
+
events: [imageMessage(1, null), { seq: 2, type: "turn/end" }]
|
|
88
|
+
};
|
|
89
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
90
|
+
|
|
91
|
+
assert.equal(calls.length, 1, "图-only 窗口现在也要蒸馏——旧版这里直接 noop");
|
|
92
|
+
const transcript = calls[0].messages.find((m) => m.role === "user").content[0].text;
|
|
93
|
+
assert.ok(transcript.includes("[图:"), "占位就是这一窗的全部可提炼内容");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("#611 图片张数落进审计 metadata,off 档与静默丢弃可区分", async () => {
|
|
97
|
+
const { events, service } = setup();
|
|
98
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
99
|
+
const session = {
|
|
100
|
+
id: "s611-audit",
|
|
101
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
102
|
+
events: [
|
|
103
|
+
imageMessage(1, "第一张"),
|
|
104
|
+
imageMessage(2, "第二张"),
|
|
105
|
+
{ seq: 3, type: "turn/end" }
|
|
106
|
+
]
|
|
107
|
+
};
|
|
108
|
+
await handler(session, { seq: 3, type: "turn/end" });
|
|
109
|
+
|
|
110
|
+
const row = service.listLlmAudits().find((r) => r.status === "success");
|
|
111
|
+
assert.ok(row, "成功行要存在");
|
|
112
|
+
assert.equal(row.metadata?.imageCount, 2, "两张图必须被数出来");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("#611 没有图时不写 imageCount(零行为变化)", async () => {
|
|
116
|
+
const { events, service } = setup();
|
|
117
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
118
|
+
const session = {
|
|
119
|
+
id: "s611-no-image",
|
|
120
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
121
|
+
events: [
|
|
122
|
+
{ seq: 1, type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "只有文字" }] } },
|
|
123
|
+
{ seq: 2, type: "turn/end" }
|
|
124
|
+
]
|
|
125
|
+
};
|
|
126
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
127
|
+
|
|
128
|
+
const row = service.listLlmAudits().find((r) => r.status === "success");
|
|
129
|
+
assert.ok(row);
|
|
130
|
+
assert.equal(row.metadata?.imageCount, undefined, "没图就别写这个键,老档位逐字节不变");
|
|
131
|
+
});
|
|
132
|
+
test("#611 被挡掉的注入消息里的图不计入(CodeRabbit 2026-11-08 指出)", async () => {
|
|
133
|
+
// 计量的口径必须是「真正进了转录的图」,不是「窗口事件里出现过几张图」。
|
|
134
|
+
// 这条用例造一条带图的插件注入消息:它会被 publicTranscriptParts 整条挡掉
|
|
135
|
+
// (转录里没有它),所以 imageCount 不该把它算进去——否则审计会报「这窗有 1 张图」
|
|
136
|
+
// 而模型根本没看到这张图。
|
|
137
|
+
const { events, service, calls } = setup();
|
|
138
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
139
|
+
const injected = {
|
|
140
|
+
seq: 1,
|
|
141
|
+
type: "user/message",
|
|
142
|
+
data: {
|
|
143
|
+
source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" },
|
|
144
|
+
content: [
|
|
145
|
+
{ type: "text", text: "插件注入的机器内容" },
|
|
146
|
+
{
|
|
147
|
+
type: "image",
|
|
148
|
+
attachment: { attachmentId: "sha256:zzz", mediaType: "image/png", width: 10, height: 10, name: "不该被数.png" }
|
|
149
|
+
}
|
|
150
|
+
]
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
const session = {
|
|
154
|
+
id: "s611-injected",
|
|
155
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
156
|
+
events: [
|
|
157
|
+
injected,
|
|
158
|
+
{ seq: 2, type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "真人的一句" }] } },
|
|
159
|
+
{ seq: 3, type: "turn/end" }
|
|
160
|
+
]
|
|
161
|
+
};
|
|
162
|
+
await handler(session, { seq: 3, type: "turn/end" });
|
|
163
|
+
|
|
164
|
+
assert.equal(calls.length, 1, "窗口里有真人文本,照常蒸馏");
|
|
165
|
+
const transcript = calls[0].messages.find((m) => m.role === "user").content[0].text;
|
|
166
|
+
assert.ok(transcript.includes("真人的一句"));
|
|
167
|
+
assert.ok(!transcript.includes("插件注入的机器内容"), "注入消息本来就不进转录");
|
|
168
|
+
const row = service.listLlmAudits().find((r) => r.status === "success");
|
|
169
|
+
assert.ok(row);
|
|
170
|
+
assert.equal(row.metadata?.imageCount, undefined, "被挡掉的注入消息里的图不该被数进去");
|
|
171
|
+
});
|