@modusensus/dsh-mneme 0.9.5 → 0.10.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 +9 -5
- package/docs/CONFIGURATION.md +21 -0
- package/docs/DAEMON.md +6 -2
- package/docs/DISTILL.md +90 -6
- package/docs/SLEEP.md +20 -6
- package/docs/STORAGE.md +1 -0
- package/lib/api-standalone.js +11 -3
- package/lib/api.js +25 -5
- package/lib/client.js +137 -10
- package/lib/config.js +33 -1
- package/lib/continuity.js +11 -3
- package/lib/dream/decisions.js +88 -27
- package/lib/dream/sleep.js +467 -81
- package/lib/dream.js +110 -13
- package/lib/fence.js +24 -0
- package/lib/index.js +15 -6
- package/lib/inject.js +9 -4
- package/lib/lang.js +17 -12
- package/lib/pipeline-nudge-auto.js +3 -0
- package/lib/pipeline-nudge.js +41 -8
- package/lib/pipeline-runner.js +15 -0
- package/lib/pre-inject-gate.js +14 -0
- package/lib/profile-extract.js +4 -1
- package/lib/provenance-http.js +162 -37
- package/lib/service.js +30 -0
- package/lib/session-gate.js +91 -0
- package/lib/settings.js +13 -1
- package/lib/store.js +526 -18
- package/lib/summarize.js +152 -15
- package/lib/tools.js +26 -6
- package/package.json +4 -2
- package/src/api-standalone.js +11 -3
- package/src/api.js +25 -5
- package/src/config.js +33 -1
- package/src/continuity.js +11 -3
- package/src/dream/decisions.js +88 -27
- package/src/dream/sleep.js +467 -81
- package/src/dream.js +110 -13
- package/src/fence.js +24 -0
- package/src/index.js +15 -6
- package/src/inject.js +9 -4
- package/src/lang.js +17 -12
- package/src/pipeline-nudge-auto.js +3 -0
- package/src/pipeline-nudge.js +41 -8
- package/src/pipeline-runner.js +15 -0
- package/src/pre-inject-gate.js +14 -0
- package/src/profile-extract.js +4 -1
- package/src/provenance-http.js +162 -37
- package/src/service.js +30 -0
- package/src/session-gate.js +91 -0
- package/src/settings.js +13 -1
- package/src/store.js +526 -18
- package/src/summarize.js +152 -15
- package/src/tools.js +26 -6
- package/test/api.test.js +153 -4
- package/test/client.test.js +215 -0
- package/test/config.test.js +8 -0
- package/test/conflict-reviewed.test.js +79 -0
- package/test/continuity.test.js +29 -1
- package/test/dependency-overrides.test.js +78 -0
- package/test/differentiate-feedback.test.js +248 -0
- package/test/dream-decision-evidence.test.js +314 -0
- package/test/dream-fence.test.js +43 -0
- package/test/inject-pools.test.js +2 -1
- package/test/mint-source.test.js +83 -0
- package/test/pipeline-nudge-auto.test.js +26 -2
- package/test/pipeline-nudge.test.js +87 -0
- package/test/pipeline-runner.test.js +26 -0
- package/test/prompt-stance.test.js +128 -0
- package/test/provenance-http.test.js +200 -17
- package/test/release-docs.test.js +7 -1
- package/test/security-doc.test.js +43 -4
- package/test/session-gate.test.js +264 -0
- package/test/settings.test.js +4 -0
- package/test/sleep-action-set.test.js +16 -11
- package/test/sleep-catchup.test.js +315 -0
- package/test/sleep-failure-backoff.test.js +156 -0
- package/test/sleep.test.js +293 -0
- package/test/standalone-api.test.js +50 -0
- package/test/store.test.js +220 -0
- package/test/summarize.test.js +172 -0
package/test/mint-source.test.js
CHANGED
|
@@ -200,3 +200,86 @@ test("mint source: rows are countable by source after minting", () => {
|
|
|
200
200
|
assert.equal(bySource.get("manual"), undefined, "nothing minted here fell back to the default");
|
|
201
201
|
store.close();
|
|
202
202
|
});
|
|
203
|
+
|
|
204
|
+
// ── Issue #512:铸造出来的记忆要能追到「哪一轮」 ──
|
|
205
|
+
//
|
|
206
|
+
// source 只说「哪条管道」,不说「哪一次运行」。同一管道跑过几百轮之后,
|
|
207
|
+
// 「这条 pattern 是哪一轮抽出来的」在数据面上仍然不可读——这正是 #512 要补的。
|
|
208
|
+
// 归因落在 memory_mints:dream/sleep 铸造的记忆没有会话区间(session/seq 都不存在),
|
|
209
|
+
// 硬塞合成区间就是编的,所以窗口级归因(memory_provenance)与行级归因(本表)分层。
|
|
210
|
+
|
|
211
|
+
test("issue#512: a created row remembers which run minted it", () => {
|
|
212
|
+
const { store, service } = setup();
|
|
213
|
+
applyDecisions(
|
|
214
|
+
[{ action: "create", type: "pattern", title: "真模式", content: "规律", importance: 3 }],
|
|
215
|
+
service,
|
|
216
|
+
null,
|
|
217
|
+
null,
|
|
218
|
+
{ mintSource: "dream", runId: "run-abc" }
|
|
219
|
+
);
|
|
220
|
+
const row = service.all().find((m) => m.type === "pattern");
|
|
221
|
+
assert.ok(row, "pattern minted");
|
|
222
|
+
const mints = store.mintsFor(row.id);
|
|
223
|
+
assert.equal(mints.length, 1, "one created row = one mint record");
|
|
224
|
+
assert.equal(mints[0].run_id, "run-abc");
|
|
225
|
+
assert.equal(mints[0].run_type, "dream");
|
|
226
|
+
assert.equal(mints[0].trigger_source, "autoDream");
|
|
227
|
+
store.close();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// 没有 run id 时什么都不写。旧装配(或直接调 applyDecisions 的调用方)没有 run 概念,
|
|
231
|
+
// 这时候编一个 id 出来比留空更坏:它会让「能追到」变成一句谎话。
|
|
232
|
+
test("issue#512: without a run id no mint record is fabricated", () => {
|
|
233
|
+
const { store, service } = setup();
|
|
234
|
+
applyDecisions(
|
|
235
|
+
[{ action: "create", type: "pattern", title: "无轮模式", content: "规律", importance: 3 }],
|
|
236
|
+
service,
|
|
237
|
+
null,
|
|
238
|
+
null,
|
|
239
|
+
{ mintSource: "dream" }
|
|
240
|
+
);
|
|
241
|
+
const row = service.all().find((m) => m.type === "pattern");
|
|
242
|
+
assert.ok(row, "pattern still minted — 记账缺失不该拦铸造");
|
|
243
|
+
assert.deepEqual(store.mintsFor(row.id), []);
|
|
244
|
+
store.close();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// 只记 create。merge/conflict/update/supersede 动的是既有行,receipt_chain 已按
|
|
248
|
+
// (run_id, record_id) 记过一遍;再造一份就是第二套账,两边迟早对不上。
|
|
249
|
+
test("issue#512: only creation is recorded — edits to existing rows stay in receipt_chain", () => {
|
|
250
|
+
const { store, service } = setup();
|
|
251
|
+
const existing = service.saveWithDedupe({ type: "project", title: "既有行", content: "旧内容", importance: 3 }).memory;
|
|
252
|
+
applyDecisions(
|
|
253
|
+
[{ action: "update", ids: [existing.id], content: "新内容", importance: 4 }],
|
|
254
|
+
service,
|
|
255
|
+
null,
|
|
256
|
+
null,
|
|
257
|
+
{ mintSource: "dream", runId: "run-upd" }
|
|
258
|
+
);
|
|
259
|
+
assert.deepEqual(store.mintsFor(existing.id), [], "update 不是铸造,不进 memory_mints");
|
|
260
|
+
store.close();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// 端到端:sleep 真的跑一轮,铸造出来的行要指回那一轮的 runId(不是随便一个 id)。
|
|
264
|
+
test("issue#512: sleep mints point back at the run that produced them", async () => {
|
|
265
|
+
const { store, service } = setup();
|
|
266
|
+
mint(service);
|
|
267
|
+
mint(service);
|
|
268
|
+
const ctx = sleepCtx(
|
|
269
|
+
JSON.stringify([{ action: "create", type: "pattern", title: "真模式", content: "从 a 和 b 提取", importance: 3 }])
|
|
270
|
+
);
|
|
271
|
+
const config = { sleepModeEnabled: true, sleepPatternMinMemories: 10, sleepMaxPatternPerRun: 3 };
|
|
272
|
+
const result = await runSleep(ctx, service, config, ctx.logger, null, null);
|
|
273
|
+
assert.equal(result.phases.patterns.status, "ok", "pattern phase minted");
|
|
274
|
+
|
|
275
|
+
const row = service.all().find((m) => m.type === "pattern");
|
|
276
|
+
assert.ok(row, "pattern minted");
|
|
277
|
+
const mints = store.mintsFor(row.id);
|
|
278
|
+
assert.equal(mints.length, 1);
|
|
279
|
+
assert.equal(mints[0].run_id, result.runId, "归因要指回这一轮的 runId,不是另编一个");
|
|
280
|
+
assert.equal(mints[0].run_type, "sleep");
|
|
281
|
+
assert.equal(mints[0].trigger_source, "sleep");
|
|
282
|
+
// 那一轮 run 台账本身也在:两边能对上,归因才成立。
|
|
283
|
+
assert.equal(store.getDreamRun(result.runId).run_type, "sleep");
|
|
284
|
+
store.close();
|
|
285
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { createNudgeAutoRun } from "../src/pipeline-nudge-auto.js";
|
|
4
|
+
import { createSessionGate } from "../src/session-gate.js";
|
|
4
5
|
|
|
5
6
|
// 这组守的是**触发侧**(v0.9.3):什么时候跑、跑几次、跑挂了怎么办。
|
|
6
7
|
// 挖掘算法本身在 pipeline-nudge.test.js 里守,这里只守接线,所以 runNudge 全部用假的。
|
|
@@ -25,8 +26,8 @@ function fakeCtx() {
|
|
|
25
26
|
};
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
function sessionAt(cwd) {
|
|
29
|
-
return { header: { cwd } };
|
|
29
|
+
function sessionAt(cwd, id = undefined) {
|
|
30
|
+
return { ...(id ? { id } : {}), header: { cwd } };
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
/** 把 setTimeout(0) 排的活跑完:本模块刻意不用同步跑(见文件头)。 */
|
|
@@ -66,6 +67,29 @@ test("自动沉淀只认 turn/end:中途事件不排队、不预跑", async ()
|
|
|
66
67
|
auto();
|
|
67
68
|
});
|
|
68
69
|
|
|
70
|
+
test("#539:无痕 turn/end 不触发全项目扫描,正常会话仍可触发", async () => {
|
|
71
|
+
const ctx = fakeCtx();
|
|
72
|
+
const calls = [];
|
|
73
|
+
const gate = createSessionGate({ sessionGateEnabled: true, sessionGateSessions: ["private"] });
|
|
74
|
+
const auto = createNudgeAutoRun({
|
|
75
|
+
ctx,
|
|
76
|
+
sessionGate: gate,
|
|
77
|
+
runNudge: (args) => { calls.push(args); return { counts: { savedProposals: 0 } }; },
|
|
78
|
+
now: () => 1_000_000
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
ctx.emit(sessionAt("D:/proj", "private"), { type: "turn/end" });
|
|
82
|
+
await tick();
|
|
83
|
+
assert.deepEqual(calls, [], "the incognito session itself cannot schedule a scan");
|
|
84
|
+
assert.equal(auto.pendingCount(), 0);
|
|
85
|
+
|
|
86
|
+
ctx.emit(sessionAt("D:/proj", "captain"), { type: "turn/end" });
|
|
87
|
+
await tick();
|
|
88
|
+
assert.equal(calls.length, 1);
|
|
89
|
+
assert.equal(calls[0].sessionGate, gate, "later project scans still exclude incognito session files");
|
|
90
|
+
auto();
|
|
91
|
+
});
|
|
92
|
+
|
|
69
93
|
test("自动跑永远是「只落提议、不落草稿」:materialize 恒 false", async () => {
|
|
70
94
|
const ctx = fakeCtx();
|
|
71
95
|
const calls = [];
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
nudgeForProject,
|
|
38
38
|
resolveProjectSessionDir
|
|
39
39
|
} from "../src/pipeline-nudge.js";
|
|
40
|
+
import { createSessionGate } from "../src/session-gate.js";
|
|
40
41
|
|
|
41
42
|
// 这组测试守的是 M.O. 的**沉淀链**:一条工作流能不能从真实数据里自己长出来。
|
|
42
43
|
// 三条不许越的分界线(见 src/pipeline-nudge.js 文件头),测试逐条守:
|
|
@@ -103,6 +104,92 @@ test("多帧 zstd 要逐帧解:只解第一帧会静默丢掉后面的回合",
|
|
|
103
104
|
});
|
|
104
105
|
});
|
|
105
106
|
|
|
107
|
+
test("#539:nudge 日志扫描按精确 id 与真实子代理头过滤会话", () => {
|
|
108
|
+
withDir((dir) => {
|
|
109
|
+
const sessions = join(dir, "sessions");
|
|
110
|
+
const manualDir = join(sessions, "ws", "manual");
|
|
111
|
+
const childDir = join(sessions, "ws", "child");
|
|
112
|
+
const captainDir = join(sessions, "ws", "captain");
|
|
113
|
+
mkdirSync(manualDir, { recursive: true });
|
|
114
|
+
mkdirSync(childDir, { recursive: true });
|
|
115
|
+
mkdirSync(captainDir, { recursive: true });
|
|
116
|
+
const privateCall = sessionLine({ turn: 1, calls: [skill("private-skill"), skill("private-next")] });
|
|
117
|
+
writeFileSync(join(manualDir, "session.v4.jsonl.zstd"), zlib.zstdCompressSync(Buffer.from(privateCall)));
|
|
118
|
+
const childHeader = JSON.stringify({
|
|
119
|
+
type: "session",
|
|
120
|
+
cwd: "C:\\proj",
|
|
121
|
+
parentSession: "captain",
|
|
122
|
+
origin: "subagent",
|
|
123
|
+
delegationDepth: 1
|
|
124
|
+
});
|
|
125
|
+
writeFileSync(join(childDir, "session.v4.jsonl.zstd"), zlib.zstdCompressSync(Buffer.from(`${childHeader}\n${privateCall}`)));
|
|
126
|
+
const publicCall = sessionLine({ turn: 1, calls: [skill("public-skill"), skill("public-next")] });
|
|
127
|
+
const captainHeader = JSON.stringify({ type: "session", cwd: "C:\\proj", origin: "main", delegationDepth: 0 });
|
|
128
|
+
writeFileSync(join(captainDir, "session.v4.jsonl.zstd"), zlib.zstdCompressSync(Buffer.from(`${captainHeader}\n${publicCall}`)));
|
|
129
|
+
// 只带 parentSession 的会话 = sessions.fork() 出来的主会话分支,不是子代理。
|
|
130
|
+
// 本机 641 个真实会话头里这样的有 3 个(各 74–136 条用户消息),把它们当子代理
|
|
131
|
+
// 过滤掉就是「fork 一下就失忆」。
|
|
132
|
+
const forkedDir = join(sessions, "ws", "forked");
|
|
133
|
+
mkdirSync(forkedDir, { recursive: true });
|
|
134
|
+
const forkedHeader = JSON.stringify({
|
|
135
|
+
type: "session",
|
|
136
|
+
version: 4,
|
|
137
|
+
cwd: "C:\\proj",
|
|
138
|
+
parentSession: "captain",
|
|
139
|
+
isSeeded: true,
|
|
140
|
+
delegationDepth: 0
|
|
141
|
+
});
|
|
142
|
+
const forkedCall = sessionLine({ turn: 1, calls: [skill("forked-skill"), skill("forked-next")] });
|
|
143
|
+
writeFileSync(join(forkedDir, "session.v4.jsonl.zstd"), zlib.zstdCompressSync(Buffer.from(`${forkedHeader}\n${forkedCall}`)));
|
|
144
|
+
|
|
145
|
+
const gate = createSessionGate({
|
|
146
|
+
sessionGateEnabled: true,
|
|
147
|
+
sessionGateSubagents: true,
|
|
148
|
+
sessionGateSessions: ["manual"]
|
|
149
|
+
});
|
|
150
|
+
const read = readSessionObservations({ sessionDir: sessions, sessionGate: gate });
|
|
151
|
+
assert.deepEqual([...new Set(read.observations.map((item) => item.sessionId))], ["captain", "forked"]);
|
|
152
|
+
assert.ok(!read.observations.some((item) => item.name.startsWith("private")));
|
|
153
|
+
assert.ok(read.observations.some((item) => item.name === "forked-skill"), "a forked captain session keeps its observations");
|
|
154
|
+
|
|
155
|
+
const disabled = createSessionGate({ sessionGateEnabled: false, sessionGateSessions: ["manual"] });
|
|
156
|
+
const unchanged = readSessionObservations({ sessionDir: sessions, sessionGate: disabled });
|
|
157
|
+
assert.ok(unchanged.observations.some((item) => item.sessionId === "manual"), "master-off keeps existing scan behavior");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// 形状锁:只有首行是 {"type":"session",...} 时才把整行当作会话头。
|
|
162
|
+
// 旧实现的兜底链末尾是 `?? row?.data?.header ?? row?.data?.config ?? row?.data ?? row`,
|
|
163
|
+
// 等于**任何**首行都能被当成 header。这不只是「会不会漏」的问题:一条非会话记录
|
|
164
|
+
// 里只要出现 `origin` / `delegationDepth`,就会被读成一条子代理头,把这个**正常
|
|
165
|
+
// 会话**的观测从扫描结果里整批划掉——M.O. 的提议因此静默少掉一块数据,而现场
|
|
166
|
+
// 回头看日志时那条会话明明在。本机 641 个真实会话首行的 `type` 全部是
|
|
167
|
+
// "session"(v3/v4 都是头字段直接躺在首行顶层),先认 `type` 不漏任何真实会话;
|
|
168
|
+
// 认不出来时返回 null、退回只按 id 判——**宁可少认一条头,也不要凭空造一个头**。
|
|
169
|
+
test("#539:非 session 首行不许把委派标记带进闸门", () => {
|
|
170
|
+
withDir((dir) => {
|
|
171
|
+
const sessions = join(dir, "sessions");
|
|
172
|
+
const captainDir = join(sessions, "ws", "captain");
|
|
173
|
+
mkdirSync(captainDir, { recursive: true });
|
|
174
|
+
const call = sessionLine({ turn: 1, calls: [skill("captain-skill")] });
|
|
175
|
+
// 首行是一条 permission/preset 记录,且 data 里带着委派标记——这一帧里
|
|
176
|
+
// 根本没有会话头,那两个字只是某条普通记录的字段。
|
|
177
|
+
const preset = JSON.stringify({ type: "permission/preset", seq: 0, time: 1, data: { origin: "subagent", delegationDepth: 1 } });
|
|
178
|
+
writeFileSync(join(captainDir, "session.v4.jsonl.zstd"), zlib.zstdCompressSync(Buffer.from(`${preset}\n${call}`)));
|
|
179
|
+
|
|
180
|
+
const automatic = createSessionGate({ sessionGateEnabled: true, sessionGateSubagents: true });
|
|
181
|
+
const read = readSessionObservations({ sessionDir: sessions, sessionGate: automatic });
|
|
182
|
+
assert.ok(
|
|
183
|
+
read.observations.some((item) => item.name === "captain-skill"),
|
|
184
|
+
"首行认不出头时不许凭它的字段判子代理——按 id 判、放行"
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const manual = createSessionGate({ sessionGateEnabled: true, sessionGateSessions: ["captain"] });
|
|
188
|
+
const filtered = readSessionObservations({ sessionDir: sessions, sessionGate: manual });
|
|
189
|
+
assert.equal(filtered.observations.length, 0, "手动名单仍然按目录名里的精确 id 生效");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
106
193
|
test("截断帧:半行被丢掉,不抛错、也不把半行当成一条记录", () => {
|
|
107
194
|
withDir((dir) => {
|
|
108
195
|
const sessions = join(dir, "sessions");
|
|
@@ -5,6 +5,7 @@ import { writeFileSync, rmSync } from "node:fs";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { createExecutionRecorder, projectRootOf, MAX_BUFFERED_EVENTS } from "../src/pipeline-runner.js";
|
|
7
7
|
import { validatePipeline } from "../src/pipeline.js";
|
|
8
|
+
import { createSessionGate } from "../src/session-gate.js";
|
|
8
9
|
|
|
9
10
|
// 这组测试守的是串联层:把「事件 → 观察 → 归属 → 窗口 → 落盘」串起来的那一层。
|
|
10
11
|
// 它最要紧的两条性质:
|
|
@@ -61,6 +62,31 @@ test("没开窗时一个字都不记:事件到了也不缓冲(这是整个
|
|
|
61
62
|
assert.equal(rec.stop({ session }).reason, "no-open-window");
|
|
62
63
|
});
|
|
63
64
|
|
|
65
|
+
test("#539:无痕会话不开始、缓冲或结算执行记录", () => {
|
|
66
|
+
const f = fakeCtx();
|
|
67
|
+
const config = { sessionGateEnabled: false, sessionGateSessions: ["s-private"] };
|
|
68
|
+
const gate = createSessionGate(config);
|
|
69
|
+
let writes = 0;
|
|
70
|
+
const rec = createExecutionRecorder({
|
|
71
|
+
ctx: f.ctx,
|
|
72
|
+
sessionGate: gate,
|
|
73
|
+
resolvePipeline: () => PIPE,
|
|
74
|
+
persistRun: () => { writes += 1; }
|
|
75
|
+
});
|
|
76
|
+
const session = { id: "s-private", header: { cwd: "C:\\proj" } };
|
|
77
|
+
|
|
78
|
+
assert.equal(rec.start({ session, pipelineId: "demo" }).ok, true);
|
|
79
|
+
for (const event of callAndResult("npm test")) f.emit(session, event);
|
|
80
|
+
assert.equal(rec.stop({ session }).state, "awaiting-confirmation");
|
|
81
|
+
|
|
82
|
+
config.sessionGateEnabled = true;
|
|
83
|
+
assert.equal(rec.status({ session }).incognito, true);
|
|
84
|
+
assert.equal(rec.confirm({ session, answers: { P6: "done" } }).reason, "session-incognito");
|
|
85
|
+
config.sessionGateEnabled = false;
|
|
86
|
+
assert.deepEqual(rec.status({ session }), { open: false }, "gating discards both buffered and pending session data");
|
|
87
|
+
assert.equal(writes, 0, "incognito observation never reaches persistent run storage");
|
|
88
|
+
});
|
|
89
|
+
|
|
64
90
|
test("开窗 → 走一遍 → 结账:产出并落盘一条账", () => {
|
|
65
91
|
const f = fakeCtx();
|
|
66
92
|
let written = null;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { STR } from "../src/lang.js";
|
|
4
|
+
import { createStore } from "../src/store.js";
|
|
5
|
+
import { createService } from "../src/service.js";
|
|
6
|
+
import { createDreamScheduler } from "../src/dream.js";
|
|
7
|
+
|
|
8
|
+
// 防谄媚的**提示词侧**回归锁。
|
|
9
|
+
//
|
|
10
|
+
// 背景:#380 的注入前判定(pre-inject-gate)只拦「已落库的意见/立场」,而落到库里
|
|
11
|
+
// 之前的两个写作环节——蒸馏(summary / codingSummary)与总览叙述(dreamSummary)
|
|
12
|
+
// ——此前对谄媚内容一字未提。这两个环节写出的内容**完全不过** writeAdmission
|
|
13
|
+
// (write-admission.js 只在带 sessionKey 的 memory_save 路径生效),所以提示词是
|
|
14
|
+
// 唯一能施加约束的地方,本轮把「立场纪律 / 叙述纪律」写进字面量(Issue #530)。
|
|
15
|
+
//
|
|
16
|
+
// 这些用例锁的是**形状**(提示词里有没有这条约束、位置对不对),锁不了**行为**
|
|
17
|
+
// (模型是否真的照做)——后者要 LLM 在环 + 预注册判据,属 E12 那类实验,不在单测里
|
|
18
|
+
// 宣称。PR 与 CHANGELOG 不得把本文件写成「已知降低谄媚率」。
|
|
19
|
+
|
|
20
|
+
const LANGS = ["zh", "en"];
|
|
21
|
+
|
|
22
|
+
// 五组判据在两种语言里的对应词。写成「中英成对」是刻意的:少一种语言就等于
|
|
23
|
+
// 换语言的用户拿到一份没有防线的提示词(同 lang.test.js 对 profileExtract 的锁法)。
|
|
24
|
+
const STANCE_PATTERNS = [
|
|
25
|
+
[/立场纪律|Stance discipline/, "立场纪律小节本身"],
|
|
26
|
+
[/不要记录对用户本人|Never record evaluations of the user/, "规则 a:不记对用户本人的性格/能力/情绪评价"],
|
|
27
|
+
[/可信度不同|differ in trustworthiness/, "规则 b:转录三种角色可信度不同"],
|
|
28
|
+
[/事实归属以用户陈述为准|the user statement wins on the facts/, "规则 c:事实归属不记成助手推断"],
|
|
29
|
+
[/如实并存|keep both sides/, "规则 c:两种说法并存,不写成谁对谁错"],
|
|
30
|
+
[/不是提高 importance 的理由|never a reason to raise it/, "规则 d:赞许不抬 importance"],
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
for (const key of ["summary", "codingSummary"]) {
|
|
34
|
+
test(`${key} 提示词带完整立场纪律(中英成对)`, () => {
|
|
35
|
+
for (const lang of LANGS) {
|
|
36
|
+
const text = STR.prompts[key][lang];
|
|
37
|
+
for (const [re, label] of STANCE_PATTERNS) {
|
|
38
|
+
assert.match(text, re, `${key}.${lang} 缺少${label}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test("立场纪律排在任何 JSON 输出形状之前,不会被后置的时间锚点覆盖", () => {
|
|
45
|
+
// summarize.js 在提示词**之后**再拼 STR.distillTimeAnchor(「据此换算 occurred_at」)。
|
|
46
|
+
// 规则若排在那句之后,就成了「先按时间换算、再谈记什么」——指令优先级反了。
|
|
47
|
+
// 同时它必须排在 schema 行之前:schema 是输出形状,纪律是内容判据,形状压不住判据。
|
|
48
|
+
for (const key of ["summary", "codingSummary"]) {
|
|
49
|
+
for (const lang of LANGS) {
|
|
50
|
+
const text = STR.prompts[key][lang];
|
|
51
|
+
const stance = text.search(/立场纪律|Stance discipline/);
|
|
52
|
+
assert.ok(stance > 0, `${key}.${lang}: 立场纪律必须存在于提示词字面量内部`);
|
|
53
|
+
assert.ok(stance < text.search(/occurred_at|occurred_at/), `${key}.${lang}: 立场纪律必须在 occurred_at 说明之前`);
|
|
54
|
+
assert.ok(stance < text.search(/只输出 JSON 数组|Output only a JSON array/), `${key}.${lang}: 立场纪律必须在输出形状之前`);
|
|
55
|
+
assert.ok(text.indexOf("立场纪律") < 0 || stance > text.indexOf("原子记忆原则") - 1,
|
|
56
|
+
`${key}.${lang}: 立场纪律要跟在原子记忆原则之后,别抢掉开头角色句`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("工具/报错结论的豁免条款必须在场(删掉它会误杀 pitfall 与 constraint)", () => {
|
|
62
|
+
// 规则 b 说「助手的结论是推断」,但编码类记忆(pitfall / rejected_solution /
|
|
63
|
+
// constraint)的内容本来就是工具输出与报错直接给出的技术结论。没有这半句豁免,
|
|
64
|
+
// 规则 b 会把整个编码回溯功能一并否掉——这是最容易被后人顺手删掉的高危句。
|
|
65
|
+
for (const lang of LANGS) {
|
|
66
|
+
const text = STR.prompts.summary[lang];
|
|
67
|
+
assert.match(text, /工具输出、报错、命令结果|tool output, errors or command results/,
|
|
68
|
+
`summary.${lang}: 工具结论豁免条款缺失,规则 b 会误杀 pitfall/constraint`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("dreamSummary 带叙述纪律,且散文契约不被破坏", () => {
|
|
73
|
+
for (const lang of LANGS) {
|
|
74
|
+
const text = STR.prompts.dreamSummary[lang];
|
|
75
|
+
assert.match(text, /叙述纪律|Narrative discipline/, `dreamSummary.${lang}: 缺叙述纪律`);
|
|
76
|
+
assert.match(text, /不要给用户添上|add no characterisation/, `dreamSummary.${lang}: 缺「不给用户添性格评价」`);
|
|
77
|
+
// 总览是**散文**契约:dream-fence 那条锁要求这里绝不能出现「JSON 数组」,
|
|
78
|
+
// 否则模型会按数组契约回 `[]`,而 summaryText.trim() 判非空通过,`[]` 就
|
|
79
|
+
// 被当成摘要存进库(dream.js:326-328 的注释记着这次评审实测)。
|
|
80
|
+
assert.doesNotMatch(text, /JSON 数组|JSON array/, `dreamSummary.${lang}: 散文契约里不得出现 JSON 数组`);
|
|
81
|
+
// 硬输出格式锁必须留在末尾,它是「只回叙述文本」的唯一保证。
|
|
82
|
+
assert.match(lang === "zh" ? text : text, /只输出叙述文本,不要其他内容。|Output only the narrative text, nothing else./,
|
|
83
|
+
`dreamSummary.${lang}: 末尾输出格式锁丢了`);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// 下面这条是**输出侧**的接线锁:提示词改了,落库判据不能跟着变。
|
|
88
|
+
// 空字符串仍必须判为「没有摘要」(degraded/noop),不能被新文案带成「存了个空叙述」。
|
|
89
|
+
test("总览落库判据不因提示词改动而放宽", async () => {
|
|
90
|
+
function mockCtx({ summaryText }) {
|
|
91
|
+
return {
|
|
92
|
+
logger: { warn: () => {} },
|
|
93
|
+
agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
|
|
94
|
+
llm: {
|
|
95
|
+
async *stream(options) {
|
|
96
|
+
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
97
|
+
if (userText.includes("| type=")) {
|
|
98
|
+
yield { type: "block-start", index: 0, blockType: "text" };
|
|
99
|
+
yield { type: "text-delta", index: 0, text: "[]" };
|
|
100
|
+
yield { type: "block-end", index: 0, block: { type: "text" } };
|
|
101
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
yield { type: "text-delta", index: 0, text: summaryText };
|
|
105
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const store = createStore(":memory:");
|
|
112
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
113
|
+
const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
|
|
114
|
+
service.saveWithDedupe({ type: "preference", title: "语言", content: "中文", importance: 3 });
|
|
115
|
+
|
|
116
|
+
const ok = await dream.runDream(mockCtx({ summaryText: "记忆库总览:用户偏好中文。" }), service, {});
|
|
117
|
+
assert.equal(ok.summary, true, "非空叙述照常落库");
|
|
118
|
+
const row = service.all().find((m) => m.type === "summary");
|
|
119
|
+
assert.ok(row, "总览行必须存在");
|
|
120
|
+
assert.equal(row.source, "dream");
|
|
121
|
+
assert.equal(row.importance, 5, "总览行的 importance 固定 5(最高注入档)");
|
|
122
|
+
|
|
123
|
+
const empty = await dream.runDream(mockCtx({ summaryText: "" }), service, {});
|
|
124
|
+
assert.equal(empty.summary, false, "空叙述不得落库");
|
|
125
|
+
const rows = service.all().filter((m) => m.type === "summary");
|
|
126
|
+
assert.equal(rows.length, 1, "旧总览存活,没有被空叙述覆盖");
|
|
127
|
+
store.close();
|
|
128
|
+
});
|