@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
|
@@ -53,7 +53,7 @@ function llmCtx(captured = [], selection = { provider: "mock", model: "sleep-mod
|
|
|
53
53
|
async *stream(options) {
|
|
54
54
|
captured.push(options);
|
|
55
55
|
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
56
|
-
yield { type: "text-delta", index: 0, text:
|
|
56
|
+
yield { type: "text-delta", index: 0, text: options.purpose === "sleep-conflict" ? "[]" : "[]" };
|
|
57
57
|
yield { type: "finish", reason: { kind: "stop" } };
|
|
58
58
|
}
|
|
59
59
|
}
|
|
@@ -210,7 +210,7 @@ test("non-freeze with an LLM: cross-scope pair bypasses adjudication; same-scope
|
|
|
210
210
|
ctx.llm.stream = async function* (options) {
|
|
211
211
|
captured.push(options);
|
|
212
212
|
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
213
|
-
const isConflictPass =
|
|
213
|
+
const isConflictPass = options.purpose === "sleep-conflict";
|
|
214
214
|
const text = isConflictPass
|
|
215
215
|
? JSON.stringify([{ action: "conflict", winner: yNew.id, loser: yOld.id, reason: "同归属重复" }])
|
|
216
216
|
: "[]";
|
|
@@ -221,9 +221,12 @@ test("non-freeze with an LLM: cross-scope pair bypasses adjudication; same-scope
|
|
|
221
221
|
const result = await runSleep(ctx, service, baseConfig(), ctx.logger, { embedder, vectorIndex }, null);
|
|
222
222
|
assert.equal(result.status, "ok");
|
|
223
223
|
|
|
224
|
+
// 认「哪一次是冲突裁决」改按 purpose,不再看 user 段首行:#493 追加的围栏让 user 段以
|
|
225
|
+
// 角色声明开头,而败者行的溯源注释里也会出现「候选冲突」这个词——用 includes 会把模式
|
|
226
|
+
// 阶段误判成冲突阶段。purpose 与 prompt 形态彻底解耦。
|
|
224
227
|
const conflictPassTexts = captured
|
|
225
|
-
.
|
|
226
|
-
.
|
|
228
|
+
.filter((o) => o.purpose === "sleep-conflict")
|
|
229
|
+
.map((o) => o.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "");
|
|
227
230
|
assert.ok(conflictPassTexts.length >= 1, "conflict pass ran for the same-scope pair");
|
|
228
231
|
for (const t of conflictPassTexts) {
|
|
229
232
|
assert.ok(!t.includes(xNew.id) && !t.includes(xOld.id), "cross-scope pair must never enter the LLM prompt");
|
package/test/scope-state.test.js
CHANGED
|
@@ -421,6 +421,135 @@ test("睡眠侧:被按下作用域的行不进 pattern 扫描池,也就不
|
|
|
421
421
|
store.close();
|
|
422
422
|
});
|
|
423
423
|
|
|
424
|
+
// --- issue #515:闸门把可扫行挡在 LIMIT 窗口之外 ---------------------------
|
|
425
|
+
//
|
|
426
|
+
// 上面那条用例锁的是「政策轴排在截断之前」,但它的库只有 4 行,根本碰不到
|
|
427
|
+
// store.list 的 `LIMIT 200`,所以它测不到 #515 真正的症状:被按下的行**多到占满窗口**
|
|
428
|
+
// 时,过滤前它们吃掉全部名额、过滤后自家行一条都不剩,相位回 `no memories to scan`
|
|
429
|
+
// ——读起来像「库里没东西可扫」,实际是「闸门把可扫的行挡在了窗口里」。
|
|
430
|
+
// 修复方向是不动判定、只放宽取数:窗口过滤后不够本次要扫的名额时,不带窗口重取一次。
|
|
431
|
+
//
|
|
432
|
+
// 造「自家行在窗口之外」用的是 importance 而不是 updated_at:默认档位是
|
|
433
|
+
// `importance DESC, updated_at DESC, id`,而 store.save 的时间戳由实现内部取
|
|
434
|
+
// nowIso()(调用方传不进去),同毫秒落库的行会退到 id 平局键——靠时间戳排序会依赖
|
|
435
|
+
// 插入速度,快机器上自家行可能反而排进窗口,用例就成了偶发绿。importance 差(5 vs 1)
|
|
436
|
+
// 让被按下的那批恒占窗口前排,与毫秒无关。
|
|
437
|
+
|
|
438
|
+
/** 填满 200 窗口的按下行 + 两条落在窗口之外的自家行。 */
|
|
439
|
+
function seedStarvedPatternPool(store, service) {
|
|
440
|
+
const own = [];
|
|
441
|
+
for (const t of ["own-1", "own-2"]) {
|
|
442
|
+
own.push(store.save({ type: "project", title: t, content: `${t} 正文`, importance: 1 }));
|
|
443
|
+
}
|
|
444
|
+
for (let i = 0; i < 200; i++) {
|
|
445
|
+
store.save({
|
|
446
|
+
type: "project", title: `paused-${i}`, content: `paused-${i} 正文`, importance: 5,
|
|
447
|
+
workspace_scope: "proj-a", workspace_scope_source: "explicit"
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
451
|
+
return own;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** 记录 service.list 的入参(只包 list,其余方法原样透出),用于「付了几次查询」。 */
|
|
455
|
+
function countingList(service, calls) {
|
|
456
|
+
return new Proxy(service, {
|
|
457
|
+
get(target, prop) {
|
|
458
|
+
if (prop === "list") {
|
|
459
|
+
return (query) => {
|
|
460
|
+
calls.push(query);
|
|
461
|
+
return target.list(query);
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
return Reflect.get(target, prop, target);
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
test("#515:被按下的行占满 LIMIT 200 窗口时,模式扫描不报「没东西可扫」", async () => {
|
|
470
|
+
const { store, service } = setup(sleepConfig());
|
|
471
|
+
seedStarvedPatternPool(store, service);
|
|
472
|
+
|
|
473
|
+
const seen = [];
|
|
474
|
+
const ctx = {
|
|
475
|
+
...sleepCtx(),
|
|
476
|
+
llm: {
|
|
477
|
+
async *stream(options) {
|
|
478
|
+
const text = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
479
|
+
// 认清单**形状**(行里带 `| importance=`),不认首行——#501/#493 那批围栏让 user 段以角色声明开头了
|
|
480
|
+
if (text.includes("| importance=")) seen.push(text);
|
|
481
|
+
yield { type: "text-delta", index: 0, text: "[]" };
|
|
482
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
const result = await runSleep(ctx, service, sleepConfig(), ctx.logger, null, null);
|
|
487
|
+
const patterns = result.phases.patterns;
|
|
488
|
+
|
|
489
|
+
assert.notEqual(patterns?.reason, "no memories to scan",
|
|
490
|
+
`窗口被按下行占满时不该静默跳过,实得 ${JSON.stringify(patterns)}`);
|
|
491
|
+
// 反向断言:这条才是「真的扫了」的证据——只断上面那句的话,一个「相位整个没跑」
|
|
492
|
+
// 的实现(连 reason 都不产出)也能过。
|
|
493
|
+
assert.equal(seen.length, 1, "模式扫描必须真发起了这次 LLM 调用");
|
|
494
|
+
assert.ok(seen[0].includes("own-1") && seen[0].includes("own-2"), "窗口外的自家行要被重取回来");
|
|
495
|
+
// 重取只放宽窗口,不放宽判定:被按下的行一条都不许混进提示。
|
|
496
|
+
assert.ok(!seen[0].includes("paused-"), "被按下的行不该因为重取而混进扫描池");
|
|
497
|
+
store.close();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test("#515:重取按需——闸门没按下且窗口够用时,仍只付一次有界查询", async () => {
|
|
501
|
+
const { store, service } = setup(sleepConfig());
|
|
502
|
+
// 120 条自家行(无闸门):过滤后的池子 ≥ sleepPatternMinMemories(100),窗口没被吃掉,
|
|
503
|
+
// 第二趟不该发生。这一条锁的是「常态仍只付一次有界查询」那半句承诺。
|
|
504
|
+
for (let i = 0; i < 120; i++) {
|
|
505
|
+
store.save({ type: "project", title: `m-${i}`, content: `m-${i} 正文`, importance: 3 });
|
|
506
|
+
}
|
|
507
|
+
const calls = [];
|
|
508
|
+
const ctx = sleepCtx();
|
|
509
|
+
await runSleep(ctx, countingList(service, calls), sleepConfig(), ctx.logger, null, null);
|
|
510
|
+
|
|
511
|
+
assert.equal(calls.length, 1, `常态只该取一次,实得 ${calls.length} 次`);
|
|
512
|
+
assert.equal(calls[0].limit, 200, "常态走的是有界窗口");
|
|
513
|
+
store.close();
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("#515:窗口跟着配额走——把 sleepPatternMinMemories 配到 200 之上时,常态仍不触发无界重取", async () => {
|
|
517
|
+
// 这条锁的是我自己差点留下的坑:合法上限是 1000(src/config.js:612),若窗口仍是死的
|
|
518
|
+
// 200,凡把这个值配到 200 以上的人**每一轮**都会白付一次全表重取,跟闸门按没按下无关。
|
|
519
|
+
const config = sleepConfig({ sleepPatternMinMemories: 300 });
|
|
520
|
+
const { store, service } = setup(config);
|
|
521
|
+
for (let i = 0; i < 400; i++) {
|
|
522
|
+
store.save({ type: "project", title: `m-${i}`, content: `m-${i} 正文`, importance: 3 });
|
|
523
|
+
}
|
|
524
|
+
const calls = [];
|
|
525
|
+
const ctx = sleepCtx();
|
|
526
|
+
await runSleep(ctx, countingList(service, calls), config, ctx.logger, null, null);
|
|
527
|
+
|
|
528
|
+
assert.equal(calls.length, 1, `窗口够用时不该重取,实得 ${calls.length} 次`);
|
|
529
|
+
assert.equal(calls[0].limit, 300, "窗口至少等于本次要扫的名额");
|
|
530
|
+
store.close();
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test("#515:窗口饿死时重取一次,且只放宽 limit——三条过滤轴一条不许动", async () => {
|
|
534
|
+
const { store, service } = setup(sleepConfig());
|
|
535
|
+
seedStarvedPatternPool(store, service);
|
|
536
|
+
|
|
537
|
+
const calls = [];
|
|
538
|
+
const ctx = sleepCtx();
|
|
539
|
+
await runSleep(ctx, countingList(service, calls), sleepConfig(), ctx.logger, null, null);
|
|
540
|
+
|
|
541
|
+
assert.equal(calls.length, 2, `饿死时补一趟无界重取,实得 ${calls.length} 次`);
|
|
542
|
+
assert.equal(calls[0].limit, 200);
|
|
543
|
+
assert.equal(calls[1].limit, null, "重取是不带窗口(store.list 的 limit == null 即无界档)");
|
|
544
|
+
// 除 limit 之外的口径必须逐字段相同:重取放宽的是窗口,不是判定。
|
|
545
|
+
assert.deepEqual(
|
|
546
|
+
{ ...calls[0], limit: null },
|
|
547
|
+
calls[1],
|
|
548
|
+
"第二趟只能比第一趟少一个窗口,excludeTypes/excludeImported/includeForgotten 都得在"
|
|
549
|
+
);
|
|
550
|
+
store.close();
|
|
551
|
+
});
|
|
552
|
+
|
|
424
553
|
test("睡眠侧:被按下作用域的行不进关系补全的候选池", async () => {
|
|
425
554
|
const { store, service } = setup(sleepConfig());
|
|
426
555
|
const alpha = service.createEntity({ name: "Alpha", type: "project" });
|
|
@@ -453,7 +582,7 @@ test("巩固侧:被按下作用域的行不进总览输入——否则会被
|
|
|
453
582
|
llm: {
|
|
454
583
|
async *stream(options) {
|
|
455
584
|
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
456
|
-
if (userText.
|
|
585
|
+
if (userText.includes("| type=")) {
|
|
457
586
|
yield { type: "text-delta", index: 0, text: "[]" };
|
|
458
587
|
yield { type: "finish", reason: { kind: "stop" } };
|
|
459
588
|
return;
|
|
@@ -104,10 +104,22 @@ function routesInBullet(line) {
|
|
|
104
104
|
return out;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
107
|
+
// 清单行必须**恰好一条**。原先用 .find() 只读第一条命中,于是「同一条目出现两份」
|
|
108
|
+
// 这件事守卫完全无感:#509 的 merge 把两份 Open reads / Gated routes 一起解进来,
|
|
109
|
+
// 其中一份 stale(少 /memory/provenance),CI 却全绿——因为 .find() 读的是第一份。
|
|
110
|
+
// 「文档与代码平价」的全部信心都来自这个文件,一旦它对重复条目零感知,第二份就是没有守卫的谎话。
|
|
111
|
+
// 所以这里先收全部命中,再判唯一;多于一条时把行号报出来,避免变成「哪份算数」的新谜题。
|
|
112
|
+
// source 参数只为可测:守卫本身是「唯一性」,喂一份合成的重复文档就能验,不必真去改 SECURITY.md。
|
|
113
|
+
function bullet(prefix, source = doc) {
|
|
114
|
+
const hits = [];
|
|
115
|
+
source.split(/\r?\n/).forEach((line, i) => {
|
|
116
|
+
if (line.startsWith(prefix)) hits.push({ line, no: i + 1 });
|
|
117
|
+
});
|
|
118
|
+
assert.ok(hits.length > 0, `SECURITY.md 里应有以「${prefix}」开头的清单行`);
|
|
119
|
+
assert.equal(hits.length, 1,
|
|
120
|
+
`SECURITY.md 里以「${prefix}」开头的清单行必须恰好一条,实际 ${hits.length} 条(行号 ${hits.map((h) => h.no).join(", ")})。` +
|
|
121
|
+
"清单只许有一份:多出来的那份不会被本文件校验,改它不会红,只能靠删掉来保持平价。");
|
|
122
|
+
return routesInBullet(hits[0].line);
|
|
111
123
|
}
|
|
112
124
|
|
|
113
125
|
// 文档里的 /semantic/graph/* 覆盖代码里的 /semantic/graph/ego 与 /semantic/graph/entity-attrs。
|
|
@@ -137,3 +149,30 @@ test("SECURITY.md 的受门控清单与代码里的 policy 声明一致", () =>
|
|
|
137
149
|
assert.deepEqual(stale, [], `${prefix} 写了代码里并不受门控的路由:${stale.join(", ")}`);
|
|
138
150
|
}
|
|
139
151
|
});
|
|
152
|
+
|
|
153
|
+
// 上面那条唯一性断言本身要有回归:把 SECURITY.md 的清单行复制成两份必须红。
|
|
154
|
+
// 这条用例喂合成文档,不碰真文件——真文件的平价由上面两条双向比对负责。
|
|
155
|
+
// 变异自证(人工做过一次,记录在 PR 里):把 Gated routes 那一行原样复制一份 → 红;
|
|
156
|
+
// 复制成两份且第二份把 /memory/provenance 换成别的 → 也红。两种都只在「恰好一条」上红。
|
|
157
|
+
test("重复的清单行会被守卫抓住(#523)", () => {
|
|
158
|
+
const gated = doc.split(/\r?\n/).find((l) => l.startsWith("- **Gated routes"));
|
|
159
|
+
assert.ok(gated, "SECURITY.md 里应有 Gated routes 清单行");
|
|
160
|
+
|
|
161
|
+
const withDup = ["# synthetic", gated, gated].join("\n");
|
|
162
|
+
assert.throws(
|
|
163
|
+
() => bullet("- **Gated routes", withDup),
|
|
164
|
+
/必须恰好一条,实际 2 条(行号 2, 3)/,
|
|
165
|
+
"同一清单行出现两份时必须报出重复的行号——这正是 #509 merge 时守卫看不见的形状"
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// 内容不同的两份同样要红:危险的不是写错了什么,而是那份副本对守卫隐形。
|
|
169
|
+
const staleDup = ["# synthetic", gated, gated.replace("/memory/provenance", "/memory/provenance-OLD")].join("\n");
|
|
170
|
+
assert.throws(
|
|
171
|
+
() => bullet("- **Gated routes", staleDup),
|
|
172
|
+
/必须恰好一条,实际 2 条/,
|
|
173
|
+
"两份内容不同的清单同样必须红,否则陈旧副本可以一直过期"
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
// 只有一份时不能误伤。
|
|
177
|
+
assert.ok(bullet("- **Gated routes", gated).length > 0);
|
|
178
|
+
});
|
package/test/serve.test.js
CHANGED
|
@@ -4,6 +4,8 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { createServeRuntime } from "../src/serve.js";
|
|
7
|
+
import { createStore } from "../src/store.js";
|
|
8
|
+
import { createSettings } from "../src/settings.js";
|
|
7
9
|
|
|
8
10
|
// daemon 装配面锁(test/serve-bin.test.js 另有真子进程 + 多进程共存):
|
|
9
11
|
// in-process 起真 HTTP(port 0,OS 分配),fetch 走 health / 401 / save / search /
|
|
@@ -194,3 +196,63 @@ test("serve: 非法 embed provider 拒装配;embed=off 时 semantic 为 null", a
|
|
|
194
196
|
try { rmSync(dir, { recursive: true, force: true }); } catch { /* 同上 */ }
|
|
195
197
|
}
|
|
196
198
|
});
|
|
199
|
+
|
|
200
|
+
// --- GET /memory/provenance 的 distillRecords.enabled 门(#509 评审整改)--------
|
|
201
|
+
// 单独立一条 daemon 级用例的理由:standalone 的既有用例是手搭 config 骗过门的,
|
|
202
|
+
// 而 daemon 的 cfg 来自 serve.js 自己装配(原本是最小集,一个 distillRecords 键都没有)
|
|
203
|
+
// ——不测真实装配,就会漏掉「daemon 恒定回功能关了」这一整类回归。
|
|
204
|
+
async function seedFlags(dir, patch) {
|
|
205
|
+
const seed = createStore(join(dir, "memory.db"));
|
|
206
|
+
createSettings(seed.db).setFeatureFlags(patch);
|
|
207
|
+
seed.close();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
test("serve: /memory/provenance 跟随面板开关(开着读得到)", async () => {
|
|
211
|
+
const dir = tmpDir();
|
|
212
|
+
await seedFlags(dir, { "distillRecords.enabled": true });
|
|
213
|
+
const rt = await createServeRuntime({ memoryDir: dir, port: 0, embed: "off" });
|
|
214
|
+
await rt.api.ready;
|
|
215
|
+
try {
|
|
216
|
+
const base = `http://127.0.0.1:${rt.api.port}`;
|
|
217
|
+
const auth = { authorization: `Bearer ${rt.api.token}` };
|
|
218
|
+
// 先经数据面写一条记忆,再直接落溯源链接与原文(写入面不关心是谁写的)。
|
|
219
|
+
const saved = await fetch(`${base}/memories`, {
|
|
220
|
+
method: "POST", headers: { ...auth, "content-type": "application/json" },
|
|
221
|
+
body: JSON.stringify({ type: "decision", title: "核心决策", content: "选定 SQLite 单文件库", importance: 5 })
|
|
222
|
+
});
|
|
223
|
+
const savedBody = await saved.json();
|
|
224
|
+
const memoryId = savedBody.memory?.id ?? savedBody.id;
|
|
225
|
+
rt.store.saveMemoryProvenance({ memoryId, sessionId: "s1", seqStart: 10, seqEnd: 11, createdAt: "2026-10-23T00:00:00Z" });
|
|
226
|
+
rt.store.saveDistillRecords([
|
|
227
|
+
{ sessionId: "s1", seq: 10, role: "user", text: "第一句原文", windowId: "w1", distilledAt: "2026-10-23T00:00:00Z" },
|
|
228
|
+
{ sessionId: "s1", seq: 11, role: "assistant", text: "第二句原文", windowId: "w1", distilledAt: "2026-10-23T00:00:00Z" }
|
|
229
|
+
]);
|
|
230
|
+
|
|
231
|
+
const res = await fetch(`${base}/memory/provenance?id=${encodeURIComponent(memoryId)}`, { headers: auth });
|
|
232
|
+
assert.equal(res.status, 200, "面板开着的留存,daemon 必须读得到(serve.js 把开关展开进了 cfg)");
|
|
233
|
+
const body = await res.json();
|
|
234
|
+
assert.deepEqual(body.windows[0].records.map((r) => r.text), ["第一句原文", "第二句原文"]);
|
|
235
|
+
} finally {
|
|
236
|
+
rt.dispose();
|
|
237
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* win 文件锁偶发 */ }
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("serve: 面板从没设过 distillRecords.enabled → daemon 这一面按关处理(409)", async () => {
|
|
242
|
+
// 残留不对称(写进 docs/DAEMON.md):只靠 bundle 配置打开、从没在面板点过的用户,
|
|
243
|
+
// 宿主认、daemon 不认——daemon 结构上看不到宿主的 bundle config。这是已知的那一半,
|
|
244
|
+
// 不是本用例要修的;这里锁的是「没设过 → 关」这个 fail-closed 方向本身。
|
|
245
|
+
const dir = tmpDir();
|
|
246
|
+
const rt = await createServeRuntime({ memoryDir: dir, port: 0, embed: "off" });
|
|
247
|
+
await rt.api.ready;
|
|
248
|
+
try {
|
|
249
|
+
const res = await fetch(`http://127.0.0.1:${rt.api.port}/memory/provenance?id=m1`, {
|
|
250
|
+
headers: { authorization: `Bearer ${rt.api.token}` }
|
|
251
|
+
});
|
|
252
|
+
assert.equal(res.status, 409);
|
|
253
|
+
assert.deepEqual(await res.json(), { error: "distill-records-disabled" });
|
|
254
|
+
} finally {
|
|
255
|
+
rt.dispose();
|
|
256
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* win 文件锁偶发 */ }
|
|
257
|
+
}
|
|
258
|
+
});
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { Config } from "../src/config.js";
|
|
4
|
+
import { createSessionGate } from "../src/session-gate.js";
|
|
5
|
+
import { createStore } from "../src/store.js";
|
|
6
|
+
import { createService } from "../src/service.js";
|
|
7
|
+
import { createSettings } from "../src/settings.js";
|
|
8
|
+
import { createInjector } from "../src/inject.js";
|
|
9
|
+
import { MEMORY_GUIDE_SECTION } from "../src/guide.js";
|
|
10
|
+
import { createTools } from "../src/tools.js";
|
|
11
|
+
import { createSummarizer } from "../src/summarize.js";
|
|
12
|
+
|
|
13
|
+
test("session gate defaults off and gates subagents by the host's delegation marks", () => {
|
|
14
|
+
const defaults = Config({});
|
|
15
|
+
assert.equal(defaults.sessionGateEnabled, false);
|
|
16
|
+
assert.equal(defaults.sessionGateSubagents, true);
|
|
17
|
+
assert.deepEqual(defaults.sessionGateSessions, []);
|
|
18
|
+
|
|
19
|
+
const gate = createSessionGate({ sessionGateEnabled: true, sessionGateSubagents: true });
|
|
20
|
+
const sessions = [
|
|
21
|
+
{ id: "header-origin", header: { origin: "subagent" } },
|
|
22
|
+
{ id: "header-depth", header: { delegationDepth: 2 } },
|
|
23
|
+
{ id: "header-both", header: { parentSession: "captain", origin: "subagent", delegationDepth: 1 } },
|
|
24
|
+
{ id: "request-origin", requestHeader: () => ({ config: { origin: "subagent", delegationDepth: 1 } }) }
|
|
25
|
+
];
|
|
26
|
+
for (const session of sessions) {
|
|
27
|
+
assert.equal(gate.isOff(session), true, session.id);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const firstEvent = { id: "first-event", header: { parentSession: "captain", origin: "subagent" } };
|
|
31
|
+
assert.equal(gate.isOff(firstEvent), true, "header match works before any session/event was observed");
|
|
32
|
+
gate.observe(firstEvent);
|
|
33
|
+
assert.equal(gate.isOff("first-event"), true, "observed child ids remain recognizable in log scans");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// 形状锁:`parentSession` 是「有父会话」,不是「是子代理」。dsh-session 的
|
|
37
|
+
// sessions.fork() 给**主会话分支**也写 parentSession(且不写 origin / 不带
|
|
38
|
+
// delegationDepth),把它当判据就会让用户 fork 出来的普通会话整体失忆。
|
|
39
|
+
// 本机 641 个真实会话头实测:origin/delegationDepth 命中 455 个子代理、零漏检,
|
|
40
|
+
// parentSession 对子代理识别零贡献、只多判出 3 个 fork 主会话(各有 74–136 条
|
|
41
|
+
// 用户消息、694 次工具调用)。这条用例锁的就是那次误判不许回来。
|
|
42
|
+
test("parentSession alone never gates: forked captain sessions stay fully enabled", () => {
|
|
43
|
+
const gate = createSessionGate({ sessionGateEnabled: true, sessionGateSubagents: true });
|
|
44
|
+
const forked = { id: "forked-captain", header: { parentSession: "captain", isSeeded: true, delegationDepth: 0 } };
|
|
45
|
+
assert.equal(gate.isOff(forked), false, "a fork of a captain session is still a captain session");
|
|
46
|
+
assert.equal(gate.isOff({ id: "flat-parent", parentSession: "captain" }), false);
|
|
47
|
+
assert.equal(gate.isOff({ id: "alias-parent", parentSessionId: "captain" }), false);
|
|
48
|
+
assert.equal(gate.isOff({ id: "request-parent", requestHeader: () => ({ config: { parentSession: "captain" } }) }), false);
|
|
49
|
+
gate.observe(forked);
|
|
50
|
+
assert.equal(gate.isOff("forked-captain"), false, "observing a non-child must not record it as one");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("manual ids survive the subagent switch, master-off is inert, and config is read live", () => {
|
|
54
|
+
const cfg = { sessionGateEnabled: false, sessionGateSubagents: false, sessionGateSessions: ["manual"] };
|
|
55
|
+
const gate = createSessionGate(cfg);
|
|
56
|
+
const manual = { id: "manual" };
|
|
57
|
+
const child = { id: "child", header: { origin: "subagent" } };
|
|
58
|
+
|
|
59
|
+
assert.equal(gate.isOff(manual), false, "the master is the only opt-in");
|
|
60
|
+
cfg.sessionGateEnabled = true;
|
|
61
|
+
assert.equal(gate.isOff(manual), true, "manual id matches even when automatic child detection is off");
|
|
62
|
+
assert.equal(gate.isOff(child), false, "subagent switch independently controls automatic matching");
|
|
63
|
+
cfg.sessionGateSubagents = true;
|
|
64
|
+
assert.equal(gate.isOff(child), true, "the next call sees a live config change");
|
|
65
|
+
cfg.sessionGateEnabled = false;
|
|
66
|
+
assert.equal(gate.isOff(manual), false, "turning the master off restores normal behavior immediately");
|
|
67
|
+
|
|
68
|
+
// 复现 comment 6541389(#541):上面刚把总开关关掉,若在这里直接断言
|
|
69
|
+
// doesNotThrow,isEnabled() 就先把调用挡回去了,id getter 那条保护路径
|
|
70
|
+
// 一次都不会执行——测的是「关掉时不出事」,不是「读不到 id 时不出事」。
|
|
71
|
+
// 所以先开总开关,再断言两件事:不抛,且被当成普通会话(false),
|
|
72
|
+
// 而不是被当成「id 为空所以放行」的巧合。
|
|
73
|
+
cfg.sessionGateEnabled = true;
|
|
74
|
+
const inaccessible = {};
|
|
75
|
+
Object.defineProperty(inaccessible, "id", { get() { throw new Error("host getter"); } });
|
|
76
|
+
assert.doesNotThrow(() => gate.isOff(inaccessible));
|
|
77
|
+
assert.equal(gate.isOff(inaccessible), false, "an unreadable id is an ordinary session, not a gated one");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// #539 原文:「子会话在首次 session/event 之前就可能组装上下文,故必须直接检查
|
|
81
|
+
// 传入 session,不能只靠事件后记录 ID」。所以「拿不到 id」不能当作「不是无痕
|
|
82
|
+
// 会话」——那一行早退正是这条要求最容易失守的地方(注入面在上下文组装期跑的
|
|
83
|
+
// 就是这条路径)。手动名单仍然依赖 id,那是名单本身的形状,不是缺陷。
|
|
84
|
+
test("a subagent session without a usable id is still gated", () => {
|
|
85
|
+
const gate = createSessionGate({ sessionGateEnabled: true, sessionGateSubagents: true });
|
|
86
|
+
assert.equal(gate.isOff({ header: { origin: "subagent" } }), true, "id-less subagent header");
|
|
87
|
+
assert.equal(gate.isOff({ header: { delegationDepth: 1 } }), true, "id-less delegation depth");
|
|
88
|
+
assert.equal(gate.isOff({ id: "", header: { origin: "subagent" } }), true, "empty id is the same as missing");
|
|
89
|
+
assert.equal(gate.isOff({}), false, "no id and no child mark is an ordinary session");
|
|
90
|
+
assert.equal(gate.isOff(null), false);
|
|
91
|
+
assert.equal(gate.isOff(undefined), false);
|
|
92
|
+
const offSwitch = createSessionGate({ sessionGateEnabled: true, sessionGateSubagents: false });
|
|
93
|
+
assert.equal(offSwitch.isOff({ header: { origin: "subagent" } }), false, "the subagent switch still wins");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// 记账表只服务「日志扫描拿得到 id、却解析不出头」这一条退路,所以它必须有界:
|
|
97
|
+
// 长驻宿主跑上几百个子代理后只涨不落会把每个 id 永久留在内存里,而淘汰最旧的
|
|
98
|
+
// 代价只是那一次日志过滤少认一个会话,活路径永远直接用会话对象判定。
|
|
99
|
+
test("tracked child ids stay bounded and eviction only costs the log-scan fallback", () => {
|
|
100
|
+
const gate = createSessionGate({ sessionGateEnabled: true, sessionGateSubagents: true });
|
|
101
|
+
for (let i = 0; i < 600; i += 1) gate.observe({ id: `child-${i}`, header: { origin: "subagent" } });
|
|
102
|
+
assert.equal(gate.isOff("child-599"), true, "the newest child is still tracked");
|
|
103
|
+
assert.equal(gate.isOff("child-0"), false, "the oldest id was evicted instead of kept forever");
|
|
104
|
+
gate.observe({ id: "child-599", header: { origin: "subagent" } });
|
|
105
|
+
assert.equal(gate.isOff("child-599"), true, "re-observing the same id stays stable");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
function injectionSetup() {
|
|
109
|
+
const store = createStore(":memory:");
|
|
110
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
111
|
+
const settings = createSettings(store.db);
|
|
112
|
+
service.saveWithDedupe({ type: "preference", title: "语言", content: "中文", importance: 5 });
|
|
113
|
+
settings.setProfile("用户画像正文");
|
|
114
|
+
settings.setRules(["先给结论"]);
|
|
115
|
+
const contexts = [];
|
|
116
|
+
const sections = [];
|
|
117
|
+
const ctx = {
|
|
118
|
+
systemPrompt: {
|
|
119
|
+
context(definition) { contexts.push(definition); return () => {}; },
|
|
120
|
+
section(definition) { sections.push(definition); return () => {}; }
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const gate = createSessionGate({
|
|
124
|
+
sessionGateEnabled: true,
|
|
125
|
+
sessionGateSubagents: true,
|
|
126
|
+
sessionGateSessions: ["private"]
|
|
127
|
+
});
|
|
128
|
+
createInjector(ctx, service, settings, { injectGuidanceEnabled: true }, null, gate);
|
|
129
|
+
return { store, contexts, sections, gate };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
test("all system-prompt surfaces are empty for incognito and remain available to captain", () => {
|
|
133
|
+
const { contexts, sections, gate } = injectionSetup();
|
|
134
|
+
const memory = contexts.find((item) => item.name === "memory");
|
|
135
|
+
const userSettings = contexts.find((item) => item.name === "user-settings");
|
|
136
|
+
const guide = sections.find((item) => item.name === "memory-guide");
|
|
137
|
+
const privateContext = { agent: { session: { id: "private" } } };
|
|
138
|
+
const childContext = { agent: { session: { id: "worker", header: { parentSession: "captain", origin: "subagent" } } } };
|
|
139
|
+
const captainContext = { agent: { session: { id: "captain", header: { origin: "main" } } } };
|
|
140
|
+
|
|
141
|
+
assert.equal(memory.text(privateContext), "");
|
|
142
|
+
assert.equal(userSettings.text(privateContext), "");
|
|
143
|
+
assert.equal(guide.text(privateContext), "");
|
|
144
|
+
assert.equal(memory.text(childContext), "");
|
|
145
|
+
assert.equal(userSettings.text(childContext), "");
|
|
146
|
+
assert.equal(guide.text(childContext), "");
|
|
147
|
+
|
|
148
|
+
assert.match(memory.text(captainContext), /\[记忆库\]/);
|
|
149
|
+
assert.match(userSettings.text(captainContext), /用户画像正文/);
|
|
150
|
+
assert.equal(guide.text(captainContext), MEMORY_GUIDE_SECTION);
|
|
151
|
+
gate.observe(childContext.agent.session);
|
|
152
|
+
assert.equal(gate.isOff("worker"), true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("every registered memory tool rejects before side effects for an incognito session", async () => {
|
|
156
|
+
const store = createStore(":memory:");
|
|
157
|
+
const baseService = createService({ store, mirror: null, config: {} });
|
|
158
|
+
let serviceCalls = 0;
|
|
159
|
+
const service = new Proxy(baseService, {
|
|
160
|
+
get(target, key, receiver) {
|
|
161
|
+
const value = Reflect.get(target, key, receiver);
|
|
162
|
+
if (typeof value !== "function") return value;
|
|
163
|
+
return (...args) => {
|
|
164
|
+
serviceCalls += 1;
|
|
165
|
+
return value.apply(target, args);
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
const registered = [];
|
|
170
|
+
const registry = { register(tool) { registered.push(tool); return () => {}; } };
|
|
171
|
+
const gate = createSessionGate({ sessionGateEnabled: true, sessionGateSessions: ["private"] });
|
|
172
|
+
createTools({ tools: registry }, service, {}, null, gate);
|
|
173
|
+
const exec = { agent: { session: { id: "private" } } };
|
|
174
|
+
|
|
175
|
+
assert.ok(registered.length >= 11);
|
|
176
|
+
for (const tool of registered) {
|
|
177
|
+
await assert.rejects(tool.execute({}, exec), /incognito session/, tool.name);
|
|
178
|
+
}
|
|
179
|
+
assert.equal(serviceCalls, 0, "rejected calls do not reach memory service reads or writes");
|
|
180
|
+
assert.equal(store.count(), 0, "no memory rows were written through an executor");
|
|
181
|
+
assert.equal(store.db.prepare("SELECT COUNT(*) AS n FROM scope_state").get().n, 0, "set_scope_state also rejects before writing");
|
|
182
|
+
|
|
183
|
+
const captainSave = registered.find((tool) => tool.name === "memory_save");
|
|
184
|
+
const result = await captainSave.execute(
|
|
185
|
+
{ type: "preference", title: "captain", content: "可用", importance: 4 },
|
|
186
|
+
{ agent: { session: { id: "captain", header: { origin: "main" } } } }
|
|
187
|
+
);
|
|
188
|
+
assert.equal(result.action, "created", "normal captain tool use is unaffected");
|
|
189
|
+
assert.equal(store.count(), 1);
|
|
190
|
+
store.close();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("retained tool executors use the latest gate after live registry reapply", async () => {
|
|
194
|
+
const store = createStore(":memory:");
|
|
195
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
196
|
+
const registered = [];
|
|
197
|
+
const registry = { register(tool) { registered.push(tool); return () => {}; } };
|
|
198
|
+
const initialGate = createSessionGate({ sessionGateEnabled: true, sessionGateSessions: ["private"] });
|
|
199
|
+
createTools({ tools: registry }, service, {}, null, initialGate);
|
|
200
|
+
const retainedSave = registered.find((tool) => tool.name === "memory_save");
|
|
201
|
+
const privateExec = { agent: { session: { id: "private" } } };
|
|
202
|
+
const args = { type: "preference", title: "live gate", content: "check", importance: 4 };
|
|
203
|
+
|
|
204
|
+
await assert.rejects(retainedSave.execute(args, privateExec), /incognito session/);
|
|
205
|
+
const openGate = createSessionGate({ sessionGateEnabled: false });
|
|
206
|
+
createTools({ tools: registry }, service, {}, null, openGate);
|
|
207
|
+
assert.equal(registered.length, 11, "live reapply keeps the host's existing tool registrations");
|
|
208
|
+
assert.equal((await retainedSave.execute(args, privateExec)).action, "created", "off restores the original tool behavior");
|
|
209
|
+
const closedGate = createSessionGate({ sessionGateEnabled: true, sessionGateSessions: ["private"] });
|
|
210
|
+
createTools({ tools: registry }, service, {}, null, closedGate);
|
|
211
|
+
await assert.rejects(
|
|
212
|
+
retainedSave.execute({ ...args, content: "blocked" }, privateExec),
|
|
213
|
+
/incognito session/,
|
|
214
|
+
"a retained tool uses the gate from the latest live reapply"
|
|
215
|
+
);
|
|
216
|
+
assert.equal(store.count(), 1, "the re-applied gate still runs before any write");
|
|
217
|
+
store.close();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
function summarySession(id, header = {}) {
|
|
221
|
+
return {
|
|
222
|
+
id,
|
|
223
|
+
header,
|
|
224
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
225
|
+
events: [
|
|
226
|
+
{ type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "选型" }] } },
|
|
227
|
+
{ type: "turn/end", seq: 2 }
|
|
228
|
+
]
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
test("distillation skips incognito sessions before model or storage work", async () => {
|
|
233
|
+
const store = createStore(":memory:");
|
|
234
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
235
|
+
const listeners = [];
|
|
236
|
+
const calls = [];
|
|
237
|
+
const ctx = {
|
|
238
|
+
on(name, handler) { listeners.push({ name, handler }); return () => {}; },
|
|
239
|
+
llm: {
|
|
240
|
+
stream() {
|
|
241
|
+
calls.push(true);
|
|
242
|
+
const output = JSON.stringify([{ type: "decision", title: "选择", content: "落库", importance: 4 }]);
|
|
243
|
+
return (async function* () {
|
|
244
|
+
yield { type: "block-start", block: { type: "text" } };
|
|
245
|
+
yield { type: "text-delta", delta: output };
|
|
246
|
+
yield { type: "block-end", block: { type: "text" } };
|
|
247
|
+
yield { type: "finish", kind: "ok" };
|
|
248
|
+
})();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
const gate = createSessionGate({ sessionGateEnabled: true, sessionGateSessions: ["private"] });
|
|
253
|
+
const summarizer = createSummarizer(ctx, service, { autoSummarize: true, distillRateLimitIntervalMs: 0 }, {}, gate);
|
|
254
|
+
const handler = listeners.find((item) => item.name === "session/event").handler;
|
|
255
|
+
|
|
256
|
+
await handler(summarySession("private"), { type: "turn/end", seq: 2 });
|
|
257
|
+
assert.equal(calls.length, 0);
|
|
258
|
+
assert.equal(store.count(), 0);
|
|
259
|
+
await handler(summarySession("captain", { origin: "main" }), { type: "turn/end", seq: 2 });
|
|
260
|
+
assert.equal(calls.length, 1);
|
|
261
|
+
assert.equal(store.count(), 1, "captain still distills normally");
|
|
262
|
+
summarizer.dispose();
|
|
263
|
+
store.close();
|
|
264
|
+
});
|
package/test/settings.test.js
CHANGED
|
@@ -186,6 +186,8 @@ test("feature flags accept the new boolean, string, url and enum keys", () => {
|
|
|
186
186
|
"distillRecords.retentionDays": 30,
|
|
187
187
|
bm25SearchEnabled: false,
|
|
188
188
|
conflictFreezeEnabled: true,
|
|
189
|
+
sessionGateEnabled: true,
|
|
190
|
+
sessionGateSubagents: false,
|
|
189
191
|
trustEpistemicWeighting: true,
|
|
190
192
|
reflectionFailureTracking: false,
|
|
191
193
|
dreamProvider: " siliconflow ",
|
|
@@ -205,6 +207,8 @@ test("feature flags accept the new boolean, string, url and enum keys", () => {
|
|
|
205
207
|
assert.equal(flags.conflictFreezeEnabled, true);
|
|
206
208
|
assert.equal(flags.trustEpistemicWeighting, true);
|
|
207
209
|
assert.equal(flags.reflectionFailureTracking, false);
|
|
210
|
+
assert.equal(flags.sessionGateEnabled, true);
|
|
211
|
+
assert.equal(flags.sessionGateSubagents, false);
|
|
208
212
|
assert.equal(flags.dreamProvider, "siliconflow", "string values are trimmed");
|
|
209
213
|
assert.equal(flags.dreamModel, "deepseek-chat");
|
|
210
214
|
assert.equal(flags.ollamaBaseUrl, "http://127.0.0.1:11434/");
|