recallnest 2.5.4 → 2.6.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +34 -9
  3. package/README_CN.md +37 -12
  4. package/package.json +4 -4
  5. package/src/__tests__/access-tracker-novelty.test.ts +66 -0
  6. package/src/__tests__/api-server-security.test.ts +102 -0
  7. package/src/__tests__/belief-history.test.ts +220 -0
  8. package/src/__tests__/capture-engine.test.ts +93 -3
  9. package/src/__tests__/category-min-scores.test.ts +83 -0
  10. package/src/__tests__/cluster-consolidation.test.ts +167 -4
  11. package/src/__tests__/conflict-store.test.ts +46 -2
  12. package/src/__tests__/consolidation-engine.test.ts +97 -0
  13. package/src/__tests__/contracts.test.ts +46 -0
  14. package/src/__tests__/decay-exemption.test.ts +45 -0
  15. package/src/__tests__/dream-pipeline.test.ts +464 -5
  16. package/src/__tests__/freshness.test.ts +186 -0
  17. package/src/__tests__/hotness-blend.test.ts +207 -0
  18. package/src/__tests__/llm-client-pivot.test.ts +628 -0
  19. package/src/__tests__/maintenance-locks.test.ts +15 -0
  20. package/src/__tests__/memory-boundaries.test.ts +32 -0
  21. package/src/__tests__/noise-prototype-bank.test.ts +280 -0
  22. package/src/__tests__/pii-redact.test.ts +19 -0
  23. package/src/__tests__/pivot-apply.test.ts +373 -0
  24. package/src/__tests__/pivot-distill-supervisor.test.ts +457 -0
  25. package/src/__tests__/pivot-distill.test.ts +1178 -0
  26. package/src/__tests__/preference-matcher.test.ts +54 -2
  27. package/src/__tests__/pull-from-macbook-sync-only.test.ts +679 -0
  28. package/src/__tests__/recall-gate.test.ts +266 -0
  29. package/src/__tests__/resolve-db-path.test.ts +130 -0
  30. package/src/__tests__/resolve-tier.test.ts +64 -0
  31. package/src/__tests__/retrieval-eval.test.ts +163 -0
  32. package/src/__tests__/retriever-lazy-init-dispatch.test.ts +123 -0
  33. package/src/__tests__/skill-promotion.test.ts +53 -0
  34. package/src/__tests__/stable-context-length-contract.test.ts +62 -0
  35. package/src/__tests__/store-read-consistency.test.ts +122 -0
  36. package/src/__tests__/store-schema-migration.test.ts +87 -0
  37. package/src/__tests__/store-upsert.test.ts +17 -0
  38. package/src/__tests__/term-registry-terminal-roster.test.ts +127 -0
  39. package/src/__tests__/ui-server-security.test.ts +93 -0
  40. package/src/access-tracker.ts +53 -3
  41. package/src/adaptive-retrieval.ts +11 -0
  42. package/src/api-server.ts +9 -2
  43. package/src/auto-recall.ts +61 -1
  44. package/src/belief-history.ts +164 -0
  45. package/src/capture-engine.ts +88 -11
  46. package/src/cli.ts +109 -14
  47. package/src/conflict-engine.ts +24 -3
  48. package/src/consolidation-engine.ts +146 -12
  49. package/src/context-composer-stable-selection.ts +7 -1
  50. package/src/data-checkup.ts +1 -1
  51. package/src/decay-engine.ts +59 -7
  52. package/src/doctor.ts +4 -2
  53. package/src/dream-pipeline.ts +359 -11
  54. package/src/env-config.ts +41 -0
  55. package/src/eval.ts +154 -11
  56. package/src/freshness.ts +192 -0
  57. package/src/http-request-policy.ts +148 -0
  58. package/src/ingest.ts +277 -2
  59. package/src/kg-extractor.ts +7 -1
  60. package/src/llm-client.ts +106 -11
  61. package/src/mcp-server.ts +2 -1
  62. package/src/mcp-tools-advanced.ts +13 -0
  63. package/src/mcp-tools-core.ts +5 -1
  64. package/src/memory-boundaries.ts +23 -2
  65. package/src/memory-health-rebalance.ts +4 -0
  66. package/src/memory-output.ts +16 -2
  67. package/src/memory-store-port.ts +3 -0
  68. package/src/noise-prototype-bank.ts +408 -0
  69. package/src/pii-detector.ts +52 -2
  70. package/src/pivot-apply.ts +399 -0
  71. package/src/preference-matcher.ts +37 -3
  72. package/src/recall-gate.ts +305 -0
  73. package/src/retrieval-profiles.ts +46 -4
  74. package/src/retriever.ts +95 -18
  75. package/src/retroactive-boost.ts +6 -2
  76. package/src/runtime-config.ts +70 -5
  77. package/src/skill-promotion.ts +58 -2
  78. package/src/store.ts +54 -19
  79. package/src/term-registry.ts +35 -0
  80. package/src/ui-server.ts +7 -0
  81. package/src/version.ts +19 -0
