@modusensus/dsh-mneme 0.9.4 → 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 +57 -10
- package/bin/cli.mjs +215 -2
- package/docs/CONFIGURATION.md +25 -2
- package/docs/DAEMON.md +13 -2
- package/docs/DISTILL.md +150 -4
- package/docs/SHARE.md +552 -0
- package/docs/SLEEP.md +21 -7
- package/docs/STORAGE.md +2 -1
- package/lib/api-standalone.js +294 -8
- package/lib/api.js +217 -3
- package/lib/client.js +919 -39
- package/lib/config.js +33 -1
- package/lib/continuity.js +11 -3
- package/lib/document.js +15 -2
- package/lib/dream/decisions.js +176 -30
- package/lib/dream/sleep.js +530 -94
- package/lib/dream.js +174 -21
- package/lib/fence.js +24 -0
- package/lib/index.js +15 -6
- package/lib/inject.js +9 -4
- package/lib/lang.js +38 -16
- package/lib/memory-source.js +9 -0
- package/lib/organize.js +5 -1
- package/lib/pipeline-nudge-auto.js +3 -0
- package/lib/pipeline-nudge.js +41 -8
- package/lib/pipeline-runner.js +15 -0
- package/lib/pipeline.js +62 -1
- package/lib/pre-inject-gate.js +14 -0
- package/lib/profile-extract.js +4 -1
- package/lib/provenance-http.js +251 -0
- package/lib/serve.js +13 -0
- package/lib/service.js +97 -4
- package/lib/session-gate.js +91 -0
- package/lib/settings.js +13 -1
- package/lib/share-http.js +281 -0
- package/lib/share-import.js +234 -0
- package/lib/share.js +335 -0
- package/lib/store.js +797 -28
- package/lib/summarize.js +209 -23
- package/lib/tools.js +26 -6
- package/package.json +4 -2
- package/src/api-standalone.js +294 -8
- package/src/api.js +217 -3
- package/src/config.js +33 -1
- package/src/continuity.js +11 -3
- package/src/document.js +15 -2
- package/src/dream/decisions.js +176 -30
- package/src/dream/sleep.js +530 -94
- package/src/dream.js +174 -21
- package/src/fence.js +24 -0
- package/src/index.js +15 -6
- package/src/inject.js +9 -4
- package/src/lang.js +38 -16
- package/src/memory-source.js +9 -0
- package/src/organize.js +5 -1
- package/src/pipeline-nudge-auto.js +3 -0
- package/src/pipeline-nudge.js +41 -8
- package/src/pipeline-runner.js +15 -0
- package/src/pipeline.js +62 -1
- package/src/pre-inject-gate.js +14 -0
- package/src/profile-extract.js +4 -1
- package/src/provenance-http.js +251 -0
- package/src/serve.js +13 -0
- package/src/service.js +97 -4
- package/src/session-gate.js +91 -0
- package/src/settings.js +13 -1
- package/src/share-http.js +281 -0
- package/src/share-import.js +234 -0
- package/src/share.js +335 -0
- package/src/store.js +797 -28
- package/src/summarize.js +209 -23
- package/src/tools.js +26 -6
- package/test/api.test.js +523 -6
- package/test/audit.test.js +2 -2
- package/test/cli-receive.test.js +390 -0
- package/test/cli-share.test.js +437 -0
- package/test/client-share.test.js +225 -0
- package/test/client.test.js +354 -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/dedupe-candidate-window.test.js +180 -0
- package/test/dependency-overrides.test.js +78 -0
- package/test/differentiate-feedback.test.js +248 -0
- package/test/dream-candidate-hybrid.test.js +2 -2
- package/test/dream-decision-evidence.test.js +314 -0
- package/test/dream-fence.test.js +207 -0
- package/test/dream.test.js +87 -5
- package/test/helpers/dream-mock.js +6 -2
- package/test/inject-pools.test.js +2 -1
- package/test/llm-message-source.test.js +1 -1
- package/test/mint-source.test.js +84 -1
- package/test/pipeline-nudge-auto.test.js +26 -2
- package/test/pipeline-nudge.test.js +87 -0
- package/test/pipeline-r1r2.test.js +129 -0
- package/test/pipeline-runner.test.js +26 -0
- package/test/policy-epoch.test.js +2 -2
- package/test/prompt-stance.test.js +128 -0
- package/test/provenance-http.test.js +367 -0
- package/test/readme-claims.test.js +45 -0
- package/test/reasoning-effort.test.js +10 -10
- package/test/release-docs.test.js +7 -1
- package/test/scope-candidates.test.js +7 -4
- package/test/scope-state.test.js +130 -1
- package/test/security-doc.test.js +43 -4
- package/test/serve.test.js +62 -0
- package/test/session-gate.test.js +264 -0
- package/test/settings.test.js +4 -0
- package/test/share-foreign-rows.test.js +144 -0
- package/test/share-http.test.js +144 -0
- package/test/share-import.test.js +577 -0
- package/test/share-imported-guard.test.js +311 -0
- package/test/share-imported-identity.test.js +111 -0
- package/test/share-pack.test.js +511 -0
- package/test/share-receive-http.test.js +756 -0
- package/test/share-routes.test.js +337 -0
- package/test/share-store.test.js +133 -0
- package/test/share-validate.test.js +101 -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 +574 -5
- package/test/store.test.js +220 -0
- package/test/summarize-parse-diagnostics.test.js +75 -0
- package/test/summarize.test.js +187 -2
package/test/dream.test.js
CHANGED
|
@@ -850,7 +850,7 @@ test("runDream with dreamImplicitKeep=false rejects a partial mock output (missi
|
|
|
850
850
|
llm: {
|
|
851
851
|
async *stream(options) {
|
|
852
852
|
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
853
|
-
if (userText.
|
|
853
|
+
if (userText.includes("| type=")) {
|
|
854
854
|
const ids = [...userText.matchAll(/id=([^\s|]+)\s*\|\s*type=(\w+)\s*\|\s*importance=\d+/g)].map((m) => m[1]);
|
|
855
855
|
yield { type: "text-delta", index: 0, text: JSON.stringify(ids.slice(0, 1).map((id) => ({ action: "archive", ids: [id], reason: "stale" }))) };
|
|
856
856
|
} else {
|
|
@@ -1091,7 +1091,7 @@ test("issue#89: dream run with a skipped invalid decision lands the valid subset
|
|
|
1091
1091
|
llm: {
|
|
1092
1092
|
async *stream(options) {
|
|
1093
1093
|
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
1094
|
-
if (userText.
|
|
1094
|
+
if (userText.includes("| type=")) {
|
|
1095
1095
|
// 一条跨类型 merge(非法 → 跳过)+ 两条合法 archive(覆盖 2/4 快照,
|
|
1096
1096
|
// 高于 50% 显式覆盖率下限)
|
|
1097
1097
|
yield { type: "text-delta", index: 0, text: JSON.stringify([
|
|
@@ -1131,7 +1131,7 @@ test("issue#104: degraded run persists the skipped decision detail into dream_ru
|
|
|
1131
1131
|
llm: {
|
|
1132
1132
|
async *stream(options) {
|
|
1133
1133
|
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
1134
|
-
if (userText.
|
|
1134
|
+
if (userText.includes("| type=")) {
|
|
1135
1135
|
// 一条跨类型 merge(非法 → 跳过)+ 两条合法 archive(显式覆盖 2/4,过 50% 下限)
|
|
1136
1136
|
yield { type: "text-delta", index: 0, text: JSON.stringify([
|
|
1137
1137
|
{ action: "merge", ids: [pref.id, a.id], keepSource: pref.id, title: "跨类型", content: "不应合并", importance: 4 },
|
|
@@ -1169,7 +1169,7 @@ test("issue#89: dreamSkipInvalid:false restores the whole-batch strict rejection
|
|
|
1169
1169
|
llm: {
|
|
1170
1170
|
async *stream(options) {
|
|
1171
1171
|
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
1172
|
-
if (userText.
|
|
1172
|
+
if (userText.includes("| type=")) {
|
|
1173
1173
|
yield { type: "text-delta", index: 0, text: JSON.stringify([
|
|
1174
1174
|
{ action: "merge", ids: [pref.id, a.id], keepSource: pref.id, title: "跨类型", content: "不应合并", importance: 4 },
|
|
1175
1175
|
{ action: "archive", ids: [b.id], reason: "stale" }
|
|
@@ -1291,7 +1291,7 @@ test("issue#258: dreamSummaryMaxInputs caps summary inputs to the newest N (0 =
|
|
|
1291
1291
|
const origStream = ctx.llm.stream.bind(ctx.llm);
|
|
1292
1292
|
ctx.llm.stream = async function* (options) {
|
|
1293
1293
|
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
1294
|
-
if (!userText.
|
|
1294
|
+
if (!userText.includes("| type=")) seenUserText.push(userText);
|
|
1295
1295
|
yield* origStream(options);
|
|
1296
1296
|
};
|
|
1297
1297
|
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
@@ -1370,6 +1370,88 @@ test("validateDecisions mergeGuard: non-guarded types unaffected, guard off = le
|
|
|
1370
1370
|
);
|
|
1371
1371
|
});
|
|
1372
1372
|
|
|
1373
|
+
// Issue #521:merge 护栏与 archive 护栏对齐——真重复/真过时的长保留类型必须
|
|
1374
|
+
// 能合,否则提示词催着模型合、护栏一律拦,形成「每轮重提同一簇、满载 token 空
|
|
1375
|
+
// 转、merge 恒为 0」的自锁。锁三件事:①同类型 + 重复理由放行;②只有理由没有
|
|
1376
|
+
// 同类型不放行(逃生门不跟着放宽跨类型);③同类型但理由只是「主题相近」不放行
|
|
1377
|
+
// (相近 ≠ 重复,这条是故意的)。
|
|
1378
|
+
test("validateDecisions mergeGuard: genuine duplicate of a long-retention type passes (issue #521)", () => {
|
|
1379
|
+
const snap = new Map([
|
|
1380
|
+
["p1", { id: "p1", type: "preference", title: "喜欢深色主题", content: "偏好深色", importance: 3, archived: false, forgotten: false }],
|
|
1381
|
+
["p2", { id: "p2", type: "preference", title: "偏好深色主题", content: "偏好深色界面", importance: 3, archived: false, forgotten: false }]
|
|
1382
|
+
]);
|
|
1383
|
+
const decisions = [
|
|
1384
|
+
{ action: "merge", ids: ["p1", "p2"], keepSource: "p1", title: "喜欢深色主题", content: "偏好深色界面", importance: 3, reason: "两条重复" }
|
|
1385
|
+
];
|
|
1386
|
+
const { ok, skipped } = validateDecisions(decisions, snap, { skipInvalid: true, mergeGuard: true });
|
|
1387
|
+
assert.equal(ok, true, "duplicate-rationale merge of guarded type survives the guard");
|
|
1388
|
+
assert.equal(skipped.length, 0, "nothing skipped when the escape hatch opens");
|
|
1389
|
+
assert.equal(decisions.filter((d) => d.action === "merge").length, 1, "merge decision is applied, not dropped");
|
|
1390
|
+
});
|
|
1391
|
+
|
|
1392
|
+
test("validateDecisions mergeGuard: same type but only 'topically similar' rationale still blocked (issue #521)", () => {
|
|
1393
|
+
const snap = new Map([
|
|
1394
|
+
["p1", { id: "p1", type: "preference", title: "喜欢深色主题", content: "偏好深色", importance: 3, archived: false, forgotten: false }],
|
|
1395
|
+
["p2", { id: "p2", type: "preference", title: "喜欢等宽字体", content: "偏好等宽", importance: 3, archived: false, forgotten: false }]
|
|
1396
|
+
]);
|
|
1397
|
+
const decisions = [
|
|
1398
|
+
{ action: "merge", ids: ["p1", "p2"], keepSource: "p1", title: "界面偏好", content: "深色 + 等宽", importance: 3, reason: "主题相近" }
|
|
1399
|
+
];
|
|
1400
|
+
// 单条决策被跳过后批次整体不成立(覆盖率下限),所以配一条合法的非 guarded
|
|
1401
|
+
// merge 让批次存活,单独观察这条被拦的理由。
|
|
1402
|
+
decisions.push({ action: "merge", ids: ["h1", "h2"], keepSource: "h1", title: "旧事合并", content: "合并内容", importance: 3 });
|
|
1403
|
+
snap.set("h1", { id: "h1", type: "history", title: "旧事", content: "内容", importance: 3, archived: false, forgotten: false });
|
|
1404
|
+
snap.set("h2", { id: "h2", type: "history", title: "旧事2", content: "内容2", importance: 3, archived: false, forgotten: false });
|
|
1405
|
+
const { ok, skipped } = validateDecisions(decisions, snap, { skipInvalid: true, mergeGuard: true });
|
|
1406
|
+
assert.equal(ok, true, "skipInvalid keeps the batch alive");
|
|
1407
|
+
assert.equal(skipped.length, 1, "topically-similar rationale is not a duplicate rationale");
|
|
1408
|
+
assert.match(skipped[0].error, /dreamMergeGuard/);
|
|
1409
|
+
assert.match(skipped[0].error, /重复\/过时/, "skip reason tells the model what would unblock it");
|
|
1410
|
+
});
|
|
1411
|
+
|
|
1412
|
+
test("validateDecisions mergeGuard: duplicate rationale does not unlock cross-type merge (issue #521)", () => {
|
|
1413
|
+
const snap = new Map([
|
|
1414
|
+
["c1", { id: "c1", type: "constraint", title: "预算", content: "$12,400", importance: 4, archived: false, forgotten: false }],
|
|
1415
|
+
["p1", { id: "p1", type: "preference", title: "预算偏好", content: "$12,400", importance: 3, archived: false, forgotten: false }]
|
|
1416
|
+
]);
|
|
1417
|
+
const decisions = [
|
|
1418
|
+
{ action: "merge", ids: ["c1", "p1"], keepSource: "c1", title: "预算", content: "$12,400", importance: 4, reason: "重复" }
|
|
1419
|
+
];
|
|
1420
|
+
// 显式放开 allowCrossTypeMerge:跨类型本身不再报错,逃生门仍必须拦住它——
|
|
1421
|
+
// 否则「重复」理由会把约束和偏好揉成一条,正是 #339 要防的失真。
|
|
1422
|
+
const { skipped } = validateDecisions(decisions, snap, {
|
|
1423
|
+
skipInvalid: true,
|
|
1424
|
+
mergeGuard: true,
|
|
1425
|
+
allowCrossTypeMerge: true
|
|
1426
|
+
});
|
|
1427
|
+
assert.equal(skipped.length, 1, "escape hatch requires all ids share one type");
|
|
1428
|
+
assert.match(skipped[0].error, /dreamMergeGuard/);
|
|
1429
|
+
});
|
|
1430
|
+
|
|
1431
|
+
// #522 评审:逃生门的词表只认关键词、不认否定。「并不重复,只是主题相近」与
|
|
1432
|
+
// "not a duplicate" 都含「重复」/"duplicate" 子串,放它们过闸等于模型用一句否认
|
|
1433
|
+
// 换来合并许可——恰好把 #339 要防的「两条独立表述被揉成一段糊话」放回来。
|
|
1434
|
+
test("validateDecisions mergeGuard: a negated duplicate rationale does not open the escape hatch (#522)", () => {
|
|
1435
|
+
const makeSnap = () => new Map([
|
|
1436
|
+
["p1", { id: "p1", type: "preference", title: "喜欢深色主题", content: "偏好深色", importance: 3, archived: false, forgotten: false }],
|
|
1437
|
+
["p2", { id: "p2", type: "preference", title: "喜欢等宽字体", content: "偏好等宽", importance: 3, archived: false, forgotten: false }],
|
|
1438
|
+
["h1", { id: "h1", type: "history", title: "旧事", content: "内容", importance: 3, archived: false, forgotten: false }],
|
|
1439
|
+
["h2", { id: "h2", type: "history", title: "旧事2", content: "内容2", importance: 3, archived: false, forgotten: false }]
|
|
1440
|
+
]);
|
|
1441
|
+
// 中英各一条否定表述:都要被拦。配一条合法的非 guarded merge 让批次存活
|
|
1442
|
+
// (单条决策被跳过后覆盖率下限会让整批不成立),单独观察被拦的理由。
|
|
1443
|
+
// 第二轮评审:词表里「过时 / stale / outdated」也是钥匙,同样要挡。
|
|
1444
|
+
for (const reason of ["并不重复,只是主题相近", "not a duplicate, just similar", "并不过时,只是主题相近", "not outdated, merely similar", "not stale, just similar"]) {
|
|
1445
|
+
const decisions = [
|
|
1446
|
+
{ action: "merge", ids: ["p1", "p2"], keepSource: "p1", title: "界面偏好", content: "深色 + 等宽", importance: 3, reason },
|
|
1447
|
+
{ action: "merge", ids: ["h1", "h2"], keepSource: "h1", title: "旧事合并", content: "合并内容", importance: 3 }
|
|
1448
|
+
];
|
|
1449
|
+
const { ok, skipped } = validateDecisions(decisions, makeSnap(), { skipInvalid: true, mergeGuard: true });
|
|
1450
|
+
assert.equal(ok, true, "skipInvalid keeps the batch alive");
|
|
1451
|
+
assert.equal(skipped.length, 1, `negated rationale must not open the escape hatch: ${reason}`);
|
|
1452
|
+
assert.match(skipped[0].error, /dreamMergeGuard/);
|
|
1453
|
+
}
|
|
1454
|
+
});
|
|
1373
1455
|
test("validateDecisions archive guard: long-retention type without duplicate/outdated rationale is skipped (prior gap)", () => {
|
|
1374
1456
|
const snap = new Map([
|
|
1375
1457
|
["c", { id: "c", type: "constraint", title: "预算", content: "$12,400", importance: 4, archived: false, forgotten: false }],
|
|
@@ -70,8 +70,12 @@ export function mockCtx({ onConsolidation, summaryText = "记忆库总览:用
|
|
|
70
70
|
llm: {
|
|
71
71
|
async *stream(options) {
|
|
72
72
|
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
// 认「这是巩固那一次调用」的判据:以前看 userText 是否以 `id=` 开头,但
|
|
74
|
+
// #493 追加的围栏让 user 段以角色声明开头了。改判清单**形状**而不是首行——
|
|
75
|
+
// `| type=` 只有巩固清单有(叙述条的 listing 形如 `- id=… | title=…`,没有
|
|
76
|
+
// type=),所以两条调用路径不会串台。
|
|
77
|
+
if (userText.includes("| type=")) {
|
|
78
|
+
yield { type: "text-delta", index: 0, text: onConsolidation ? onConsolidation(userText, options.purpose) : "[]" };
|
|
75
79
|
} else {
|
|
76
80
|
yield { type: "text-delta", index: 0, text: summaryText };
|
|
77
81
|
}
|
|
@@ -135,7 +135,8 @@ test("#249: capability guide registers exactly once, and stays off when the key
|
|
|
135
135
|
assert.equal(on.sections.length, 1, "exactly one guide section");
|
|
136
136
|
assert.equal(on.sections[0].name, "memory-guide");
|
|
137
137
|
assert.equal(on.sections[0].order, 150, "插件指引段的既有 order 惯例");
|
|
138
|
-
assert.equal(on.sections[0].text,
|
|
138
|
+
assert.equal(typeof on.sections[0].text, "function", "guide text resolves per prompt context so session gating can apply");
|
|
139
|
+
assert.equal(on.sections[0].text({}), MEMORY_GUIDE_SECTION, "normal sessions receive the same guide text");
|
|
139
140
|
// 不可逆工具(memory_delete)的克制指引必须在场:这是全 guide 里唯一有数据
|
|
140
141
|
// 损失后果的一句,被误删掉的回归要能立刻转红。
|
|
141
142
|
assert.ok(MEMORY_GUIDE_SECTION.includes("memory_delete"), "guide keeps the irreversible-tool warning");
|
|
@@ -62,7 +62,7 @@ test("dream: consolidation and summary messages carry source", async () => {
|
|
|
62
62
|
calls.push(options);
|
|
63
63
|
assertMessageSources(options);
|
|
64
64
|
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
65
|
-
yield* reply(userText.
|
|
65
|
+
yield* reply(userText.includes("| type=") ? "[]" : "记忆库总览:已巩固。");
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
};
|
package/test/mint-source.test.js
CHANGED
|
@@ -127,7 +127,7 @@ function sleepCtx(decisionsJson) {
|
|
|
127
127
|
llm: {
|
|
128
128
|
async *stream(options) {
|
|
129
129
|
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
130
|
-
yield { type: "text-delta", index: 0, text: userText.
|
|
130
|
+
yield { type: "text-delta", index: 0, text: userText.includes("| type=") ? decisionsJson : "[]" };
|
|
131
131
|
yield { type: "finish", reason: { kind: "stop" } };
|
|
132
132
|
}
|
|
133
133
|
}
|
|
@@ -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");
|
|
@@ -147,3 +147,132 @@ test("坏输入不抛", () => {
|
|
|
147
147
|
assert.doesNotThrow(() => suggestRetirements(p, { runs: [{}] }));
|
|
148
148
|
assert.doesNotThrow(() => suggestRetirements(p, { runs: [{ at: null, steps: null }] }));
|
|
149
149
|
});
|
|
150
|
+
|
|
151
|
+
// #461:violationSeen 跨整本账本共享,而键里的 `run.at ?? ""` 在 at 缺失时塌缩成
|
|
152
|
+
// 空串——于是**所有**无时间戳的 run,其同 (step, need) 违规会被并成一条。去重本意是
|
|
153
|
+
// 「同一轮里同一条只报一次」,不是「所有轮共享一条」。内容不同的两条 run 该产出两条。
|
|
154
|
+
test("#461:两条内容不同、都无 run.at 的 run,同类违规不被并成一条", () => {
|
|
155
|
+
const p = makePipeline([
|
|
156
|
+
step("A", { tool: "npm test" }),
|
|
157
|
+
step("B", { tool: "git push", needs: ["A"] })
|
|
158
|
+
]);
|
|
159
|
+
// 都只做了 B、都没带 at,但走的不是同一条路:by 不同 → 内容指纹不同 → 各报一条
|
|
160
|
+
const runs = [
|
|
161
|
+
{ at: null, by: "human", triggeredBy: "demo", steps: [{ id: "B", at: "x" }] },
|
|
162
|
+
{ at: undefined, by: "agent", triggeredBy: "nightly", steps: [{ id: "B", at: "x" }] }
|
|
163
|
+
];
|
|
164
|
+
const v = suggestRetirements(p, { runs }).filter((s) => s.kind === "violation-precedence");
|
|
165
|
+
assert.equal(v.length, 2, "两条独立的 run 各报一条,不被键塌缩吞掉");
|
|
166
|
+
assert.equal(v[0].stepId, "B");
|
|
167
|
+
assert.equal(v[1].stepId, "B");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// #507 评审:同一个 run 在账本里出现两次,同一条违规仍只报一次。
|
|
171
|
+
// 位置索引做不到这件事——它只在「本次遍历的数组顺序」里唯一,同一条 run 出现在
|
|
172
|
+
// 下标 0 和 1 时会拿到两个键。所以 at 缺失时兜底用内容指纹,不用索引。
|
|
173
|
+
test("#507:同一条 run 出现两次,同类违规仍只报一次", () => {
|
|
174
|
+
const p = makePipeline([
|
|
175
|
+
step("A", { tool: "npm test" }),
|
|
176
|
+
step("B", { tool: "git push", needs: ["A"] })
|
|
177
|
+
]);
|
|
178
|
+
const same = { at: null, by: "human", triggeredBy: "demo", steps: [{ id: "B", at: "x" }] };
|
|
179
|
+
const v = suggestRetirements(p, { runs: [same, same] }).filter((s) => s.kind === "violation-precedence");
|
|
180
|
+
assert.equal(v.length, 1, "同一条 run 的两份记录是同一个观测,不是两次违规");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// #507 评审(CodeRabbit):两类键必须不相撞。第 0 条缺 at,而另一条的 at 字面量
|
|
184
|
+
// 正好是 "#0"——用位置索引兜底时两者生成同一个键,后一条违规被静默吞掉。
|
|
185
|
+
test("#507:at 字面量与兜底键不相撞(第 0 条缺 at vs 另一条 at 是 \"#0\")", () => {
|
|
186
|
+
const p = makePipeline([
|
|
187
|
+
step("A", { tool: "npm test" }),
|
|
188
|
+
step("B", { tool: "git push", needs: ["A"] })
|
|
189
|
+
]);
|
|
190
|
+
const runs = [
|
|
191
|
+
{ at: null, by: "human", triggeredBy: "demo", steps: [{ id: "B", at: "x" }] },
|
|
192
|
+
{ at: "#0", by: "human", triggeredBy: "demo", steps: [{ id: "B", at: "x" }] }
|
|
193
|
+
];
|
|
194
|
+
const v = suggestRetirements(p, { runs }).filter((s) => s.kind === "violation-precedence");
|
|
195
|
+
assert.equal(v.length, 2, "字面量 #0 与兜底键不是同一个东西,两条都要报");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// at 存在时行为不变:同 at 的重复违规仍然只报一条(原有去重语义不能被修坏)。
|
|
199
|
+
test("#461:at 存在时同轮重复违规仍只报一条(回归保护)", () => {
|
|
200
|
+
const p = makePipeline([
|
|
201
|
+
step("A", { tool: "npm test" }),
|
|
202
|
+
step("B", { tool: "git push", needs: ["A"] })
|
|
203
|
+
]);
|
|
204
|
+
const runs = [
|
|
205
|
+
RUN("2026-10-17T00:00:00Z", [{ id: "B", at: "x" }]),
|
|
206
|
+
RUN("2026-10-17T00:00:00Z", [{ id: "B", at: "x" }])
|
|
207
|
+
];
|
|
208
|
+
const v = suggestRetirements(p, { runs }).filter((s) => s.kind === "violation-precedence");
|
|
209
|
+
assert.equal(v.length, 1, "同一 at 的同一违规仍然只报一次");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// #507(CodeRabbit):匿名指纹必须收 step 的 at。两条都缺 run.at、只在 steps[0].at 上
|
|
213
|
+
// 不同的 run 是两次不同的观测;漏了 at 会让它们共键,后一条的 violation-precedence 被
|
|
214
|
+
// 去重吞掉。修复前(只取 step.id)报 1 条,修复后(收 at)报 2 条——这是本条的 red→green。
|
|
215
|
+
test("#507:匿名指纹收 step.at——只差 steps[0].at 的两条 at-less run 各报一条", () => {
|
|
216
|
+
const p = makePipeline([
|
|
217
|
+
step("A", { tool: "npm test" }),
|
|
218
|
+
step("B", { tool: "git push", needs: ["A"] })
|
|
219
|
+
]);
|
|
220
|
+
const runs = [
|
|
221
|
+
{ at: null, by: "human", triggeredBy: "demo", steps: [{ id: "B", at: "2026-10-17T00:00:00Z" }] },
|
|
222
|
+
{ at: undefined, by: "human", triggeredBy: "demo", steps: [{ id: "B", at: "2026-10-18T00:00:00Z" }] }
|
|
223
|
+
];
|
|
224
|
+
const v = suggestRetirements(p, { runs }).filter((s) => s.kind === "violation-precedence");
|
|
225
|
+
assert.equal(v.length, 2, "只差步骤时间的两条 at-less run 是两次观测,不能被并成一条");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// #507:匿名指纹**不能**把整个 step 对象序列化进去——note 是自由文本,进去会让「只差
|
|
229
|
+
// 备注的同一条观测」被当成两次。今天没有任何测试锁这条;不加,未来重构把 note 加回来
|
|
230
|
+
// 还会全绿。这是前瞻守卫:当前修复前后都是 1,但把 note 塞进指纹就会转红(见复盘)。
|
|
231
|
+
test("#507:匿名指纹排除 note——只差 steps[0].note 的两条 at-less run 仍是同一条", () => {
|
|
232
|
+
const p = makePipeline([
|
|
233
|
+
step("A", { tool: "npm test" }),
|
|
234
|
+
step("B", { tool: "git push", needs: ["A"] })
|
|
235
|
+
]);
|
|
236
|
+
const runs = [
|
|
237
|
+
{ at: null, by: "human", triggeredBy: "demo", steps: [{ id: "B", at: "x", note: "第一次跑,网络慢" }] },
|
|
238
|
+
{ at: undefined, by: "human", triggeredBy: "demo", steps: [{ id: "B", at: "x", note: "重跑,还是慢" }] }
|
|
239
|
+
];
|
|
240
|
+
const v = suggestRetirements(p, { runs }).filter((s) => s.kind === "violation-precedence");
|
|
241
|
+
assert.equal(v.length, 1, "note 是自由文本不是身份,只差备注应仍算同一条观测");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// #507:锁定旗标语义。指纹覆盖归一化 step 的全部结构化字段(含 skipped/detoured/refuted),
|
|
245
|
+
// 而这三个布尔对去重正确性是**惰性**的(violation 判定只读 doneIds 与 id 是否存在,
|
|
246
|
+
// refuted 根本不被读)。代价如实记在这里:两条只差惰性旗标的 at-less run 会各报一条
|
|
247
|
+
// **逐字相同**的提议。锁成 2 + deepEqual,防止将来无声漂移;anon 路径在生产不可达
|
|
248
|
+
// (readStoredRun 强制 run.at),故此代价不落到真实账本上。
|
|
249
|
+
test("#507:只差 refuted 的两条 at-less run 给出两条逐字相同的提议(锁旗标语义)", () => {
|
|
250
|
+
const p = makePipeline([
|
|
251
|
+
step("A", { tool: "npm test" }),
|
|
252
|
+
step("B", { tool: "git push", needs: ["A"] })
|
|
253
|
+
]);
|
|
254
|
+
const runs = [
|
|
255
|
+
{ at: null, by: "human", triggeredBy: "demo", steps: [{ id: "B", at: "x", refuted: false }] },
|
|
256
|
+
{ at: null, by: "human", triggeredBy: "demo", steps: [{ id: "B", at: "x", refuted: true }] }
|
|
257
|
+
];
|
|
258
|
+
const v = suggestRetirements(p, { runs }).filter((s) => s.kind === "violation-precedence");
|
|
259
|
+
assert.equal(v.length, 2, "refuted 在指纹里:两条只差 refuted 的 run 拿到两个键");
|
|
260
|
+
assert.deepEqual(v[0], v[1], "两条提议逐字相同——这正是把惰性旗标收进指纹的代价");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// #507:锁编码必须是**无歧义**序列化(JSON.stringify),不能退回手写 `${id}@${at}`。
|
|
264
|
+
// run 侧 step id 只过 isSafeSegment(src/pipeline-runs.js:83),`@` `,` `:` 都是合法 id:
|
|
265
|
+
// 手写拼接会把 [{A,t1},{B,t1},{C,t1}] 与 [{"A@t1,B",t1},{C,t1}] 撞成同一个键,run2 的
|
|
266
|
+
// (C,P) 违规被静默吞掉(只报 3 条)。当前实现与更早的 id-only 实现都报 4;退回手写拼接会红。
|
|
267
|
+
test("#507:step id 含 @/, 不撞键(锁 JSON.stringify 编码,手写拼接会漏报)", () => {
|
|
268
|
+
const p = makePipeline([
|
|
269
|
+
step("P"),
|
|
270
|
+
step("A", { needs: ["P"] }), step("B", { needs: ["P"] }), step("C", { needs: ["P"] })
|
|
271
|
+
]);
|
|
272
|
+
const runs = [
|
|
273
|
+
{ at: null, by: "human", triggeredBy: "demo", steps: [{ id: "A", at: "t1" }, { id: "B", at: "t1" }, { id: "C", at: "t1" }] },
|
|
274
|
+
{ at: null, by: "human", triggeredBy: "demo", steps: [{ id: "A@t1,B", at: "t1" }, { id: "C", at: "t1" }] }
|
|
275
|
+
];
|
|
276
|
+
const v = suggestRetirements(p, { runs }).filter((s) => s.kind === "violation-precedence");
|
|
277
|
+
assert.equal(v.length, 4, "两条 run 内容不同:run1 断 A/B/C 三条边、run2 断 C 一条,共 4 条");
|
|
278
|
+
});
|
|
@@ -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;
|
|
@@ -45,9 +45,9 @@ function mockCtx({ onConsolidation, summaryText = "记忆库总览:用户偏
|
|
|
45
45
|
llm: {
|
|
46
46
|
async *stream(options) {
|
|
47
47
|
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
48
|
-
if (userText.
|
|
48
|
+
if (userText.includes("| type=")) {
|
|
49
49
|
yield { type: "block-start", index: 0, blockType: "text" };
|
|
50
|
-
yield { type: "text-delta", index: 0, text: onConsolidation ? onConsolidation(userText) : "[]" };
|
|
50
|
+
yield { type: "text-delta", index: 0, text: onConsolidation ? onConsolidation(userText, options.purpose) : "[]" };
|
|
51
51
|
yield { type: "block-end", index: 0, block: { type: "text" } };
|
|
52
52
|
yield { type: "finish", reason: { kind: "stop" } };
|
|
53
53
|
return;
|