@modusensus/dsh-mneme 0.10.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -6
- package/bin/dsh-mneme-choro.mjs +254 -0
- package/docs/CONFIGURATION.md +10 -1
- package/lib/api-standalone.js +17 -21
- package/lib/api.js +414 -12
- package/lib/bootstrap-http.js +105 -0
- package/lib/bootstrap.js +5 -1
- package/lib/choro-command.js +104 -0
- package/lib/client.js +1637 -110
- package/lib/config-keys.js +144 -0
- package/lib/config.js +60 -1
- package/lib/document.js +9 -3
- package/lib/dream/clustering.js +6 -1
- package/lib/index.js +87 -5
- package/lib/inject.js +18 -4
- package/lib/lang.js +82 -0
- package/lib/profile-facts.js +356 -0
- package/lib/quality-filter.js +48 -12
- package/lib/service.js +112 -4
- package/lib/settings.js +13 -1
- package/lib/store.js +978 -27
- package/lib/tools.js +244 -1
- package/lib/user-settings.js +63 -7
- package/lib/wiki/inject.js +164 -0
- package/lib/wiki/partition.js +200 -0
- package/lib/wiki/plan.js +214 -0
- package/lib/wiki/queries.js +360 -0
- package/lib/wiki/render.js +187 -0
- package/lib/wiki/scan.js +371 -0
- package/lib/wiki/t0.js +463 -0
- package/lib/wiki/t1.js +496 -0
- package/lib/write-admission.js +5 -3
- package/package.json +4 -2
- package/scripts/sync-test-badge.mjs +1 -1
- package/scripts/test-count-sync.mjs +9 -5
- package/src/api-standalone.js +17 -21
- package/src/api.js +414 -12
- package/src/bootstrap-http.js +105 -0
- package/src/bootstrap.js +5 -1
- package/src/choro-command.js +104 -0
- package/src/config-keys.js +144 -0
- package/src/config.js +60 -1
- package/src/document.js +9 -3
- package/src/dream/clustering.js +6 -1
- package/src/index.js +87 -5
- package/src/inject.js +18 -4
- package/src/lang.js +82 -0
- package/src/profile-facts.js +356 -0
- package/src/quality-filter.js +48 -12
- package/src/service.js +112 -4
- package/src/settings.js +13 -1
- package/src/store.js +978 -27
- package/src/tools.js +244 -1
- package/src/user-settings.js +63 -7
- package/src/wiki/inject.js +164 -0
- package/src/wiki/partition.js +200 -0
- package/src/wiki/plan.js +214 -0
- package/src/wiki/queries.js +360 -0
- package/src/wiki/render.js +187 -0
- package/src/wiki/scan.js +371 -0
- package/src/wiki/t0.js +463 -0
- package/src/wiki/t1.js +496 -0
- package/src/write-admission.js +5 -3
- package/test/api-routes-gaps.test.js +131 -1
- package/test/api.test.js +9 -1
- package/test/bootstrap.test.js +8 -0
- package/test/choro-command.test.js +218 -0
- package/test/client.test.js +802 -21
- package/test/config-keys-routes.test.js +233 -0
- package/test/config.test.js +79 -0
- package/test/contrast.test.js +14 -7
- package/test/document.test.js +32 -0
- package/test/embedding-cache-f32.test.js +276 -0
- package/test/inject-pin-explicit.test.js +300 -0
- package/test/inject-pin-vector-crowd.test.js +225 -0
- package/test/inject.test.js +51 -0
- package/test/llm-audit.test.js +29 -1
- package/test/persona-settings.test.js +31 -0
- package/test/profile-facts.test.js +492 -0
- package/test/quality-backfill.test.js +290 -0
- package/test/quality-filter.test.js +113 -4
- package/test/service.test.js +1 -1
- package/test/session-gate.test.js +1 -1
- package/test/test-count-sync.test.js +14 -3
- package/test/tool-state-ignore.test.js +65 -0
- package/test/tools-dto-schema-matrix.test.js +23 -2
- package/test/tools.test.js +144 -7
- package/test/wiki-api.test.js +273 -0
- package/test/wiki-inject.test.js +265 -0
- package/test/wiki-partition.test.js +112 -0
- package/test/wiki-plan.test.js +80 -0
- package/test/wiki-queries.test.js +270 -0
- package/test/wiki-scan.test.js +208 -0
- package/test/wiki-store.test.js +190 -0
- package/test/wiki-t0.test.js +380 -0
- package/test/wiki-t1.test.js +235 -0
- package/test/wiki-tools.test.js +176 -0
|
@@ -0,0 +1,225 @@
|
|
|
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 { createVectorIndex } from "../src/vector-index.js";
|
|
6
|
+
|
|
7
|
+
// 回归(pin 池被向量路封顶饿死):`injectCandidates` 里 hybrid 分支先把
|
|
8
|
+
// `vectorIndex.search(qv, {limit: poolSize})` 的结果全量 push 进 merged,随后
|
|
9
|
+
// `if (merged.length >= poolSize) break;` 在**pin 筛选之前**就封顶——生产
|
|
10
|
+
// maxItems=5 → poolSize=10,向量路一次正好返回 10 条即 break,规则窗口里那些
|
|
11
|
+
// constraint/preference 一条都进不了 candidates,pin 池只能从这 10 条里筛,
|
|
12
|
+
// 命中几条纯看它们碰巧是什么类型。
|
|
13
|
+
//
|
|
14
|
+
// 代码注释向来自称「pin 不占 maxItems 名额」,实现里却把名额占掉了。实测真库
|
|
15
|
+
// maxItems=5 时 pin shown 只有 2/5,补回后恒 5/5;maxItems=10(poolSize=20)
|
|
16
|
+
// 时前后都是 5/5——所以修法不是多给名额,是把被向量路吃掉的 pin 候选补回来。
|
|
17
|
+
//
|
|
18
|
+
// 修法位置:不在 hybrid 分支之前提前摘(那样会绕过下面 strictScope 与
|
|
19
|
+
// scopeState 两道闸门,而 :1907 的注释明确写着「pin 池 eligible 取自过滤之后的
|
|
20
|
+
// 候选,顺带保证越权行进不了 pin」),而是在合并之后、闸门之前把被甩掉的 pin
|
|
21
|
+
// 类行补回候选池尾部——闸门照旧作用其上。
|
|
22
|
+
//
|
|
23
|
+
// 本文件锁三件事:
|
|
24
|
+
// ① 向量路把 poolSize 填满时,pin 池照旧拿满预算(缺口本体)
|
|
25
|
+
// ② 补回的是规则窗口(items)里的 pin,与向量命中集无关——即使查询向量与
|
|
26
|
+
// pin 条目毫不相干,它们也能进块(这是 pin 的定义:每轮必进)
|
|
27
|
+
// ③ 预算关时行为逐字节不变
|
|
28
|
+
|
|
29
|
+
function setup(config = {}) {
|
|
30
|
+
const store = createStore(":memory:");
|
|
31
|
+
const service = createService({ store, mirror: null, config });
|
|
32
|
+
const vectorIndex = createVectorIndex({ store });
|
|
33
|
+
service.setVectorIndex(vectorIndex);
|
|
34
|
+
return { store, service, vectorIndex };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 向量路上与查询完全同向的普通条目:正好填满 poolSize 个名额,且一条都不是 pin 类型。
|
|
38
|
+
function seedVectorCrowd(service, vectorIndex, n) {
|
|
39
|
+
const ids = [];
|
|
40
|
+
for (let i = 0; i < n; i++) {
|
|
41
|
+
const m = service.saveWithDedupe({
|
|
42
|
+
type: "decision",
|
|
43
|
+
title: `决定条目${i}`,
|
|
44
|
+
content: `第 ${i} 条与查询同向的普通决定,不该占掉 pin 的名额`,
|
|
45
|
+
importance: 5
|
|
46
|
+
}).memory;
|
|
47
|
+
vectorIndex.saveEmbedding(m.id, [1, 0, 0]);
|
|
48
|
+
ids.push(m.id);
|
|
49
|
+
}
|
|
50
|
+
return ids;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 与查询向量正交的 pin 条目:向量路永远检索不到它们,只能靠规则窗口进块。
|
|
54
|
+
function seedPinnedPreferences(service, vectorIndex, n) {
|
|
55
|
+
const ids = [];
|
|
56
|
+
for (let i = 0; i < n; i++) {
|
|
57
|
+
const m = service.saveWithDedupe({
|
|
58
|
+
type: "preference",
|
|
59
|
+
title: `硬偏好${i}`,
|
|
60
|
+
content: `第 ${i} 条约束/偏好,每轮必进,与查询向量正交`,
|
|
61
|
+
importance: 5
|
|
62
|
+
}).memory;
|
|
63
|
+
vectorIndex.saveEmbedding(m.id, [0, 1, 0]);
|
|
64
|
+
ids.push(m.id);
|
|
65
|
+
}
|
|
66
|
+
return ids;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const titlesOf = (list) => list.map((m) => m.title);
|
|
70
|
+
|
|
71
|
+
test("pin 池不被向量路封顶:poolSize 被填满时仍拿满 pinnedInjectBudget", () => {
|
|
72
|
+
// maxItems=5 → poolSize=10,与生产默认值一致;预算是 5,满额。
|
|
73
|
+
const { service, vectorIndex } = setup({
|
|
74
|
+
maxInjectedItems: 5,
|
|
75
|
+
pinnedInjectBudget: 5,
|
|
76
|
+
importanceThreshold: 3
|
|
77
|
+
});
|
|
78
|
+
seedVectorCrowd(service, vectorIndex, 10); // 正好 poolSize 条,全是 decision
|
|
79
|
+
seedPinnedPreferences(service, vectorIndex, 5);
|
|
80
|
+
|
|
81
|
+
const pinnedStats = {};
|
|
82
|
+
const selected = service.injectCandidates({
|
|
83
|
+
query: "随便问一个与偏好无关的问题",
|
|
84
|
+
maxItems: 5,
|
|
85
|
+
queryVector: [1, 0, 0],
|
|
86
|
+
pinnedStats
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// 缺口本体的断言:修前这里是 0(向量路 10 条里一条 preference 都没有)。
|
|
90
|
+
assert.equal(pinnedStats.shown, 5, "pin 池必须拿满预算,不能被向量路的 poolSize 截断");
|
|
91
|
+
const pinnedTitles = titlesOf(selected).filter((t) => t.startsWith("硬偏好"));
|
|
92
|
+
assert.equal(pinnedTitles.length, 5, "5 条 pin 必须真的进块(不只是计数对)");
|
|
93
|
+
// pin 前置在块内:前 5 位必须全是 pin 条目(同 importance/updated_at 的
|
|
94
|
+
// 同批条目内部次序由 id 决定,不是本用例要锁的东西,故只断言集合)。
|
|
95
|
+
const head = titlesOf(selected).slice(0, 5);
|
|
96
|
+
assert.ok(head.every((t) => t.startsWith("硬偏好")), `pin 前置到块内排序之前:${head.join(" / ")}`);
|
|
97
|
+
assert.deepEqual([...head].sort(), [...pinnedTitles].sort(), "前 5 位就是那 5 条 pin");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("pin 池取自规则窗口,不取自被封顶的 candidates", () => {
|
|
101
|
+
// 这条锁的是「怎么修」而不是「修没修」:向量路返回的 10 条与 pin 类型完全
|
|
102
|
+
// 不相交,若 eligible 仍从 candidates 过滤,pin 恒为 0——无论 poolSize 多大。
|
|
103
|
+
const { service, vectorIndex } = setup({
|
|
104
|
+
maxInjectedItems: 2,
|
|
105
|
+
pinnedInjectBudget: 2,
|
|
106
|
+
importanceThreshold: 3
|
|
107
|
+
});
|
|
108
|
+
seedVectorCrowd(service, vectorIndex, 4); // poolSize = maxItems*2 = 4,正好填满
|
|
109
|
+
seedPinnedPreferences(service, vectorIndex, 2);
|
|
110
|
+
|
|
111
|
+
const pinnedStats = {};
|
|
112
|
+
const selected = service.injectCandidates({
|
|
113
|
+
query: "另一个与偏好无关的问题",
|
|
114
|
+
maxItems: 2,
|
|
115
|
+
queryVector: [1, 0, 0],
|
|
116
|
+
pinnedStats
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
assert.equal(pinnedStats.shown, 2, "maxItems=2 时 pin 也要拿满 2 条");
|
|
120
|
+
const head = titlesOf(selected).slice(0, 2);
|
|
121
|
+
assert.ok(
|
|
122
|
+
head.every((t) => t.startsWith("硬偏好")),
|
|
123
|
+
`pin 从规则窗口取,与向量命中集无关:${head.join(" / ")}`
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("pin 池关闭时行为不变:pinnedInjectBudget=0 不得凭空引入偏好", () => {
|
|
128
|
+
// 关闭态必须与改动前逐字节一致——补回的 pin 只在预算 > 0 时发生,
|
|
129
|
+
// 预算关时 candidates 与改前完全一样。
|
|
130
|
+
const { service, vectorIndex } = setup({
|
|
131
|
+
maxInjectedItems: 5,
|
|
132
|
+
pinnedInjectBudget: 0,
|
|
133
|
+
importanceThreshold: 3
|
|
134
|
+
});
|
|
135
|
+
seedVectorCrowd(service, vectorIndex, 10);
|
|
136
|
+
seedPinnedPreferences(service, vectorIndex, 5);
|
|
137
|
+
|
|
138
|
+
const pinnedStats = {};
|
|
139
|
+
const selected = service.injectCandidates({
|
|
140
|
+
query: "又一个与偏好无关的问题",
|
|
141
|
+
maxItems: 5,
|
|
142
|
+
queryVector: [1, 0, 0],
|
|
143
|
+
pinnedStats
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
assert.equal(pinnedStats.shown, 0, "预算 0 → pin 池关闭");
|
|
147
|
+
assert.equal(selected.length, 5, "槽位数不变");
|
|
148
|
+
assert.ok(
|
|
149
|
+
titlesOf(selected).every((t) => t.startsWith("决定条目")),
|
|
150
|
+
"关闭态下 preference 不得被前置(顺序与改动前一致)"
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// 安全底线:补回 pin 是在 strictScope 与 scopeState 两道闸门**之前**做的,
|
|
155
|
+
// 所以这两道闸必须照旧能拦住被补回来的 pin 行。service.js 里那条位置纪律
|
|
156
|
+
// (「pin 池 eligible 取自过滤之后的候选,顺带保证越权行进不了 pin」)就是靠
|
|
157
|
+
// 这两条用例守住的——不然「补回」会顺带开一个越权口子。
|
|
158
|
+
test("补回的 pin 仍受 strictScope 硬过滤:越权偏好不得因补回而进块", () => {
|
|
159
|
+
const { store, service, vectorIndex } = setup({
|
|
160
|
+
maxInjectedItems: 5,
|
|
161
|
+
pinnedInjectBudget: 5,
|
|
162
|
+
importanceThreshold: 3,
|
|
163
|
+
strictScope: true
|
|
164
|
+
});
|
|
165
|
+
seedVectorCrowd(service, vectorIndex, 10);
|
|
166
|
+
// 一条属于 proj-a 的偏好:调用方 scope 是 proj-b 时它必须进不来。
|
|
167
|
+
const m = service.saveWithDedupe({
|
|
168
|
+
type: "preference",
|
|
169
|
+
title: "越权偏好",
|
|
170
|
+
content: "只属于 proj-a 的偏好,不该泄进 proj-b 的注入块",
|
|
171
|
+
importance: 5,
|
|
172
|
+
workspace_scope: "proj-a",
|
|
173
|
+
workspace_scope_source: "explicit"
|
|
174
|
+
}).memory;
|
|
175
|
+
vectorIndex.saveEmbedding(m.id, [0, 1, 0]);
|
|
176
|
+
|
|
177
|
+
const pinnedStats = {};
|
|
178
|
+
const selected = service.injectCandidates({
|
|
179
|
+
query: "与偏好无关的问题",
|
|
180
|
+
maxItems: 5,
|
|
181
|
+
queryVector: [1, 0, 0],
|
|
182
|
+
scope: { agent_scope: null, workspace_scope: "proj-b" },
|
|
183
|
+
pinnedStats
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
assert.ok(
|
|
187
|
+
!titlesOf(selected).includes("越权偏好"),
|
|
188
|
+
`strictScope 必须挡住被补回的越权 pin:${titlesOf(selected).join(" / ")}`
|
|
189
|
+
);
|
|
190
|
+
assert.equal(pinnedStats.shown, 0, "被 scope 挡下的行不能算成已 pin");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("补回的 pin 仍受 scopeState 拦截档约束:暂停作用域的偏好不得进块", () => {
|
|
194
|
+
const { service, vectorIndex } = setup({
|
|
195
|
+
maxInjectedItems: 5,
|
|
196
|
+
pinnedInjectBudget: 5,
|
|
197
|
+
importanceThreshold: 3,
|
|
198
|
+
scopeState: { enabled: true, enforce: true }
|
|
199
|
+
});
|
|
200
|
+
seedVectorCrowd(service, vectorIndex, 10);
|
|
201
|
+
const m = service.saveWithDedupe({
|
|
202
|
+
type: "preference",
|
|
203
|
+
title: "暂停域偏好",
|
|
204
|
+
content: "属于 proj-a 的偏好,而 proj-a 此刻被按下",
|
|
205
|
+
importance: 5,
|
|
206
|
+
workspace_scope: "proj-a",
|
|
207
|
+
workspace_scope_source: "explicit"
|
|
208
|
+
}).memory;
|
|
209
|
+
vectorIndex.saveEmbedding(m.id, [0, 1, 0]);
|
|
210
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
211
|
+
|
|
212
|
+
const pinnedStats = {};
|
|
213
|
+
const selected = service.injectCandidates({
|
|
214
|
+
query: "与偏好无关的问题",
|
|
215
|
+
maxItems: 5,
|
|
216
|
+
queryVector: [1, 0, 0],
|
|
217
|
+
pinnedStats
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
assert.ok(
|
|
221
|
+
!titlesOf(selected).includes("暂停域偏好"),
|
|
222
|
+
`scopeState 拦截档必须挡住被补回的 pin:${titlesOf(selected).join(" / ")}`
|
|
223
|
+
);
|
|
224
|
+
assert.equal(pinnedStats.shown, 0, "被闸门拦下的行不能算成已 pin");
|
|
225
|
+
});
|
package/test/inject.test.js
CHANGED
|
@@ -441,3 +441,54 @@ test("throwing surface getter degrades to full-log hot memory instead of a blank
|
|
|
441
441
|
assert.ok(text.includes("热记忆不该因此整块消失"), "轮次回复应照旧进入");
|
|
442
442
|
assert.equal(getInjectionSnapshot().query, "把投影插件卸了会怎样", "语义查询不受 getter 抛错影响");
|
|
443
443
|
});
|
|
444
|
+
|
|
445
|
+
// M3 仓库地志(chorography):注入块折进同一个 memory context(不新开 context),
|
|
446
|
+
// 次序是 时间 → 地志 → 热上下文 → 长期记忆。这三例锁的是「折进来之后仍只有一个块」
|
|
447
|
+
// 与「空串不占位」——后者是宿主契约(空文本 section 被 filter 掉),不是我们自己加的。
|
|
448
|
+
function setupWithChoro(choroText, over = {}) {
|
|
449
|
+
const store = createStore(":memory:");
|
|
450
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
451
|
+
const settings = createSettings(store.db);
|
|
452
|
+
const contexts = [];
|
|
453
|
+
const ctx = { systemPrompt: { context(def) { contexts.push(def); return () => {}; } } };
|
|
454
|
+
const calls = [];
|
|
455
|
+
const choroInject = {
|
|
456
|
+
tier: "light",
|
|
457
|
+
text(cwd) { calls.push(cwd); return typeof choroText === "function" ? choroText(cwd) : choroText; },
|
|
458
|
+
dispose() {}
|
|
459
|
+
};
|
|
460
|
+
const config = { maxInjectedItems: 3, importanceThreshold: 3, ...over };
|
|
461
|
+
const injector = createInjector(ctx, service, settings, config, null, null, choroInject);
|
|
462
|
+
return { store, service, contexts, injector, calls, ctx };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
test("M3: 地志块折进 memory context,不新开第二个 context", () => {
|
|
466
|
+
const { contexts } = setupWithChoro("[仓库地志]");
|
|
467
|
+
assert.equal(contexts.length, 2, "折进来就仍是两块;多开一块会改宿主的装配顺序");
|
|
468
|
+
assert.equal(contexts[0].name, "memory");
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("M3: 地志块排在热上下文与长期记忆之前,且 cwd 取自 scope.workspace_scope", () => {
|
|
472
|
+
const { contexts, service, calls } = setupWithChoro("[仓库地志]");
|
|
473
|
+
service.saveWithDedupe({ type: "preference", title: "语言", content: "用户用中文交流", importance: 5 });
|
|
474
|
+
const events = [
|
|
475
|
+
{ type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "这个仓库怎么组织" }] } },
|
|
476
|
+
{ type: "assistant/message", data: { message: { content: [{ type: "text", text: "按模块分组" }] } } }
|
|
477
|
+
];
|
|
478
|
+
// scope.workspace_scope 不是入参,是 scope.js 现场解析出来的(registry 反查,
|
|
479
|
+
// header.cwd 兜底);测试只能提供 header.cwd,才能真验「注入器吃的是那一份口径」。
|
|
480
|
+
const text = contexts[0].text({ agent: { session: { header: { cwd: "/repo" }, snapshotEvents: () => events } } });
|
|
481
|
+
const iChoro = text.indexOf("[仓库地志]");
|
|
482
|
+
const iHot = text.indexOf("[短期上下文]");
|
|
483
|
+
const iMem = text.indexOf("[记忆库]");
|
|
484
|
+
assert.ok(iChoro >= 0 && iHot > iChoro && iMem > iHot, "次序 地志 → 热上下文 → 长期记忆:" + text);
|
|
485
|
+
assert.ok(calls.includes("/repo"), "cwd 只能用 scope.workspace_scope 这一份口径(P27),calls=" + JSON.stringify(calls));
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("M3: 地志为空串时不占位,块首尾不留空行;choroChars 记实际体积", () => {
|
|
489
|
+
const { contexts, service } = setupWithChoro("");
|
|
490
|
+
service.saveWithDedupe({ type: "preference", title: "语言", content: "用户用中文交流", importance: 5 });
|
|
491
|
+
const text = contexts[0].text({});
|
|
492
|
+
assert.equal(text.startsWith("[记忆库]"), true, "空串拼接不许在块首留下空行:" + JSON.stringify(text.slice(0, 8)));
|
|
493
|
+
assert.equal(getInjectionSnapshot().choroChars, 0);
|
|
494
|
+
});
|
package/test/llm-audit.test.js
CHANGED
|
@@ -552,7 +552,7 @@ test("entity extraction writes an llm_audit row with the streamed usage tokens",
|
|
|
552
552
|
});
|
|
553
553
|
const text = await streamEntityText(
|
|
554
554
|
[{ role: "user", content: [{ type: "text", text: "记忆内容" }] }],
|
|
555
|
-
{}
|
|
555
|
+
{ memoryId: "mem-ok-1" }
|
|
556
556
|
);
|
|
557
557
|
assert.ok(text.includes("entities"), "the extracted text still reaches the extractor");
|
|
558
558
|
|
|
@@ -565,6 +565,34 @@ test("entity extraction writes an llm_audit row with the streamed usage tokens",
|
|
|
565
565
|
assert.equal(row.input_tokens, 700, "input tokens come from chunk.usage");
|
|
566
566
|
assert.equal(row.output_tokens, 180, "output tokens come from chunk.usage");
|
|
567
567
|
assert.equal(row.total_tokens, 880);
|
|
568
|
+
// success 行同样必须可归因。此前这里不传 memoryId、也不断言 id,于是
|
|
569
|
+
// writeAudit("success", null) 省略第三参的缺陷在绿测试下活了很久——活库
|
|
570
|
+
// 6256 条 success 行 related_memory_ids 100% 空数组,成功的那些抽取反而
|
|
571
|
+
// 一条都对不上记忆,#405 只修了 error 半边。
|
|
572
|
+
assert.deepEqual(row.related_memory_ids, ["mem-ok-1"],
|
|
573
|
+
"a successful extraction is attributable to the memory it read");
|
|
574
|
+
store.close();
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("entity extraction success row still leaves the id empty when there is none to attribute", async () => {
|
|
578
|
+
const store = createStore(":memory:");
|
|
579
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
580
|
+
const streamEntityText = createEntityStreamAdapter({
|
|
581
|
+
llm: {
|
|
582
|
+
async *stream() {
|
|
583
|
+
yield { type: "text-delta", index: 0, text: '{"entities":[],"relations":[]}' };
|
|
584
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
585
|
+
}
|
|
586
|
+
},
|
|
587
|
+
agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
|
|
588
|
+
logger: { warn: () => {} },
|
|
589
|
+
service
|
|
590
|
+
});
|
|
591
|
+
await streamEntityText([{ role: "user", content: [{ type: "text", text: "记忆内容" }] }], {});
|
|
592
|
+
const row = service.listLlmAudits({ source: "entityExtract" })[0];
|
|
593
|
+
assert.equal(row.status, "success");
|
|
594
|
+
assert.deepEqual(row.related_memory_ids, [],
|
|
595
|
+
"no memoryId → empty, never a fabricated id");
|
|
568
596
|
store.close();
|
|
569
597
|
});
|
|
570
598
|
|
|
@@ -165,3 +165,34 @@ test("personaEnabled is in the feature-flag boolean whitelist", () => {
|
|
|
165
165
|
assert.throws(() => settings.setFeatureFlags({ notARealFlag: true }), TypeError);
|
|
166
166
|
store.close();
|
|
167
167
|
});
|
|
168
|
+
|
|
169
|
+
test("strict 寻址:一张身份卡都没建时仍回落 default(#587 的例外)", () => {
|
|
170
|
+
// 两种处境必须分开:
|
|
171
|
+
// A) 建了身份卡、只是没这一张 → strict 下不注入(见上一条用例,行为不变)
|
|
172
|
+
// B) 一张身份卡都没建 → default 是用户手里唯一那张卡,strict 不许把它关死
|
|
173
|
+
// DS Bot 这类宿主里所有 bot 会话共用 agentPreset "standard",用户按 bot 名字建卡
|
|
174
|
+
// 永远不命中,于是必然落进 B——此时按 none 处理等于把唯一那张卡静默废掉。
|
|
175
|
+
const { store, settings } = setup();
|
|
176
|
+
settings.setPersona({ name: "Amarre", enabled: true });
|
|
177
|
+
assert.deepEqual(settings.getPersona().byAgent, {}, "前提:byAgent 整体为空");
|
|
178
|
+
const { card, source } = resolvePersonaCard(settings, "standard", { strict: true });
|
|
179
|
+
assert.equal(source, "default", "一张卡都没建时严格寻址仍回落 default");
|
|
180
|
+
assert.equal(card.name, "Amarre");
|
|
181
|
+
// 建了任意一张身份卡之后,例外立即失效——兜底退回严格语义
|
|
182
|
+
settings.setPersonaFor("zephyr", { name: "泽菲尔", enabled: true });
|
|
183
|
+
assert.equal(resolvePersonaCard(settings, "standard", { strict: true }).source, "none");
|
|
184
|
+
// 而这张卡自己仍然命中
|
|
185
|
+
assert.equal(resolvePersonaCard(settings, "zephyr", { strict: true }).source, "byAgent");
|
|
186
|
+
store.close();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("strict 寻址:身份卡存在但全被关掉,仍算「建过卡」——例外只看有没有键", () => {
|
|
190
|
+
// 例外判的是 byAgent 的键数,不是「有没有可用的卡」:一张 enabled=false 的卡
|
|
191
|
+
// 也算「用户已经做过分区」,此时不该再拿 default 去兜。这条锁住判据不会被
|
|
192
|
+
// 改成「有可用卡才算建过」,否则关掉唯一那张身份卡会重新落回 default。
|
|
193
|
+
const { store, settings } = setup();
|
|
194
|
+
settings.setPersona({ name: "Amarre", enabled: true });
|
|
195
|
+
settings.setPersonaFor("zephyr", { name: "泽菲尔", enabled: false });
|
|
196
|
+
assert.equal(resolvePersonaCard(settings, "standard", { strict: true }).source, "none");
|
|
197
|
+
store.close();
|
|
198
|
+
});
|