@@ -2,11 +2,15 @@ import { describe, expect, it, beforeEach, beforeAll, afterAll } from "bun:test"
2
2
  import { mkdtempSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { runDream, formatDreamResult, type DreamResult } from "../dream-pipeline.js";
5
+ import {
6
+ runDream, formatDreamResult, formatDreamMetrics, assertDreamSweepHealth, classifyDreamOutput, countDreamEffects,
7
+ type DreamResult,
8
+ } from "../dream-pipeline.js";
9
+ import { isDerivedInsight } from "../consolidation-engine.js";
6
10
  import type { MemoryEntry, MemoryStore } from "../store.js";
7
11
  import type { LLMClient } from "../llm-client.js";
8
12
  import type { Embedder } from "../embedder.js";
9
- import { resetWriteCount } from "../activity-counter.js";
13
+ import { resetWriteCount, incrementWriteCount, getWriteCount, listScopesAboveThreshold } from "../activity-counter.js";
10
14
 
11
15
  // Isolate the activity-counter (default path = <dataDir>/activity-stats.json) to a temp
12
16
  // dir so these tests never read/write the repo's data/activity-stats.json that the
@@ -56,7 +60,18 @@ function createMockStore(entries: MemoryEntry[]): MemoryStore {
56
60
  let storeCounter = 0;
57
61
 
58
62
  return {
59
- async list() { return stored; },
63
+ // 忠实复刻生产行为:list() 恒返回 vector:[](性能优化的假空数组)。
64
+ // 旧 mock 直接返回带真向量的 stored,把「聚类消费者必须 getVectors 回填」
65
+ // 这个坑遮了两年——promote_scan 与 dream 3b 都在生产栽过、单测全绿。
66
+ async list() { return stored.map(e => ({ ...e, vector: [] as number[] })); },
67
+ async getVectors(ids: string[]) {
68
+ const m = new Map<string, number[]>();
69
+ for (const id of ids) {
70
+ const e = stored.find(x => x.id === id);
71
+ if (e && e.vector?.length) m.set(id, e.vector);
72
+ }
73
+ return m;
74
+ },
60
75
  async stats() {
61
76
  return {
62
77
  totalCount: stored.length,
@@ -285,6 +300,235 @@ describe("runDream", () => {
285
300
  expect(typeof result.stats.mergedCount).toBe("number");
286
301
  expect(typeof result.stats.archivedCount).toBe("number");
287
302
  });
303
+
304
+ // 2026-08-12 回归:dream 完整跑完后必须清掉该 scope 的写计数,否则它永远留在
305
+ // listScopesAboveThreshold 的队列里 —— 每轮从头重扫同一批最老的 scope,
306
+ // 6h 预算耗尽后排在后面的永远轮不到(生产日志:已处理 167/563,剩余 396 跳过)。
307
+ // 根因:dream-pipeline.ts 末尾 resetWriteCount() 漏传 scope,运行时 delete stats.scopes[undefined],
308
+ // 目标计数纹丝不动;早退路径 (:226) 传对了。源于 d269159(2026-07-02 per-scope 写计数重构)漏改一处,
309
+ // 该 commit message 自己写着「reset 在末尾清该 scope」——意图对、实现漏,活了 41 天。
310
+ // 由 codex + kimi 双路独立冷读查出(2026-08-12),互相隔离、收敛到同一行。
311
+ it("清空已处理 scope 的写计数,使它退出 dream 队列", async () => {
312
+ const statsPath = join(mkdtempSync(join(tmpdir(), "dream-reset-")), "activity-stats.json");
313
+ const statsCfg = { statsPath };
314
+
315
+ incrementWriteCount("project:test", 12, statsCfg);
316
+ expect(getWriteCount("project:test", statsCfg)).toBe(12);
317
+ expect(listScopesAboveThreshold(10, statsCfg)).toEqual(["project:test"]);
318
+
319
+ const entries = [
320
+ makeEntry({ id: "a", vector: [0.9, 0.1, 0, 0, 0] }),
321
+ makeEntry({ id: "b", vector: [0.88, 0.12, 0, 0, 0] }),
322
+ makeEntry({ id: "c", vector: [0.92, 0.08, 0, 0, 0] }),
323
+ ];
324
+ const result = await runDream({
325
+ store: createMockStore(entries),
326
+ llm: createMockLLM(),
327
+ embedder: createMockEmbedder(),
328
+ scope: "project:test",
329
+ activityStatsPath: statsPath,
330
+ });
331
+
332
+ // 走的是完整路径(不是 too-few-entries 早退),早退路径本来就传对了参数
333
+ expect(result.ran).toBe(true);
334
+ expect(result.reason).toBeUndefined();
335
+
336
+ expect(getWriteCount("project:test", statsCfg)).toBe(0);
337
+ expect(listScopesAboveThreshold(10, statsCfg)).toEqual([]);
338
+ });
339
+
340
+ it("excludes its own derivatives from the gather, so insights are not re-consolidated", async () => {
341
+ const derivedMeta = (flag: "cluster_insight" | "cross_memory_pattern") => JSON.stringify({
342
+ evolution: { status: "active", version: 1, sourceMemories: ["real-1", "real-2"] },
343
+ [flag]: true,
344
+ });
345
+
346
+ const store = createMockStore([
347
+ makeEntry({ id: "real-1" }),
348
+ makeEntry({ id: "real-2" }),
349
+ makeEntry({ id: "insight-1", metadata: derivedMeta("cluster_insight") }),
350
+ makeEntry({ id: "pattern-1", metadata: derivedMeta("cross_memory_pattern") }),
351
+ ]);
352
+
353
+ const result = await runDream({
354
+ store,
355
+ llm: createMockLLM(),
356
+ embedder: createMockEmbedder(),
357
+ scope: "project:test",
358
+ force: true,
359
+ });
360
+
361
+ expect(result.ran).toBe(true);
362
+ // All four stay visible to stats and usage observation...
363
+ expect(result.stats.activeMemories).toBe(4);
364
+ // ...but the insight and the pattern are derivatives of the other two and
365
+ // must not feed the next round of consolidation.
366
+ const gather = result.phases.find(p => p.phase === "gather");
367
+ expect(gather!.detail).toContain("4 active entries gathered from 4 total");
368
+ expect(gather!.detail).toContain("2 derivatives held back from consolidation");
369
+ });
370
+
371
+ it("does not hold anything back when there are no derivatives", async () => {
372
+ // Guard against the exclusion being too greedy: the metadata flags are what
373
+ // matter, not the words. A memory that merely talks about insights stays in.
374
+ const store = createMockStore([
375
+ makeEntry({ id: "real-1", text: "a note about cluster_insight and patterns" }),
376
+ makeEntry({ id: "real-2" }),
377
+ ]);
378
+
379
+ const result = await runDream({
380
+ store,
381
+ llm: createMockLLM(),
382
+ embedder: createMockEmbedder(),
383
+ scope: "project:test",
384
+ force: true,
385
+ });
386
+
387
+ expect(result.stats.activeMemories).toBe(2);
388
+ const gather = result.phases.find(p => p.phase === "gather");
389
+ expect(gather!.detail).not.toContain("held back");
390
+ });
391
+ });
392
+
393
+ // ---------------------------------------------------------------------------
394
+ // P1(2026-08-12):产出断言 —— 让 ok 只表示「确有副作用」
395
+ // ---------------------------------------------------------------------------
396
+
397
+ describe("dream 产出断言", () => {
398
+ beforeEach(() => {
399
+ resetWriteCount("project:test");
400
+ });
401
+
402
+ // 必红端到端:精确复现 2026-07-23 第二真凶的形状(store.list() 恒返回假空向量,
403
+ // getVectors 回填也拿不到东西 → 84/84 全被 clusterAndConsolidate 的
404
+ // `vector?.length > 0` 滤光 → semantic 簇恒 0 → LLM 从未被调 → 2716 次零 insight)。
405
+ // 在加 output 之前,这个场景返回的是一个看起来完全正常的成功结果 ——
406
+ // 这条测试就是把「跑完了」和「做成了」分开的那道断言。
407
+ it("向量管线断裂时不报成功,而是 noop + vector_pipeline_empty", async () => {
408
+ const entries = [
409
+ makeEntry({ id: "a", vector: [0.9, 0.1, 0, 0, 0] }),
410
+ makeEntry({ id: "b", vector: [0.88, 0.12, 0, 0, 0] }),
411
+ makeEntry({ id: "c", vector: [0.92, 0.08, 0, 0, 0] }),
412
+ ];
413
+ const healthy = createMockStore(entries);
414
+ // 唯一的改动:getVectors 返回空 Map(向量管线断裂),其余一切正常
415
+ const broken = { ...healthy, getVectors: async () => new Map<string, number[]>() };
416
+
417
+ const result = await runDream({
418
+ store: broken as unknown as MemoryStore,
419
+ llm: createMockLLM(),
420
+ embedder: createMockEmbedder(),
421
+ scope: "project:test",
422
+ force: true,
423
+ });
424
+
425
+ expect(result.ran).toBe(true); // 没崩 —— 这正是它以前能蒙混过关的原因
426
+ expect(result.stats.consolidatableCount).toBeGreaterThan(0);
427
+ expect(result.stats.semanticVectorFed).toBe(0);
428
+ // 注意断的是 degraded 而不是 kind:3a 确定性去重不需要向量,它照样合并、照样
429
+ // 产出副作用,effects > 0 会把 kind 顶成 produced —— 这条测试第一次跑时正是这样
430
+ // 红的,暴露出「一条路径的产出会掩盖另一条路径的断裂」,degraded 字段因此单列。
431
+ expect(result.output.degraded).toBe("vector_pipeline_empty");
432
+ expect(formatDreamResult(result)).toContain("DEGRADED");
433
+ });
434
+
435
+ it("向量管线正常时,同一批数据报 produced", async () => {
436
+ const entries = [
437
+ makeEntry({ id: "a", vector: [0.9, 0.1, 0, 0, 0] }),
438
+ makeEntry({ id: "b", vector: [0.88, 0.12, 0, 0, 0] }),
439
+ makeEntry({ id: "c", vector: [0.92, 0.08, 0, 0, 0] }),
440
+ ];
441
+ const result = await runDream({
442
+ store: createMockStore(entries),
443
+ llm: createMockLLM(),
444
+ embedder: createMockEmbedder(),
445
+ scope: "project:test",
446
+ force: true,
447
+ });
448
+
449
+ expect(result.stats.semanticVectorFed).toBe(result.stats.consolidatableCount);
450
+ expect(result.output.effectsWritten).toBeGreaterThan(0);
451
+ expect(result.output.kind).toBe("produced");
452
+ expect(result.output.degraded).toBeUndefined();
453
+ });
454
+ });
455
+
456
+ describe("assertDreamSweepHealth", () => {
457
+ it("没配 LLM = 结构性故障(3b 整段不跑,insight 恒为 0)", () => {
458
+ expect(assertDreamSweepHealth({ llmPresent: false, consolidatable: 10, vectorFed: 10 }))
459
+ .toEqual({ healthy: false, reason: "no_llm_configured" });
460
+ });
461
+
462
+ it("有素材可聚却一条带向量的都没有 = 向量管线断了", () => {
463
+ expect(assertDreamSweepHealth({ llmPresent: true, consolidatable: 84, vectorFed: 0 }))
464
+ .toEqual({ healthy: false, reason: "vector_pipeline_empty" });
465
+ });
466
+
467
+ it("素材本来就是 0 时不算故障(合法空转,不是断裂)", () => {
468
+ expect(assertDreamSweepHealth({ llmPresent: true, consolidatable: 0, vectorFed: 0 }))
469
+ .toEqual({ healthy: true });
470
+ });
471
+
472
+ it("不拿 insight 数量做判据 —— 那会是第三个阈值陷阱", () => {
473
+ // 有素材、向量齐、LLM 在位,但一条 insight 都没产出:数据真不成簇时这是合法的,
474
+ // 必须判 healthy。若这里返回 unhealthy,就等于偷偷设了「insight ≥ 1」的下限。
475
+ expect(assertDreamSweepHealth({ llmPresent: true, consolidatable: 40, vectorFed: 40 }))
476
+ .toEqual({ healthy: true });
477
+ });
478
+ });
479
+
480
+ describe("classifyDreamOutput", () => {
481
+ const healthy = { healthy: true } as const;
482
+
483
+ it("硬不变量:produced <=> effects > 0", () => {
484
+ expect(classifyDreamOutput({ consolidateRan: true, effects: 3, health: healthy }).kind).toBe("produced");
485
+ expect(classifyDreamOutput({ consolidateRan: true, effects: 0, health: healthy }).kind).toBe("noop");
486
+ });
487
+
488
+ it("健康的零产出叫 no_qualified_work(合法,不是失败)", () => {
489
+ expect(classifyDreamOutput({ consolidateRan: true, effects: 0, health: healthy }))
490
+ .toEqual({ kind: "noop", reason: "no_qualified_work", effectsWritten: 0 });
491
+ });
492
+
493
+ it("不健康的零产出带出具体故障原因", () => {
494
+ expect(classifyDreamOutput({
495
+ consolidateRan: true, effects: 0,
496
+ health: { healthy: false, reason: "vector_pipeline_empty" },
497
+ }).reason).toBe("vector_pipeline_empty");
498
+ });
499
+
500
+ it("有产出也可以是 degraded —— 3a 的产出不许掩盖 3b 的断裂", () => {
501
+ const out = classifyDreamOutput({
502
+ consolidateRan: true, effects: 5,
503
+ health: { healthy: false, reason: "vector_pipeline_empty" },
504
+ });
505
+ expect(out.kind).toBe("produced"); // 确实写入了东西,不撒谎
506
+ expect(out.degraded).toBe("vector_pipeline_empty"); // 但语义路径整段断了
507
+ });
508
+
509
+ it("必经阶段没跑 = partial,不能混进 noop", () => {
510
+ // partial 同时是「不要清写计数」的信号:这个 scope 并没有被真正处理完。
511
+ expect(classifyDreamOutput({ consolidateRan: false, effects: 0, health: healthy }))
512
+ .toEqual({ kind: "partial", reason: "consolidate_lock_held", effectsWritten: 0 });
513
+ });
514
+ });
515
+
516
+ describe("isDerivedInsight", () => {
517
+ it("flags cluster insights and cross-memory patterns", () => {
518
+ expect(isDerivedInsight(JSON.stringify({ cluster_insight: true }))).toBe(true);
519
+ expect(isDerivedInsight(JSON.stringify({ cross_memory_pattern: true }))).toBe(true);
520
+ });
521
+
522
+ it("leaves ordinary memories alone", () => {
523
+ expect(isDerivedInsight(JSON.stringify({ evolution: { status: "active" } }))).toBe(false);
524
+ expect(isDerivedInsight(JSON.stringify({ cluster_insight: false }))).toBe(false);
525
+ expect(isDerivedInsight("{}")).toBe(false);
526
+ });
527
+
528
+ it("treats missing or malformed metadata as not derived", () => {
529
+ expect(isDerivedInsight(undefined)).toBe(false);
530
+ expect(isDerivedInsight("{ not json")).toBe(false);
531
+ });
288
532
  });
289
533
 
290
534
  describe("formatDreamResult", () => {
@@ -292,8 +536,9 @@ describe("formatDreamResult", () => {
292
536
  const result: DreamResult = {
293
537
  ran: false,
294
538
  reason: "insufficient_writes (3/10)",
539
+ output: { kind: "skipped", reason: "insufficient_writes", effectsWritten: 0 },
295
540
  phases: [{ phase: "orient", detail: "50 memories, 3 writes" }],
296
- stats: { totalMemories: 50, activeMemories: 0, writesSinceLastDream: 3, clustersFound: 0, insightsGenerated: 0, patternsExtracted: 0, mergedCount: 0, archivedCount: 0 },
541
+ stats: { totalMemories: 50, activeMemories: 0, writesSinceLastDream: 3, clustersFound: 0, dedupeClustersFound: 0, semanticClustersFound: 0, insightsGenerated: 0, patternsExtracted: 0, mergedCount: 0, archivedCount: 0, relationsAdded: 0, consolidatableCount: 0, semanticVectorFed: 0 },
297
542
  };
298
543
  const output = formatDreamResult(result);
299
544
  expect(output).toContain("skipped");
@@ -309,7 +554,8 @@ describe("formatDreamResult", () => {
309
554
  { phase: "consolidate", detail: "3 clusters, 1 merged, 2 insights, 1 pattern" },
310
555
  { phase: "prune", detail: "5 entries archived" },
311
556
  ],
312
- stats: { totalMemories: 100, activeMemories: 80, writesSinceLastDream: 15, clustersFound: 3, insightsGenerated: 2, patternsExtracted: 1, mergedCount: 1, archivedCount: 5 },
557
+ output: { kind: "produced", effectsWritten: 9 },
558
+ stats: { totalMemories: 100, activeMemories: 80, writesSinceLastDream: 15, clustersFound: 3, dedupeClustersFound: 2, semanticClustersFound: 1, insightsGenerated: 2, patternsExtracted: 1, mergedCount: 1, archivedCount: 5, relationsAdded: 0, consolidatableCount: 80, semanticVectorFed: 80 },
313
559
  };
314
560
  const output = formatDreamResult(result);
315
561
  expect(output).toContain("Dream completed");
@@ -317,5 +563,218 @@ describe("formatDreamResult", () => {
317
563
  expect(output).toContain("[consolidate]");
318
564
  expect(output).toContain("[prune]");
319
565
  expect(output).toContain("Patterns: 1");
566
+ // 总数仍在,但两条路径要能分辨
567
+ expect(output).toContain("Clusters: 3 (dedupe 2 + semantic 1)");
568
+ });
569
+
570
+ it("区分「语义聚类一个都没凑出来」和「凑出来了但没产出 insight」", () => {
571
+ // 这两种情况在合并成一个 clusters 数字时长得一模一样,但含义完全相反:
572
+ // 前者是门槛太高压根没调 LLM,后者是调了 LLM 但没拿到结果。
573
+ // 2026-07 排查 insight 长期零产出时,正是被合并后的数字带偏了方向。
574
+ const base = {
575
+ ran: true as const,
576
+ phases: [],
577
+ output: { kind: "produced" as const, effectsWritten: 1 },
578
+ stats: {
579
+ totalMemories: 40, activeMemories: 40, writesSinceLastDream: 40,
580
+ insightsGenerated: 0, patternsExtracted: 0, mergedCount: 1, archivedCount: 0,
581
+ relationsAdded: 0, consolidatableCount: 40, semanticVectorFed: 40,
582
+ },
583
+ };
584
+
585
+ // A:dedupe 找到 2 个并合了 1 个,语义聚类一个合格簇都没有 —— LLM 根本没被调用
586
+ const noSemanticCluster: DreamResult = {
587
+ ...base,
588
+ stats: { ...base.stats, clustersFound: 2, dedupeClustersFound: 2, semanticClustersFound: 0 },
589
+ };
590
+ // B:语义聚类找到 2 个合格簇,却一条 insight 都没产出 —— 问题在 LLM 那一侧
591
+ const llmYieldedNothing: DreamResult = {
592
+ ...base,
593
+ stats: { ...base.stats, clustersFound: 2, dedupeClustersFound: 0, semanticClustersFound: 2 },
594
+ };
595
+
596
+ expect(formatDreamResult(noSemanticCluster)).toContain("semantic 0");
597
+ expect(formatDreamResult(llmYieldedNothing)).toContain("semantic 2");
598
+ // 两者的 clustersFound 相同,仅凭它无法区分——这正是拆开的理由
599
+ expect(noSemanticCluster.stats.clustersFound).toBe(llmYieldedNothing.stats.clustersFound);
600
+ expect(formatDreamResult(noSemanticCluster)).not.toBe(formatDreamResult(llmYieldedNothing));
601
+ });
602
+ });
603
+
604
+ // ---------------------------------------------------------------------------
605
+ // semanticClusterThreshold(2026-07-23 零 insight 根因修复)
606
+ // ---------------------------------------------------------------------------
607
+
608
+ import { DEFAULT_DREAM_CONFIG } from "../dream-pipeline.js";
609
+
610
+ describe("semanticClusterThreshold default", () => {
611
+ it("is an independent config at 0.68 (not clusterThreshold - 0.07)", () => {
612
+ // 三 scope 真实数据造影:相似度 p99≈0.74,旧 offset 值 0.75 卡在 p99 之上
613
+ // → semantic 簇恒 0、2716 次运行零 insight。0.68 落在实测甜点区。
614
+ expect(DEFAULT_DREAM_CONFIG.semanticClusterThreshold).toBe(0.68);
615
+ expect(DEFAULT_DREAM_CONFIG.semanticClusterThreshold).toBeLessThan(
616
+ DEFAULT_DREAM_CONFIG.clusterThreshold,
617
+ );
618
+ });
619
+ });
620
+
621
+ // ---------------------------------------------------------------------------
622
+ // --auto 失败分诊 + wall-clock 预算(2026-07-29 dream blocked 根因修复)
623
+ // ---------------------------------------------------------------------------
624
+
625
+ import { classifyDreamFailure, shouldBlockDreamRun, DREAM_TRANSIENT_BLOCK_RATIO, partitionAutoDreamScopes } from "../dream-pipeline.js";
626
+
627
+ describe("classifyDreamFailure", () => {
628
+ // 下面三条都是 dream-consolidation-launchd.log 里的生产原文,不是构造的样本。
629
+ it("treats the store-write lock contention as transient", () => {
630
+ expect(
631
+ classifyDreamFailure(
632
+ `Failed to store memory in "/Users/x/recallnest/data/lancedb": lock 'store-write' timed out after 10000ms (held by another process)`,
633
+ ),
634
+ ).toBe("transient");
635
+ });
636
+
637
+ it("treats the LLM abort as transient", () => {
638
+ // llm-client.ts:757 的 15s AbortController 超时
639
+ expect(classifyDreamFailure("Request was aborted.")).toBe("transient");
640
+ });
641
+
642
+ it("treats the null-vector TypeError as fatal", () => {
643
+ // 07-18~07-23 的真凶(store.ts 病行,d1a2a9c 已修)。这类必须一票否决——
644
+ // 判成 transient 会让代码缺陷被静默。
645
+ expect(
646
+ classifyDreamFailure("Array.from requires an array-like object - not null or undefined"),
647
+ ).toBe("fatal");
648
+ });
649
+
650
+ it("defaults unknown errors to fatal rather than silencing them", () => {
651
+ expect(classifyDreamFailure("something nobody has seen before")).toBe("fatal");
652
+ expect(classifyDreamFailure("")).toBe("fatal");
653
+ });
654
+
655
+ it("does not classify on scope ids that merely contain digits", () => {
656
+ // 回归:4 天 15 小时那次的根因是 shell 层在整个输出(含全部 hex scope id)上 grep
657
+ // "403",被 cc:44036269 假命中。分类器只看错误消息,绝不能被 id 里的数字带跑。
658
+ expect(classifyDreamFailure("[scope=cc:44036269] unexpected failure")).toBe("fatal");
659
+ });
660
+ });
661
+
662
+ describe("shouldBlockDreamRun", () => {
663
+ it("blocks on any fatal failure", () => {
664
+ expect(shouldBlockDreamRun({ totalScopes: 475, fatalFailures: 1, transientFailures: 0 })).toBe(true);
665
+ });
666
+
667
+ it("does not block on a handful of transient failures", () => {
668
+ // 旧语义下这就是「475 个里 1 个撞锁 → 整轮红 → 脚本重试 → 重跑全量」的起点。
669
+ expect(shouldBlockDreamRun({ totalScopes: 475, fatalFailures: 0, transientFailures: 5 })).toBe(false);
670
+ });
671
+
672
+ it("still blocks when transient failures are widespread", () => {
673
+ // 全被锁挡住不能判假绿。
674
+ const over = Math.ceil(475 * DREAM_TRANSIENT_BLOCK_RATIO) + 1;
675
+ expect(shouldBlockDreamRun({ totalScopes: 475, fatalFailures: 0, transientFailures: over })).toBe(true);
676
+ });
677
+
678
+ it("is clean when nothing failed, and safe on an empty sweep", () => {
679
+ expect(shouldBlockDreamRun({ totalScopes: 475, fatalFailures: 0, transientFailures: 0 })).toBe(false);
680
+ expect(shouldBlockDreamRun({ totalScopes: 0, fatalFailures: 0, transientFailures: 0 })).toBe(false);
681
+ });
682
+ });
683
+
684
+ describe("autoRunBudgetMs default", () => {
685
+ it("caps one --auto sweep well below a day", () => {
686
+ // 07-24 那轮 4 天 15 小时,堵死三天调度。预算是防跨天的兜底闸。
687
+ expect(DEFAULT_DREAM_CONFIG.autoRunBudgetMs).toBe(6 * 60 * 60 * 1000);
688
+ expect(DEFAULT_DREAM_CONFIG.autoRunBudgetMs).toBeLessThan(24 * 60 * 60 * 1000);
689
+ });
690
+ });
691
+
692
+ // ---------------------------------------------------------------------------
693
+ // 巨型 scope 移交专用 schedule(2026-07-30 预算被单个 scope 吃光的修复)
694
+ // ---------------------------------------------------------------------------
695
+
696
+ describe("autoExcludeScopes / partitionAutoDreamScopes", () => {
697
+ it("hands memory off by default", () => {
698
+ // 07-30 实测:6h 的 --auto 只跑完 15/507,而 memory 一个 scope 占 3075/3891 条(79%)。
699
+ // 它留在日常轮次里,等于每天拿整个预算巩固同一个 scope,靠后的 492 个永远排不到。
700
+ expect(DEFAULT_DREAM_CONFIG.autoExcludeScopes).toContain("memory");
701
+ });
702
+
703
+ it("splits the sweep list without reordering either side", () => {
704
+ // 顺序必须原样保留:调用方靠列表顺序决定谁先跑,排序策略不该藏在这个函数里。
705
+ const { run, deferred } = partitionAutoDreamScopes(
706
+ ["cc:82a919ea", "cc:f23f3893", "memory", "global", "cc:b11f3d48"],
707
+ ["memory"],
708
+ );
709
+ expect(run).toEqual(["cc:82a919ea", "cc:f23f3893", "global", "cc:b11f3d48"]);
710
+ expect(deferred).toEqual(["memory"]);
711
+ });
712
+
713
+ it("matches exactly, never by prefix", () => {
714
+ // 前缀规则会顺手吞掉将来的 memory-* / memory:* scope —— 那是静默失效:它们既不在
715
+ // 日常轮次里跑,也没有专用 job 负责,谁都不会发现。
716
+ const { run, deferred } = partitionAutoDreamScopes(
717
+ ["memory", "memory-archive", "memory:cc", "cc:memory"],
718
+ ["memory"],
719
+ );
720
+ expect(deferred).toEqual(["memory"]);
721
+ expect(run).toEqual(["memory-archive", "memory:cc", "cc:memory"]);
722
+ });
723
+
724
+ it("is a no-op when nothing is excluded", () => {
725
+ const scopes = ["cc:aaa", "global"];
726
+ const { run, deferred } = partitionAutoDreamScopes(scopes, []);
727
+ expect(run).toEqual(scopes);
728
+ expect(deferred).toEqual([]);
729
+ });
730
+
731
+ it("can defer every eligible scope, which is not the same as an empty sweep", () => {
732
+ // cli.ts 靠 eligible.length 区分「真没人达标」和「达标的全被挪走了」——两种空混成
733
+ // 一句话,排障会从第一步就跑偏。
734
+ const { run, deferred } = partitionAutoDreamScopes(["memory"], ["memory"]);
735
+ expect(run).toEqual([]);
736
+ expect(deferred).toEqual(["memory"]);
737
+ });
738
+ });
739
+
740
+ describe("formatDreamMetrics", () => {
741
+ // 这行有个进程外消费者:dream-consolidation.sh 的存在性闸按字面量匹配
742
+ // `[[DREAM_METRICS]]`,报了 ok 却没有它就判整轮失败。所以格式本身要锁住。
743
+ it("产出的前缀必须是 shell 闸认的字面量", () => {
744
+ const line = formatDreamMetrics({
745
+ tally: { produced: 0, noop: 0, partial: 0, skipped: 0 },
746
+ degraded: 0, effects: 0, processed: 0, total: 0,
747
+ });
748
+ expect(line.startsWith("[[DREAM_METRICS]] ")).toBe(true);
749
+ });
750
+
751
+ it("--auto 场景:六个字段与 processed/total 都如实带出", () => {
752
+ const line = formatDreamMetrics({
753
+ tally: { produced: 54, noop: 7, partial: 0, skipped: 0 },
754
+ degraded: 0, effects: 1142, processed: 61, total: 166,
755
+ });
756
+ expect(line).toBe(
757
+ "[[DREAM_METRICS]] produced=54 noop=7 partial=0 skipped=0 degraded=0 effects=1142 processed=61/166",
758
+ );
759
+ });
760
+
761
+ it("单 scope 场景:tally 只有一格是 1,processed 恒为 1/1", () => {
762
+ const line = formatDreamMetrics({
763
+ tally: { produced: 1, noop: 0, partial: 0, skipped: 0 },
764
+ degraded: 0, effects: 12, processed: 1, total: 1,
765
+ });
766
+ expect(line).toBe(
767
+ "[[DREAM_METRICS]] produced=1 noop=0 partial=0 skipped=0 degraded=0 effects=12 processed=1/1",
768
+ );
769
+ });
770
+
771
+ it("degraded 非零时照实报,不被 produced 掩盖", () => {
772
+ // DreamOutput.degraded 与 kind 正交:produced 也可以是 degraded 的。
773
+ const line = formatDreamMetrics({
774
+ tally: { produced: 1, noop: 0, partial: 0, skipped: 0 },
775
+ degraded: 1, effects: 3, processed: 1, total: 1,
776
+ });
777
+ expect(line).toContain("degraded=1");
778
+ expect(line).toContain("produced=1");
320
779
  });
321
780
  });