@modusensus/dsh-mneme 0.9.5 → 0.10.1
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 +10 -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 +76 -6
- package/lib/client.js +259 -11
- 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/graph/anchoring.js +7 -2
- 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 +99 -8
- package/lib/session-gate.js +91 -0
- package/lib/settings.js +13 -1
- package/lib/store.js +681 -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 +76 -6
- 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/graph/anchoring.js +7 -2
- 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 +99 -8
- package/src/session-gate.js +91 -0
- package/src/settings.js +13 -1
- package/src/store.js +681 -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 +380 -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/graph-boosted-relations.test.js +189 -0
- package/test/graph-multipath.test.js +269 -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/store.test.js
CHANGED
|
@@ -476,6 +476,31 @@ test("countConflictPending counts unresolved rows only; resolve unknown id is un
|
|
|
476
476
|
store.close();
|
|
477
477
|
});
|
|
478
478
|
|
|
479
|
+
// Issue #533:differentiate 的裁决账本。正文不再追加注记,改由这张表记住「这一对
|
|
480
|
+
// 已经判过、两条都留」,候选构造阶段读它避免同一对每轮重判。
|
|
481
|
+
test("Issue #533: differentiated_pairs survives a reopen and normalizes pair order", () => {
|
|
482
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-diffpair-"));
|
|
483
|
+
const path = join(dir, "memory.db");
|
|
484
|
+
const s1 = createStore(path);
|
|
485
|
+
// 送审顺序与存储顺序相反也要落到同一行(账本按 id 排序归一)。
|
|
486
|
+
s1.saveDifferentiatedPair({ run_id: "r1", memory_a: "mb", memory_b: "ma", hash_a: "hb", hash_b: "ha", distinctions: ["只看甲", "只看乙"] });
|
|
487
|
+
assert.equal(s1.listDifferentiatedPairs().length, 1);
|
|
488
|
+
s1.close();
|
|
489
|
+
const s2 = createStore(path);
|
|
490
|
+
const found = s2.findDifferentiatedPair("ma", "mb");
|
|
491
|
+
assert.ok(found, "lookups work in either argument order");
|
|
492
|
+
assert.equal(found.memory_a, "ma", "the row is stored sorted");
|
|
493
|
+
assert.equal(found.hash_a, "ha", "hashes travel with their side");
|
|
494
|
+
assert.deepEqual(found.distinctions, ["只看甲", "只看乙"]);
|
|
495
|
+
assert.equal(s2.listDifferentiatedPairs().length, 1, "re-saving the same pair updates instead of duplicating");
|
|
496
|
+
s2.saveDifferentiatedPair({ run_id: "r2", memory_a: "ma", memory_b: "mb", hash_a: "ha2", hash_b: "hb2" });
|
|
497
|
+
assert.equal(s2.listDifferentiatedPairs().length, 1, "still one row for one pair");
|
|
498
|
+
const updated = s2.findDifferentiatedPair("ma", "mb");
|
|
499
|
+
assert.equal(updated.hash_a, "ha2", "the refresh overwrites the hashes");
|
|
500
|
+
assert.equal(updated.distinctions, undefined, "an empty distinctions lands as NULL, not as an empty array");
|
|
501
|
+
s2.close();
|
|
502
|
+
});
|
|
503
|
+
|
|
479
504
|
test("conflict_pending table persists across store reopen", () => {
|
|
480
505
|
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-conflict-"));
|
|
481
506
|
const path = join(dir, "memory.db");
|
|
@@ -502,3 +527,198 @@ test("Issue #128: needsEmbedding skips archived and forgotten rows", () => {
|
|
|
502
527
|
assert.deepEqual(ids, [active.id], "only the active row is a backfill candidate");
|
|
503
528
|
store.close();
|
|
504
529
|
});
|
|
530
|
+
|
|
531
|
+
// Issue #495:listDreamRuns 的 excludeRunType 是**下推**条件而不是调用方后置过滤——
|
|
532
|
+
// 后置过滤在 limit 生效之后就晚了:5 个名额已经被 organize 占掉,再筛只能得到空。
|
|
533
|
+
// 三条一起钉:默认不过滤(审计面要看全量)、指定值真的排除、白名单之外的值不生效
|
|
534
|
+
// (这个参数拼进 SQL 的 != 条件,任意外来字符串不能改变查询形状)。
|
|
535
|
+
test("Issue #495: listDreamRuns excludes a run type by pushdown, and only accepts known types", () => {
|
|
536
|
+
const store = openMemory();
|
|
537
|
+
store.saveDreamRun({ created_at: "2026-01-01T00:00:00.000Z", status: "ok", snapshot_hash: "h", input_count: 0, run_type: "auto", receipt: "r1" });
|
|
538
|
+
store.saveDreamRun({ created_at: "2026-01-02T00:00:00.000Z", status: "ok", snapshot_hash: "h", input_count: 0, run_type: "organize", receipt: "r2" });
|
|
539
|
+
store.saveDreamRun({ created_at: "2026-01-03T00:00:00.000Z", status: "ok", snapshot_hash: "h", input_count: 0, run_type: "organize", receipt: "r3" });
|
|
540
|
+
|
|
541
|
+
assert.equal(store.listDreamRuns().length, 3, "no filter by default — the audit surface keeps every run type");
|
|
542
|
+
assert.equal(store.listDreamRuns({ limit: 1 })[0].run_type, "organize", "limit applies to the full set");
|
|
543
|
+
|
|
544
|
+
const onlyConsolidation = store.listDreamRuns({ limit: 1, excludeRunType: "organize" });
|
|
545
|
+
assert.equal(onlyConsolidation.length, 1);
|
|
546
|
+
assert.equal(onlyConsolidation[0].run_type, "auto", "the pushdown must apply before LIMIT, not after");
|
|
547
|
+
|
|
548
|
+
// 白名单之外的值按「不过滤」处理,不静默返回空集。诚实说:这条断言钉得住
|
|
549
|
+
// 「未知值 ⇒ 不过滤」这个可观测契约,钉不住白名单本身——把 includes 判断换成裸绑定,
|
|
550
|
+
// 四条断言仍然全绿(不存在的 run_type 本来也筛不掉任何行)。白名单的价值是纵深:
|
|
551
|
+
// 值来自调用方时只允许已知的三个字面量进 SQL 的 != 条件,查询形状不随外部字符串改变。
|
|
552
|
+
assert.equal(store.listDreamRuns({ excludeRunType: "auto' OR 1=1 --" }).length, 3,
|
|
553
|
+
"a hostile value is bound as a parameter and filters nothing, never widens the query");
|
|
554
|
+
assert.equal(store.listDreamRuns({ excludeRunType: "nope" }).length, 3);
|
|
555
|
+
store.close();
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// ============================ #512 蒸馏 run 归因
|
|
559
|
+
|
|
560
|
+
test("#512 迁移:老库补上 distill_run_id 列与两张新表,存量链接留 NULL 不猜", () => {
|
|
561
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-migrate-run-"));
|
|
562
|
+
const dbPath = join(dir, "legacy.db");
|
|
563
|
+
try {
|
|
564
|
+
// 存量库:#399 时代的形状——memory_provenance 没有 distill_run_id,也没有
|
|
565
|
+
// distill_runs 与 memory_mints。这一行代表「改造之前就落盘的链接」,它没有 run 可指。
|
|
566
|
+
const legacy = new DatabaseSync(dbPath);
|
|
567
|
+
legacy.exec("CREATE TABLE memories (" +
|
|
568
|
+
"id TEXT PRIMARY KEY, type TEXT NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL, " +
|
|
569
|
+
"tags TEXT NOT NULL DEFAULT '[]', importance INTEGER NOT NULL DEFAULT 3, " +
|
|
570
|
+
"forgotten INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0, " +
|
|
571
|
+
"source TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);");
|
|
572
|
+
legacy.exec("CREATE TABLE memory_provenance (" +
|
|
573
|
+
"memory_id TEXT NOT NULL, session_id TEXT NOT NULL, " +
|
|
574
|
+
"seq_start INTEGER NOT NULL, seq_end INTEGER NOT NULL, created_at TEXT NOT NULL);");
|
|
575
|
+
legacy.exec("INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, created_at, updated_at) " +
|
|
576
|
+
"VALUES ('old1', 'history', '老记忆', '内容', '[]', 3, 0, 0, NULL, '2026-01-01T00:00:00.000Z', '2026-01-02T00:00:00.000Z');");
|
|
577
|
+
legacy.exec("INSERT INTO memory_provenance (memory_id, session_id, seq_start, seq_end, created_at) " +
|
|
578
|
+
"VALUES ('old1', 's-old', 1, 5, '2026-01-02T00:00:00.000Z');");
|
|
579
|
+
legacy.close();
|
|
580
|
+
|
|
581
|
+
const store = createStore(dbPath);
|
|
582
|
+
const cols = store.db.prepare("PRAGMA table_info(memory_provenance)").all().map((c) => c.name);
|
|
583
|
+
assert.ok(cols.includes("distill_run_id"), "存量库重开即补上 distill_run_id");
|
|
584
|
+
for (const table of ["distill_runs", "memory_mints"]) {
|
|
585
|
+
const row = store.db.prepare(
|
|
586
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name=?"
|
|
587
|
+
).get(table);
|
|
588
|
+
assert.ok(row, "存量库重开即建 " + table);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const links = store.provenanceFor("old1");
|
|
592
|
+
assert.equal(links.length, 1);
|
|
593
|
+
assert.equal(
|
|
594
|
+
links[0].distill_run_id,
|
|
595
|
+
null,
|
|
596
|
+
"历史链接保持 NULL:反推一个合成 run id 比不填更坏(读数正常、实际是猜的)"
|
|
597
|
+
);
|
|
598
|
+
assert.equal(store.mintsFor("old1").length, 0, "历史记忆不补铸造记录——那时根本没有 run 概念");
|
|
599
|
+
store.close();
|
|
600
|
+
} finally {
|
|
601
|
+
rmSync(dir, { recursive: true, force: true });
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
test("#512 迁移幂等:重开已升级的库不重复 ALTER、不动已落盘的 run 归因", () => {
|
|
606
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-migrate-run2-"));
|
|
607
|
+
const dbPath = join(dir, "upgraded.db");
|
|
608
|
+
try {
|
|
609
|
+
const first = createStore(dbPath);
|
|
610
|
+
const m = first.save({ type: "history", title: "t", content: "c" });
|
|
611
|
+
first.saveDistillRun({
|
|
612
|
+
id: "run-a",
|
|
613
|
+
status: "ok",
|
|
614
|
+
run_type: "autoSummarize",
|
|
615
|
+
trigger_source: "autoSummarize",
|
|
616
|
+
session_id: "s1",
|
|
617
|
+
seq_start: 1,
|
|
618
|
+
seq_end: 2,
|
|
619
|
+
memory_count: 1
|
|
620
|
+
});
|
|
621
|
+
first.saveMemoryProvenance({
|
|
622
|
+
memoryId: m.id, sessionId: "s1", seqStart: 1, seqEnd: 2,
|
|
623
|
+
createdAt: "2026-11-01T00:00:00.000Z", distillRunId: "run-a"
|
|
624
|
+
});
|
|
625
|
+
first.close();
|
|
626
|
+
|
|
627
|
+
const second = createStore(dbPath);
|
|
628
|
+
const cols = second.db.prepare("PRAGMA table_info(memory_provenance)")
|
|
629
|
+
.all().filter((c) => c.name === "distill_run_id");
|
|
630
|
+
assert.equal(cols.length, 1, "列只存在一份,没被重复 ALTER");
|
|
631
|
+
assert.equal(second.provenanceFor(m.id)[0].distill_run_id, "run-a", "重开不动已落盘的归因");
|
|
632
|
+
assert.equal(second.getDistillRun("run-a").status, "ok");
|
|
633
|
+
second.close();
|
|
634
|
+
} finally {
|
|
635
|
+
rmSync(dir, { recursive: true, force: true });
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
test("#512 distill_runs:同 id replay 覆写而不重复", () => {
|
|
640
|
+
const store = openMemory();
|
|
641
|
+
store.saveDistillRun({
|
|
642
|
+
id: "r1", status: "failed", error: "boom",
|
|
643
|
+
run_type: "autoSummarize", trigger_source: "autoSummarize",
|
|
644
|
+
session_id: "s1", seq_start: 1, seq_end: 3, memory_count: 0,
|
|
645
|
+
created_at: "2026-11-01T00:00:00.000Z"
|
|
646
|
+
});
|
|
647
|
+
// 同一轮重放(等价于宿主重启后重试同一逻辑轮):必须覆写,不能变成两行。
|
|
648
|
+
store.saveDistillRun({
|
|
649
|
+
id: "r1", status: "ok",
|
|
650
|
+
run_type: "autoSummarize", trigger_source: "autoSummarize",
|
|
651
|
+
session_id: "s1", seq_start: 1, seq_end: 3, memory_count: 2,
|
|
652
|
+
created_at: "2026-11-01T00:00:00.000Z"
|
|
653
|
+
});
|
|
654
|
+
const runs = store.listDistillRuns();
|
|
655
|
+
assert.equal(runs.length, 1, "一轮只有一行");
|
|
656
|
+
assert.equal(runs[0].status, "ok", "replay 覆写成最新的事实");
|
|
657
|
+
assert.equal(runs[0].error, undefined, "覆写清掉上一次的错误");
|
|
658
|
+
assert.equal(runs[0].memory_count, 2);
|
|
659
|
+
assert.equal(runs[0].audit_id, undefined, "没有 audit 行时是 undefined,不是 0");
|
|
660
|
+
store.close();
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("#512 distill_runs:audit_id 事后补注,补不存在的 run 静默返回 0", () => {
|
|
664
|
+
const store = openMemory();
|
|
665
|
+
store.saveDistillRun({ id: "r2", status: "ok", run_type: "autoSummarize", trigger_source: "autoSummarize" });
|
|
666
|
+
assert.equal(store.attachDistillRunAudit("r2", 42), 1);
|
|
667
|
+
assert.equal(store.getDistillRun("r2").audit_id, 42);
|
|
668
|
+
assert.equal(store.attachDistillRunAudit("missing", 42), 0, "行不存在时不动任何东西");
|
|
669
|
+
assert.equal(store.attachDistillRunAudit("r2", null), 0, "非整数 audit id 一律不写");
|
|
670
|
+
store.close();
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
test("#512 memory_mints:同一 (记忆, run) 只记一次,删记忆时显式清掉归因", () => {
|
|
674
|
+
const store = openMemory();
|
|
675
|
+
const m = store.save({ type: "pattern", title: "抽出来的规律", content: "c", source: "dream" });
|
|
676
|
+
const mint = { memoryId: m.id, runId: "run-d", runType: "dream", triggerSource: "autoDream", createdAt: "2026-11-01T00:00:00.000Z" };
|
|
677
|
+
assert.equal(store.saveMemoryMint(mint), 1);
|
|
678
|
+
assert.equal(store.saveMemoryMint(mint), 0, "INSERT OR IGNORE:重放不重复记一次铸造");
|
|
679
|
+
const rows = store.mintsFor(m.id);
|
|
680
|
+
assert.equal(rows.length, 1);
|
|
681
|
+
assert.equal(rows[0].run_type, "dream");
|
|
682
|
+
assert.equal(rows[0].trigger_source, "autoDream");
|
|
683
|
+
|
|
684
|
+
store.remove(m.id);
|
|
685
|
+
assert.equal(
|
|
686
|
+
store.db.prepare("SELECT count(*) AS c FROM memory_mints WHERE memory_id = ?").get(m.id).c,
|
|
687
|
+
0,
|
|
688
|
+
"记忆没了,归因不许悬空(CASCADE 之外仍显式清,与 memory_provenance 同一口径)"
|
|
689
|
+
);
|
|
690
|
+
store.close();
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
// ============================ #493 补充:失败计数持久化(读侧失效)
|
|
694
|
+
// 这张表只服务一道默认关的护栏(summarizeParseFailureMaxRetries),但它的语义有三处
|
|
695
|
+
// 容易写错、且错法都不可见:计数不落库(重启即清零)、失效不在读侧(几周前的失败
|
|
696
|
+
// 直接熔断当前窗口)、清零写成 0(「从没失败过」与「刚清零」变得不可区分)。
|
|
697
|
+
test("#493 补充:解析失败计数持久化、读侧带失效窗口、清零是真 DELETE", () => {
|
|
698
|
+
const store = openMemory();
|
|
699
|
+
const sid = "s-streak";
|
|
700
|
+
assert.equal(store.getDistillParseFailureStreak(sid), 0, "没有记录就是 0,不是 undefined");
|
|
701
|
+
|
|
702
|
+
assert.equal(store.bumpDistillParseFailureStreak(sid), 1);
|
|
703
|
+
assert.equal(store.bumpDistillParseFailureStreak(sid), 2, "累计加一");
|
|
704
|
+
assert.equal(store.getDistillParseFailureStreak(sid), 2);
|
|
705
|
+
|
|
706
|
+
// 读侧失效:时间戳挪到 30 天前,7 天窗口下必须当 0(不判失效时它仍是 2)。
|
|
707
|
+
const ancient = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
708
|
+
store.db.prepare("UPDATE distill_parse_failures SET updated_at = ? WHERE session_id = ?").run(ancient, sid);
|
|
709
|
+
assert.equal(store.getDistillParseFailureStreak(sid), 2, "staleAfterMs 缺省不判失效(调用方显式要才判)");
|
|
710
|
+
assert.equal(store.getDistillParseFailureStreak(sid, { staleAfterMs: 7 * 24 * 60 * 60 * 1000 }), 0,
|
|
711
|
+
"过期的 streak 必须读成 0:拿几周前的失败去熔断当前窗口,护栏就成了新的误伤路径");
|
|
712
|
+
|
|
713
|
+
// 失效之后再加一,必须从 1 起(不是在旧值上 +1)——这条是本块实现的首版 bug,
|
|
714
|
+
// 被 summarize.test.js 的端到端用例抓出来:SQL 里写 streak + 1 会让失效判断失效。
|
|
715
|
+
assert.equal(store.bumpDistillParseFailureStreak(sid, { staleAfterMs: 7 * 24 * 60 * 60 * 1000 }), 1,
|
|
716
|
+
"过期的旧行不许把「新的第一次失败」加成 2");
|
|
717
|
+
|
|
718
|
+
// 清零是真删除:没有任何在途失败时不该留一行 0。
|
|
719
|
+
assert.equal(store.clearDistillParseFailureStreak(sid), 1);
|
|
720
|
+
assert.equal(store.getDistillParseFailureStreak(sid), 0);
|
|
721
|
+
assert.equal(store.db.prepare("SELECT count(*) AS c FROM distill_parse_failures").get().c, 0,
|
|
722
|
+
"清零是 DELETE,不是写 0——写 0 会让「从没失败过」与「刚清零」不可区分");
|
|
723
|
+
store.close();
|
|
724
|
+
});
|
package/test/summarize.test.js
CHANGED
|
@@ -1637,6 +1637,82 @@ test("issue#399: expiring records keeps the memories and leaves provenance dangl
|
|
|
1637
1637
|
assert.ok(store.provenanceFor(memoryId).length >= 1, "面板/CLI 仍能回答「这条记忆出自哪个窗口」,只是来源已过期");
|
|
1638
1638
|
});
|
|
1639
1639
|
|
|
1640
|
+
// ── Issue #512:蒸馏归因补 run 概念 ────────────────────────────────────────
|
|
1641
|
+
//
|
|
1642
|
+
// 这四条锁的是「这条记忆是哪一次蒸馏带进来的」这条问句现在回答得出来:
|
|
1643
|
+
// ① 一轮真蒸馏落一行 distill_runs,链接上带同一个 distill_run_id;
|
|
1644
|
+
// ② run 与它的开销读数(llm_audit_logs)靠 audit_id 直连,不靠时间戳猜;
|
|
1645
|
+
// ③ llmAudit 关掉时 run 仍然成立(归因不该依赖计费开关),只是没有 audit_id;
|
|
1646
|
+
// ④ 失败轮也留一行——「那一轮跑了但没成」本身就是归因要回答的事。
|
|
1647
|
+
// 反面(历史链接保持 NULL、不回填)在 test/store.test.js 的迁移用例里锁。
|
|
1648
|
+
|
|
1649
|
+
test("issue#512: a distilled window records one run, and every link points at it", async () => {
|
|
1650
|
+
const { events, store } = setup(RECORDS_ON);
|
|
1651
|
+
const session = recordsSession("s512-ok", [userMessage("这轮要能追到 run", 1), { seq: 2, type: "turn/end" }]);
|
|
1652
|
+
await eventHandler(events)(session, { seq: 2, type: "turn/end" });
|
|
1653
|
+
|
|
1654
|
+
const runs = store.listDistillRuns();
|
|
1655
|
+
assert.equal(runs.length, 1, "一轮真蒸馏就是一行 run");
|
|
1656
|
+
const run = runs[0];
|
|
1657
|
+
assert.equal(run.status, "ok");
|
|
1658
|
+
assert.equal(run.run_type, "autoSummarize");
|
|
1659
|
+
assert.equal(run.trigger_source, "autoSummarize");
|
|
1660
|
+
assert.equal(run.session_id, "s512-ok");
|
|
1661
|
+
assert.deepEqual([run.seq_start, run.seq_end], [1, 2], "run 记的是这一步真正蒸馏掉的窗口");
|
|
1662
|
+
assert.equal(run.memory_count, 2, "记忆条数是实际写成功的条数");
|
|
1663
|
+
|
|
1664
|
+
const links = store.db.prepare("SELECT memory_id, distill_run_id FROM memory_provenance").all();
|
|
1665
|
+
assert.equal(links.length, 2);
|
|
1666
|
+
for (const link of links) {
|
|
1667
|
+
assert.equal(link.distill_run_id, run.id, "每条记忆的链接都指向这一轮,不是 NULL、也不是别的轮");
|
|
1668
|
+
}
|
|
1669
|
+
});
|
|
1670
|
+
|
|
1671
|
+
test("issue#512: the run and its spend row are joined by audit_id, not guessed from timestamps", async () => {
|
|
1672
|
+
const { events, store } = setup(RECORDS_ON);
|
|
1673
|
+
const session = recordsSession("s512-audit", [userMessage("归因要连到开销行", 1), { seq: 2, type: "turn/end" }]);
|
|
1674
|
+
await eventHandler(events)(session, { seq: 2, type: "turn/end" });
|
|
1675
|
+
|
|
1676
|
+
const run = store.listDistillRuns()[0];
|
|
1677
|
+
const audits = store.listLlmAudits({ source: "autoSummarize" });
|
|
1678
|
+
assert.equal(audits.length, 1);
|
|
1679
|
+
assert.equal(run.audit_id, audits[0].id, "run 直接指着它自己那一行,不靠时间窗匹配");
|
|
1680
|
+
// 反向也成立:从 run 拿到的 id 读回来就是那一条。
|
|
1681
|
+
const join = store.getDistillRun(run.id);
|
|
1682
|
+
assert.equal(join.audit_id, audits[0].id);
|
|
1683
|
+
});
|
|
1684
|
+
|
|
1685
|
+
test("issue#512: with llmAudit off the run still lands — attribution must not depend on the spend switch", async () => {
|
|
1686
|
+
const { events, store } = setup({ ...RECORDS_ON, llmAudit: { enabled: false } });
|
|
1687
|
+
const session = recordsSession("s512-noaudit", [userMessage("关了计费也要能归因", 1), { seq: 2, type: "turn/end" }]);
|
|
1688
|
+
await eventHandler(events)(session, { seq: 2, type: "turn/end" });
|
|
1689
|
+
|
|
1690
|
+
assert.equal(store.listLlmAudits().length, 0, "开关关着,一行 audit 都不写");
|
|
1691
|
+
const runs = store.listDistillRuns();
|
|
1692
|
+
assert.equal(runs.length, 1, "但 run 照落");
|
|
1693
|
+
assert.equal(runs[0].status, "ok");
|
|
1694
|
+
assert.equal(runs[0].audit_id, undefined, "没有开销行时这一列是空的——不填假 id");
|
|
1695
|
+
});
|
|
1696
|
+
|
|
1697
|
+
test("issue#512: a failed round still leaves a run row saying it failed", async () => {
|
|
1698
|
+
// 流失败:窗口保留待重试,但「跑过一轮且失败」这件事必须留痕,否则归因只记得住成的。
|
|
1699
|
+
const { events, store } = setup(RECORDS_ON, {
|
|
1700
|
+
stream: () => (async function* () {
|
|
1701
|
+
yield { type: "block-start", block: { type: "text" } };
|
|
1702
|
+
yield { type: "finish", kind: "error", error: { message: "upstream exploded" } };
|
|
1703
|
+
})()
|
|
1704
|
+
});
|
|
1705
|
+
const session = recordsSession("s512-fail", [userMessage("这轮会失败", 1), { seq: 2, type: "turn/end" }]);
|
|
1706
|
+
await eventHandler(events)(session, { seq: 2, type: "turn/end" });
|
|
1707
|
+
|
|
1708
|
+
const runs = store.listDistillRuns();
|
|
1709
|
+
assert.equal(runs.length, 1, "失败轮也是一轮");
|
|
1710
|
+
assert.equal(runs[0].status, "failed");
|
|
1711
|
+
assert.ok(runs[0].error, "失败要有原因,空错误等于没说");
|
|
1712
|
+
assert.equal(runs[0].memory_count, 0);
|
|
1713
|
+
assert.equal(countRows(store, "SELECT count(*) AS c FROM distill_records"), 0, "失败轮不落原文");
|
|
1714
|
+
});
|
|
1715
|
+
|
|
1640
1716
|
test("issue#399: enabled=false (the default) writes nothing at all", async () => {
|
|
1641
1717
|
const { events, store, service } = setup({ distillRateLimitIntervalMs: 0 });
|
|
1642
1718
|
let saverCalls = 0;
|
|
@@ -2051,3 +2127,99 @@ test("#493 失败护栏:一次成功即清零计数,不会隔窗累计", asy
|
|
|
2051
2127
|
summarizer.dispose();
|
|
2052
2128
|
store.close();
|
|
2053
2129
|
});
|
|
2130
|
+
|
|
2131
|
+
// ============================ #493 补充:失败计数落库(不再只在进程内)
|
|
2132
|
+
// 报告者 strange2000(2026-11-02)第 ① 条:别只存裸计数。原先 streak 活在
|
|
2133
|
+
// createSummarizer 闭包里的一个 Map,重启即清零——而这道护栏要防的恰恰是「确定性
|
|
2134
|
+
// 失败反复重试」,重启一次就绕过去了。两条用例各自钉一半:
|
|
2135
|
+
// 1) 换一个 summarizer 实例(= 重启)后计数仍然接得上,阈值照旧触发;
|
|
2136
|
+
// 2) 会话停了很久之后计数失效,不会被几周前攒下的 streak 误伤(他特别点名的那个
|
|
2137
|
+
// 反例:护栏自己变成新的误伤路径)。
|
|
2138
|
+
test("#493 补充:失败计数落库——换一个 summarizer 实例(模拟重启)仍然接得上", async () => {
|
|
2139
|
+
const store = createStore(":memory:");
|
|
2140
|
+
const outputs = ["不是 JSON", "还不是 JSON", "依然不是 JSON"];
|
|
2141
|
+
let attempt = 0;
|
|
2142
|
+
const failStream = () => (async function* () {
|
|
2143
|
+
yield { type: "block-start", block: { type: "text" } };
|
|
2144
|
+
yield { type: "text-delta", delta: outputs[attempt++] ?? "[]" };
|
|
2145
|
+
yield { type: "finish", kind: "ok" };
|
|
2146
|
+
})();
|
|
2147
|
+
const config = { summarizeParseFailureMaxRetries: 3, distillRateLimitIntervalMs: 0 };
|
|
2148
|
+
const sessionOf = () => ({
|
|
2149
|
+
id: "s493-persisted-streak",
|
|
2150
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
2151
|
+
events: [userMessage("会被放弃的窗口", 1), { seq: 2, type: "turn/end" }]
|
|
2152
|
+
});
|
|
2153
|
+
|
|
2154
|
+
// 第一个实例:失败两次就「重启」(dispose + 丢掉闭包),阈值 3 还没到。
|
|
2155
|
+
const first = setup(config, { store, stream: failStream });
|
|
2156
|
+
const handlerA = eventHandler(first.events);
|
|
2157
|
+
const sessionA = sessionOf();
|
|
2158
|
+
await handlerA(sessionA, { seq: 2, type: "turn/end" });
|
|
2159
|
+
await handlerA(sessionA, { seq: 2, type: "turn/end" });
|
|
2160
|
+
first.summarizer.dispose();
|
|
2161
|
+
assert.equal(first.service.getDistillCursor("s493-persisted-streak"), undefined, "两次失败还没到阈值,游标不推进");
|
|
2162
|
+
assert.equal(first.store.getDistillParseFailureStreak("s493-persisted-streak"), 2,
|
|
2163
|
+
"计数必须已经在库里——进程内 Map 在这里会是 0(重启就丢)");
|
|
2164
|
+
|
|
2165
|
+
// 第二个实例:同一个库、同一个会话。第 3 次失败必须凑满阈值并放弃窗口。
|
|
2166
|
+
const second = setup(config, { store, stream: failStream });
|
|
2167
|
+
const handlerB = eventHandler(second.events);
|
|
2168
|
+
const sessionB = sessionOf();
|
|
2169
|
+
await handlerB(sessionB, { seq: 2, type: "turn/end" });
|
|
2170
|
+
|
|
2171
|
+
assert.equal(second.service.getDistillCursor("s493-persisted-streak")?.last_seq, 2,
|
|
2172
|
+
"重启前攒下的 2 次 + 这次的 1 次 = 阈值,窗口必须被放弃(进程内 Map 到这儿是 1,不会放弃)");
|
|
2173
|
+
const abandoned = second.service.listLlmAudits().filter((r) => r.status === "error")
|
|
2174
|
+
.find((r) => r.metadata?.skipped === "parse-failure-streak");
|
|
2175
|
+
assert.equal(abandoned?.metadata?.streak, 3, "审计里的 streak 是**累计**值,不是本次进程内从 1 起算");
|
|
2176
|
+
assert.equal(second.store.getDistillParseFailureStreak("s493-persisted-streak"), 0,
|
|
2177
|
+
"放弃后清零:窗口已消费,下一窗从全新状态起算");
|
|
2178
|
+
second.summarizer.dispose();
|
|
2179
|
+
store.close();
|
|
2180
|
+
});
|
|
2181
|
+
|
|
2182
|
+
test("#493 补充:停了很久的会话,旧 streak 失效,不会被几周前的失败误伤", async () => {
|
|
2183
|
+
const store = createStore(":memory:");
|
|
2184
|
+
let attempts = 0;
|
|
2185
|
+
const { events, calls, service, summarizer } = setup(
|
|
2186
|
+
{ summarizeParseFailureMaxRetries: 2, distillRateLimitIntervalMs: 0 },
|
|
2187
|
+
{
|
|
2188
|
+
store,
|
|
2189
|
+
stream() {
|
|
2190
|
+
return (async function* () {
|
|
2191
|
+
yield { type: "block-start", block: { type: "text" } };
|
|
2192
|
+
yield { type: "text-delta", delta: attempts++ === 0 ? "不是 JSON" : "[]" };
|
|
2193
|
+
yield { type: "finish", kind: "ok" };
|
|
2194
|
+
})();
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
);
|
|
2198
|
+
const handler = eventHandler(events);
|
|
2199
|
+
const session = {
|
|
2200
|
+
id: "s493-stale-streak",
|
|
2201
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
2202
|
+
events: [userMessage("隔了很久才回来的窗口", 1), { seq: 2, type: "turn/end" }]
|
|
2203
|
+
};
|
|
2204
|
+
|
|
2205
|
+
// 先在库里种一条「很久以前失败过一次」的记录(阈值 2,再有一次就会熔断)。
|
|
2206
|
+
service.bumpDistillParseFailureStreak("s493-stale-streak");
|
|
2207
|
+
const ancient = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
2208
|
+
store.db.prepare("UPDATE distill_parse_failures SET updated_at = ? WHERE session_id = ?").run(ancient, "s493-stale-streak");
|
|
2209
|
+
assert.equal(store.getDistillParseFailureStreak("s493-stale-streak"), 1, "不判失效时它还在");
|
|
2210
|
+
assert.equal(store.getDistillParseFailureStreak("s493-stale-streak", { staleAfterMs: 7 * 24 * 60 * 60 * 1000 }), 0,
|
|
2211
|
+
"读侧带失效窗口时必须当 0:30 天前的失败不算「连续失败」");
|
|
2212
|
+
|
|
2213
|
+
// 于是这次的第一次失败应当只算 streak=1,不熔断;成功一次即清零。
|
|
2214
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
2215
|
+
const firstFail = service.listLlmAudits().find((r) => r.status === "error");
|
|
2216
|
+
assert.equal(firstFail?.metadata?.streak, 1, "旧记录失效之后,这次失败是「新的第一次」");
|
|
2217
|
+
assert.equal(firstFail?.metadata?.skipped, undefined, "不许因为几周前的失败就直接熔断当前窗口");
|
|
2218
|
+
assert.equal(service.getDistillCursor("s493-stale-streak"), undefined, "游标没被跳过,窗口仍可重试");
|
|
2219
|
+
|
|
2220
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
2221
|
+
assert.equal(calls.length, 2);
|
|
2222
|
+
assert.equal(store.getDistillParseFailureStreak("s493-stale-streak"), 0, "成功即清零(DELETE,不是写 0)");
|
|
2223
|
+
summarizer.dispose();
|
|
2224
|
+
store.close();
|
|
2225
|
+
});
|