recallnest 2.5.4 → 2.6.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.
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 +6 -5
  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
@@ -5,9 +5,11 @@ import { extractErrorSignatures } from "./error-signature.js";
5
5
  import { verifyWrite } from "./write-verifier.js";
6
6
  // batchInternalDedup is available in ingest.ts for large-batch scenarios;
7
7
  // persistMemoryBatch relies on per-item conflict detection (A-2) instead.
8
- import { defaultEvolution, buildSupersedeMetadata, buildSupersedeMetadataForNew, buildPendingReviewMetadata, isActiveMemory, patchEvolution } from "./memory-evolution.js";
8
+ import { defaultEvolution, buildSupersedeMetadata, buildSupersedeMetadataForNew, buildPendingReviewMetadata, isActiveMemory, parseEvolution, patchEvolution } from "./memory-evolution.js";
9
+ import { archiveBeliefVersion, buildSupersedingBeliefMetadata } from "./belief-history.js";
9
10
  import { detectTopicTag, injectTopicTag } from "./topic-tag.js";
10
11
  import { assignDefaultConfidence, type ConfidenceMetadata } from "./confidence-tracker.js";
12
+ import { parseDependsOnInput } from "./freshness.js";
11
13
  import {
12
14
  type CaseMemoryInput,
13
15
  CaseMemoryInputSchema,
@@ -59,9 +61,10 @@ import type { KGExtractor } from "./kg-extractor.js";
59
61
  import { scanForPII, redactSecrets } from "./pii-detector.js";
60
62
  import type { AuditLogger } from "./audit-log.js";
61
63
  import { checkAdmission, type ScopeRateLimiter, type AdmissionConfig } from "./admission-control.js";
64
+ import { resolveNoisePrototypeBank, type NoisePrototypeBank } from "./noise-prototype-bank.js";
62
65
  import { tagNarrativeIfEnabled } from "./narrative-tagger.js";
63
66
 
64
- type StoreDeps = Pick<MemoryStore, "store"> & Partial<Pick<MemoryStore, "list" | "update" | "getById" | "get" | "vectorSearch">>;
67
+ type StoreDeps = Pick<MemoryStore, "store"> & Partial<Pick<MemoryStore, "list" | "update" | "getById" | "get" | "vectorSearch" | "upsert">>;
65
68
  type ConflictStoreDeps = Pick<ConflictCandidateStore, "save" | "replace" | "getOpenByFingerprint" | "getLatestByFingerprint">;
66
69
 
67
70
  export interface PersistMemoryDeps {
@@ -78,9 +81,15 @@ export interface PersistMemoryDeps {
78
81
  rateLimiter?: ScopeRateLimiter | null;
79
82
  /** LME-8: Optional admission config overrides */
80
83
  admissionConfig?: Partial<AdmissionConfig>;
84
+ /**
85
+ * Noise-prototype shadow experiment (mlp borrow). undefined → env-governed
86
+ * shared instance (RECALLNEST_NOISE_PROTOTYPE, shadow default); null → off.
87
+ * Shadow-only: it observes and logs, it never rejects.
88
+ */
89
+ noisePrototypeBank?: NoisePrototypeBank | null;
81
90
  }
82
91
 
83
- interface DurableWriteInput {
92
+ export interface DurableWriteInput {
84
93
  text: string;
85
94
  vector: number[];
86
95
  category: DurableMemoryCategory;
@@ -140,7 +149,7 @@ function mergeTags(...groups: string[][]): string[] {
140
149
  return merged.slice(0, 8);
141
150
  }
142
151
 
143
- function buildStructuredMetadata(params: {
152
+ export function buildStructuredMetadata(params: {
144
153
  source: string;
145
154
  tags: string[];
146
155
  capture: string;
@@ -437,11 +446,15 @@ async function findCanonicalMatches(
437
446
  return entries.filter((entry) => {
438
447
  const boundary = extractBoundaryMetadata(entry.metadata);
439
448
  if (boundary?.layer !== "durable") return false;
449
+ // Belief-history rows carry the same canonicalKey as the live row they were copied
450
+ // from. Without this guard they'd be treated as dedupe/conflict targets — so rewriting
451
+ // a belief back to an older wording would look like a duplicate, not a change.
452
+ if (!isActiveMemory(entry.metadata)) return false;
440
453
  return extractCanonicalKey(entry.metadata) === canonicalKey;
441
454
  });
442
455
  }
443
456
 
444
- async function writeDurableEntry(
457
+ export async function writeDurableEntry(
445
458
  deps: PersistMemoryDeps,
446
459
  params: DurableWriteInput,
447
460
  ): Promise<{
@@ -563,13 +576,26 @@ async function writeDurableEntry(
563
576
  }
564
577
  }
565
578
 
579
+ // latest-wins is where beliefs change. Archive the version being replaced first —
580
+ // overwriting in place used to delete the old text + vector outright, which is what
581
+ // made "what did I believe last month?" unanswerable no matter how retrieval queried.
582
+ const now = Date.now();
583
+ const previousEvo = parseEvolution(latest.metadata, latest.timestamp);
584
+ const { historyId } = await archiveBeliefVersion(
585
+ { store: deps.store, embedder: deps.embedder },
586
+ latest,
587
+ { now },
588
+ );
589
+
566
590
  const updated = await deps.store.update(latest.id, {
567
591
  text: params.text,
568
592
  vector: params.vector,
569
593
  importance: params.importance,
570
594
  category: params.category,
571
- metadata: params.metadata,
572
- timestamp: Date.now(),
595
+ // Merge rather than replace: a bare params.metadata would wipe the evolution block
596
+ // (version, access stats, supersede links) along with the text.
597
+ metadata: buildSupersedingBeliefMetadata(params.metadata, previousEvo, historyId, now),
598
+ timestamp: now,
573
599
  ...(params.language ? { language: params.language } : {}),
574
600
  ...(params.fts_text ? { fts_text: params.fts_text } : {}),
575
601
  });
@@ -582,16 +608,38 @@ async function writeDurableEntry(
582
608
  };
583
609
  }
584
610
 
611
+ // No canonical match was found, so this falls through to store(). With a canonicalKey the
612
+ // id is deterministic and store() upserts on id — so a row already sitting at that id is
613
+ // overwritten here without ever reaching the latest-wins branch above. findCanonicalMatches
614
+ // only scans the most recent CANONICAL_SCAN_LIMIT rows, so any canonical row older than that
615
+ // window lands on this path. Look it up by id directly, which no scan limit can hide.
616
+ const targetId = params.canonicalKey
617
+ ? deterministicId(params.scope, params.canonicalKey)
618
+ : undefined;
619
+ let metadataForStore = params.metadata;
620
+
621
+ if (targetId && deps.store.getById) {
622
+ const rowAtTargetId = await deps.store.getById(targetId);
623
+ if (rowAtTargetId && normalizeMemoryText(rowAtTargetId.text) !== normalizeMemoryText(params.text)) {
624
+ const now = Date.now();
625
+ const previousEvo = parseEvolution(rowAtTargetId.metadata, rowAtTargetId.timestamp);
626
+ const { historyId } = await archiveBeliefVersion(
627
+ { store: deps.store, embedder: deps.embedder },
628
+ rowAtTargetId,
629
+ { now },
630
+ );
631
+ metadataForStore = buildSupersedingBeliefMetadata(params.metadata, previousEvo, historyId, now);
632
+ }
633
+ }
634
+
585
635
  const stored = await deps.store.store({
586
- id: params.canonicalKey
587
- ? deterministicId(params.scope, params.canonicalKey)
588
- : undefined,
636
+ id: targetId,
589
637
  text: params.text,
590
638
  vector: params.vector,
591
639
  category: params.category,
592
640
  scope: params.scope,
593
641
  importance: params.importance,
594
- metadata: params.metadata,
642
+ metadata: metadataForStore,
595
643
  ...(params.language ? { language: params.language } : {}),
596
644
  ...(params.fts_text ? { fts_text: params.fts_text } : {}),
597
645
  });
@@ -833,6 +881,16 @@ export async function persistMemory(
833
881
  } catch {
834
882
  // Audit must never block memory writes
835
883
  }
884
+ // Noise-prototype shadow experiment: learn ONLY from the regex noise
885
+ // filter's own verdict (mlp #914 lesson — a heuristic's low-importance
886
+ // self-judgment must not be recycled as a noise label). Embedding only
887
+ // happens on a repeat sighting under the bank cap, so this stays cheap.
888
+ if (admission.reason === "noise_detected") {
889
+ const bank = resolveNoisePrototypeBank(deps.noisePrototypeBank);
890
+ if (bank) {
891
+ await bank.learnFromRejection(input.text, (text) => deps.embedder.embedPassage(text));
892
+ }
893
+ }
836
894
  return {
837
895
  id: `rejected-${Date.now()}`,
838
896
  text: input.text,
@@ -849,6 +907,15 @@ export async function persistMemory(
849
907
  }
850
908
 
851
909
  const vector = await deps.embedder.embedPassage(input.text);
910
+
911
+ // Noise-prototype shadow experiment, matching side: reuses the write-path
912
+ // embedding just computed (zero extra embedding). Log-only — the verdict
913
+ // never blocks this write while the experiment is in shadow.
914
+ {
915
+ const bank = resolveNoisePrototypeBank(deps.noisePrototypeBank);
916
+ bank?.matchShadow(input.text, vector);
917
+ }
918
+
852
919
  const language = detectLang(input.text);
853
920
  const fts_text = tokenizeFts(input.text, language);
854
921
  const resolvedScope = resolveScope(input);
@@ -876,6 +943,7 @@ export async function persistMemory(
876
943
  matchResult,
877
944
  deps.store as MemoryStore,
878
945
  resolvedScope,
946
+ { embedder: deps.embedder },
879
947
  );
880
948
  if (applied.handled) {
881
949
  const entry = applied.entry ?? {
@@ -1029,6 +1097,15 @@ export async function persistMemory(
1029
1097
  metadata = JSON.stringify(parsed);
1030
1098
  }
1031
1099
 
1100
+ // Freshness (borrowed 2026-07-21): opt-in dependsOn declarations ride in metadata like
1101
+ // validUntil/confidence do — pulled from raw input so StoreMemoryInputSchema stays untouched.
1102
+ const dependsOn = parseDependsOnInput(rawObj?.dependsOn);
1103
+ if (dependsOn) {
1104
+ const parsed: Record<string, unknown> = JSON.parse(metadata);
1105
+ parsed.dependsOn = dependsOn;
1106
+ metadata = JSON.stringify(parsed);
1107
+ }
1108
+
1032
1109
  const { entry, disposition, conflictId } = await writeDurableEntry(deps, {
1033
1110
  text: input.text,
1034
1111
  vector,
package/src/cli.ts CHANGED
@@ -15,12 +15,13 @@ import { basename, dirname, join, resolve } from "node:path";
15
15
  import { homedir } from "node:os";
16
16
 
17
17
  import { metaDir } from "./compat.js";
18
+ import { PACKAGE_VERSION } from "./version.js";
18
19
  import type { RetrievalResult } from "./retriever.js";
19
20
  import { applyRetrievalProfile, listRetrievalProfiles } from "./retrieval-profiles.js";
20
21
  import { distillResults, formatExplainResults, formatSearchResults, selectBriefSeedResults, summarizeResults } from "./memory-output.js";
21
22
  import { archiveDirtyBriefAsset, assetSummaryLine, buildBriefAsset, buildPinAsset, getExportsDir, listDirtyBriefAssets, listMemoryAssets, listPinAssets, pinSummaryLine, saveBriefAsset, savePinAsset, writeExportArtifact } from "./memory-assets.js";
22
23
  import { indexAsset, indexPinnedAsset } from "./asset-sync.js";
23
- import { createComponents, createStoreOnly, expandHome, loadConfig, loadDotEnv, type LocalMemoryConfig } from "./runtime-config.js";
24
+ import { createComponents, createStoreOnly, expandHome, loadConfig, loadDotEnv, resolveDbPath, type LocalMemoryConfig } from "./runtime-config.js";
24
25
  import { KGStore } from "./kg-store.js";
25
26
  import { isKGModeEnabled } from "./kg-extractor.js";
26
27
  import {
@@ -30,6 +31,7 @@ import {
30
31
  ingestCCTranscripts,
31
32
  ingestCodexSessions,
32
33
  ingestGeminiSessions,
34
+ ingestKimiSessions,
33
35
  ingestMarkdownFiles,
34
36
  ingestObsidianVault,
35
37
  ingestConnectorFile,
@@ -37,7 +39,7 @@ import {
37
39
  import { runDoctor, formatDoctorResults } from "./doctor.js";
38
40
  import { persistCaseMemory, persistMemory, persistWorkflowPattern } from "./capture-engine.js";
39
41
  import { scanMemoryPromotions, buildPromoteScanDeps, formatPromoteScanResult } from "./memory-promotion.js";
40
- import { runDream, formatDreamResult, DEFAULT_DREAM_CONFIG } from "./dream-pipeline.js";
42
+ import { runDream, formatDreamResult, formatDreamMetrics, DEFAULT_DREAM_CONFIG, classifyDreamFailure, shouldBlockDreamRun, partitionAutoDreamScopes } from "./dream-pipeline.js";
41
43
  import { listScopesAboveThreshold } from "./activity-counter.js";
42
44
  import {
43
45
  CaseMemoryInputSchema,
@@ -153,7 +155,7 @@ const program = new Command();
153
155
  program
154
156
  .name("recallnest")
155
157
  .description("本地优先 AI 对话记忆搜索与蒸馏层")
156
- .version("1.4.0");
158
+ .version(PACKAGE_VERSION);
157
159
 
158
160
  async function runRetrievalView(
159
161
  view: "search" | "explain" | "distill",
@@ -1358,7 +1360,7 @@ program
1358
1360
  program
1359
1361
  .command("ingest")
1360
1362
  .description("导入对话记录到索引")
1361
- .option("-s, --source <source>", "数据源: cc / codex / memory / desktop / all", "all")
1363
+ .option("-s, --source <source>", "数据源: cc / codex / gemini / kimi / memory / desktop / all", "all")
1362
1364
  .option("-l, --limit <n>", "限制处理文件数(调试用)")
1363
1365
  .option("-v, --verbose", "详细输出")
1364
1366
  .option("--no-dedup", "跳过向量去重")
@@ -1462,6 +1464,14 @@ program
1462
1464
  console.log(` ✅ Codex: ${formatIngestSummary(r)}`);
1463
1465
  }
1464
1466
 
1467
+ // Kimi Code Sessions (wire.jsonl 事件流,~/.kimi-code/sessions/)
1468
+ if (source === "all" || source === "kimi") {
1469
+ console.log("🌙 导入 Kimi Code 对话...");
1470
+ const r = await ingestKimiSessions(store, embedder, ingestOpts);
1471
+ results.push(r);
1472
+ console.log(` ✅ Kimi: ${formatIngestSummary(r)}`);
1473
+ }
1474
+
1465
1475
  // Gemini Sessions (JSON format under ~/.gemini/tmp/*/chats/)
1466
1476
  if (source === "all" || source === "gemini") {
1467
1477
  console.log("💎 导入 Gemini 对话...");
@@ -1589,26 +1599,98 @@ program
1589
1599
  const kgStore = isKGModeEnabled() ? new KGStore({ dbPath: store.dbPath }) : null;
1590
1600
 
1591
1601
  if (options.auto) {
1592
- const scopes = listScopesAboveThreshold(DEFAULT_DREAM_CONFIG.minWritesForDream, { statsPath: activityStatsPath });
1602
+ const eligible = listScopesAboveThreshold(DEFAULT_DREAM_CONFIG.minWritesForDream, { statsPath: activityStatsPath });
1603
+ // 巨型 scope 交给专用 schedule,不占日常轮次的 wall-clock 预算(理由见
1604
+ // DreamConfig.autoExcludeScopes:07-30 那轮 6h 只跑了 15/507,而 memory 独占 79% 的条数)。
1605
+ const { run: scopes, deferred } = partitionAutoDreamScopes(eligible, DEFAULT_DREAM_CONFIG.autoExcludeScopes);
1606
+ if (deferred.length > 0) {
1607
+ // 不静默:被挪走的 scope 必须在日志里看得见,否则"今天怎么没跑 memory"只能靠翻代码回答。
1608
+ // 前缀刻意用 `[dream]` 而不是 `dream --auto:`——后者是 dream-checkup.sh 抓「达标 scope 数」
1609
+ // 的锚点,多一行同前缀的会混进它的取数。
1610
+ console.log(`[dream] ${deferred.length} 个 scope 交给专用 schedule,本轮不跑 → ${deferred.join(", ")}`);
1611
+ }
1593
1612
  if (scopes.length === 0) {
1594
- console.log("dream --auto: 无达标 scope(写计数均低于门槛)");
1613
+ // 两种空:一种是真没人达标,一种是达标的全被挪走了——混成一句会让排障从这里开始跑偏。
1614
+ console.log(
1615
+ eligible.length === 0
1616
+ ? "dream --auto: 无达标 scope(写计数均低于门槛)"
1617
+ : "dream --auto: 达标 scope 全部交给专用 schedule,本轮无事可做",
1618
+ );
1595
1619
  console.log("[[DREAM_STATUS]] skip");
1596
1620
  process.exit(0);
1597
1621
  }
1598
1622
  console.log(`dream --auto: ${scopes.length} 个达标 scope → ${scopes.join(", ")}`);
1599
- let anyFailed = false;
1623
+
1624
+ // Wall-clock 预算(2026-07-29 补):防单轮跑成跨天。07-24 那轮跑了 4 天 15 小时,
1625
+ // 把 07-25~27 三天的调度全堵死(上一次没结束,launchd 起不了新的)。
1626
+ // 天花板见 DreamConfig.autoRunBudgetMs 的注释——截断会让靠后 scope 系统性饿死,
1627
+ // 真解是 session scope TTL,不是把预算调大。
1628
+ const budgetMs = Number(process.env.RECALLNEST_DREAM_BUDGET_MS ?? DEFAULT_DREAM_CONFIG.autoRunBudgetMs);
1629
+ const deadline = Date.now() + budgetMs;
1630
+
1631
+ let fatalFailures = 0;
1632
+ let transientFailures = 0;
1633
+ let processed = 0;
1634
+ // 2026-08-12 产出计量:`[[DREAM_STATUS]] ok` 只证明「没崩」,不证明「产出了东西」。
1635
+ // 2026-07-23 查出的语义阈值卡在真实 p99 之上,就是这样静默跑了 2716 次零 insight
1636
+ // 而无人察觉 —— 当时日志里每一轮都是 ok。
1637
+ const tally = { produced: 0, noop: 0, partial: 0, skipped: 0 };
1638
+ let effectsTotal = 0;
1639
+ const degradedScopes: string[] = [];
1600
1640
  for (const scope of scopes) {
1641
+ if (Date.now() > deadline) {
1642
+ console.warn(
1643
+ `[dream] wall-clock 预算 ${Math.round(budgetMs / 60_000)} 分钟耗尽:已处理 ` +
1644
+ `${processed}/${scopes.length},剩余 ${scopes.length - processed} 个 scope 本轮跳过`,
1645
+ );
1646
+ break;
1647
+ }
1648
+ processed++;
1601
1649
  try {
1602
1650
  const result = await runDream({ store, llm, embedder, scope, force: Boolean(options.force), activityStatsPath, kgStore });
1651
+ tally[result.output.kind]++;
1652
+ effectsTotal += result.output.effectsWritten;
1653
+ if (result.output.degraded) degradedScopes.push(`${scope}=${result.output.degraded}`);
1603
1654
  console.log(`[scope=${scope}] ${formatDreamResult(result)}`);
1604
1655
  } catch (err) {
1605
- anyFailed = true;
1606
- // 单 scope 失败不阻断其他 scope
1607
- console.error(`[scope=${scope}] dream failed: ${err instanceof Error ? err.message : String(err)}`);
1656
+ // 单 scope 失败不阻断其他 scope。
1657
+ // 打完整 stack 而不只是 message:像「Array.from requires an array-like object」
1658
+ // 这种原生错误,只有 message 时根本定位不到是哪一行抛的——2026-07-18/19 连着两天
1659
+ // 因此 blocked,回头查日志除了这一句什么线索都没有,只能靠猜。
1660
+ // 分类基于 message(不是 stack):stack 里的文件路径不该参与判定。
1661
+ const message = err instanceof Error ? err.message : String(err);
1662
+ if (classifyDreamFailure(message) === "transient") transientFailures++;
1663
+ else fatalFailures++;
1664
+ console.error(`[scope=${scope}] dream failed: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
1608
1665
  }
1609
1666
  }
1610
- console.log(anyFailed ? "[[DREAM_STATUS]] blocked" : "[[DREAM_STATUS]] ok");
1611
- process.exit(anyFailed ? 1 : 0);
1667
+
1668
+ // 旧语义是「任一 scope 抛异常即 blocked」,在 475 个 scope 的规模下等于「有一个撞上
1669
+ // store-write 锁就整轮红」,而整轮红会让 shell 脚本重试 → 重跑全量。改为:代码/数据
1670
+ // 缺陷一票否决,环境性失败要成规模才算整轮失败。比例按已处理数算,不按总数。
1671
+ // 结构性降级一票否决:它不是「今天数据不成簇」(那是合法的 noop),而是某条路径
1672
+ // 整段断了 —— 向量管线空、或压根没配 LLM。判据只认确定性零值,不含任何阈值,
1673
+ // 所以不会因为数据分布变化而误伤(详见 assertDreamSweepHealth 的注释)。
1674
+ const structurallyBroken = degradedScopes.length > 0;
1675
+ if (structurallyBroken) {
1676
+ console.error(`[dream] ⚠ 结构性降级 ${degradedScopes.length} 个 scope:${degradedScopes.join(", ")}`);
1677
+ }
1678
+ const blocked = shouldBlockDreamRun({ totalScopes: processed, fatalFailures, transientFailures })
1679
+ || structurallyBroken;
1680
+ if (fatalFailures > 0 || transientFailures > 0) {
1681
+ console.log(`[dream] 失败统计:fatal=${fatalFailures} transient=${transientFailures}(已处理 ${processed})`);
1682
+ }
1683
+ // 这一行是 dream-consolidation.sh 存在性闸的锚点:STATUS=ok 却没有它 = 判失败。
1684
+ // 它防的是「断言本身被某次重构悄悄删掉」—— 一个不需要调参的自检。
1685
+ console.log(formatDreamMetrics({
1686
+ tally,
1687
+ degraded: degradedScopes.length,
1688
+ effects: effectsTotal,
1689
+ processed,
1690
+ total: scopes.length,
1691
+ }));
1692
+ console.log(blocked ? "[[DREAM_STATUS]] blocked" : "[[DREAM_STATUS]] ok");
1693
+ process.exit(blocked ? 1 : 0);
1612
1694
  }
1613
1695
 
1614
1696
  try {
@@ -1623,10 +1705,23 @@ program
1623
1705
  });
1624
1706
 
1625
1707
  console.log(formatDreamResult(result));
1708
+ // 单 scope 也必须打产出计量:dream-consolidation.sh 的存在性闸对 STATUS=ok
1709
+ // 一视同仁地要求这行,而本分支此前从不打 —— 08-12 那道闸上线后,周日的
1710
+ // memory 轮次即使跑成功也会被判"产出断言缺失"。tally 在这里只有一格是 1。
1711
+ const tally = { produced: 0, noop: 0, partial: 0, skipped: 0 };
1712
+ tally[result.output.kind] = 1;
1713
+ console.log(formatDreamMetrics({
1714
+ tally,
1715
+ degraded: result.output.degraded ? 1 : 0,
1716
+ effects: result.output.effectsWritten,
1717
+ processed: 1,
1718
+ total: 1,
1719
+ }));
1626
1720
  console.log(result.ran ? "[[DREAM_STATUS]] ok" : "[[DREAM_STATUS]] skip");
1627
1721
  process.exit(0);
1628
1722
  } catch (err) {
1629
- console.error(`dream failed: ${err instanceof Error ? err.message : String(err)}`);
1723
+ // 同 --auto 分支:带 stack,否则原生错误无从定位。
1724
+ console.error(`dream failed: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
1630
1725
  console.log("[[DREAM_STATUS]] blocked");
1631
1726
  process.exit(1);
1632
1727
  }
@@ -1741,7 +1836,7 @@ program
1741
1836
  }
1742
1837
 
1743
1838
  const config = loadConfig();
1744
- const dbPath = resolve(metaDir(import.meta), "..", expandHome(config.dbPath));
1839
+ const dbPath = resolveDbPath(config);
1745
1840
 
1746
1841
  if (existsSync(dbPath)) {
1747
1842
  const { rmSync } = await import("node:fs");
@@ -9,6 +9,8 @@ import {
9
9
  parseMetadataObject,
10
10
  } from "./memory-boundaries.js";
11
11
  import type { MemoryStore } from "./store.js";
12
+ import { archiveBeliefVersion, buildSupersedingBeliefMetadata } from "./belief-history.js";
13
+ import { parseEvolution } from "./memory-evolution.js";
12
14
  import {
13
15
  type ConflictCandidateInput,
14
16
  ConflictCandidateInputSchema,
@@ -95,7 +97,8 @@ function buildMergedConflictMetadata(
95
97
  }
96
98
 
97
99
  export interface ResolveConflictDeps {
98
- store: Pick<MemoryStore, "getById" | "update">;
100
+ // `store`/`upsert` are needed to archive the belief being replaced before overwriting it.
101
+ store: Pick<MemoryStore, "getById" | "update" | "store"> & Partial<Pick<MemoryStore, "upsert">>;
99
102
  embedder: Pick<Embedder, "embedPassage">;
100
103
  conflictStore: Pick<ConflictCandidateStore, "getById" | "replace">;
101
104
  }
@@ -144,13 +147,31 @@ export async function resolveConflictCandidate(
144
147
  }
145
148
 
146
149
  const vector = await deps.embedder.embedPassage(nextText);
150
+
151
+ // Conflict resolution is the most deliberate belief change there is — accepting the
152
+ // incoming version or merging both used to overwrite the existing row in place, losing
153
+ // precisely the "before" side of the decision. Archive it first.
154
+ const resolvedAtMs = Date.now();
155
+ const previousEvo = parseEvolution(existing.metadata, existing.timestamp);
156
+ const { historyId } = await archiveBeliefVersion(
157
+ { store: deps.store, embedder: deps.embedder },
158
+ existing,
159
+ { now: resolvedAtMs },
160
+ );
161
+
147
162
  const updated = await deps.store.update(existing.id, {
148
163
  text: nextText,
149
164
  vector,
150
165
  importance: nextImportance,
151
166
  category: nextCategory,
152
- metadata: nextMetadata,
153
- timestamp: Date.now(),
167
+ metadata: buildSupersedingBeliefMetadata(
168
+ nextMetadata,
169
+ previousEvo,
170
+ historyId,
171
+ resolvedAtMs,
172
+ `conflict:${input.resolution}`,
173
+ ),
174
+ timestamp: resolvedAtMs,
154
175
  });
155
176
  if (!updated) {
156
177
  throw new Error(`Failed to update durable memory ${existing.id}`);
@@ -25,6 +25,7 @@ import { isActiveMemory, parseEvolution, buildSupersedeMetadata, buildConsolidat
25
25
  import { cosineSimilarity } from "./multi-vector.js";
26
26
  import type { LLMClient } from "./llm-client.js";
27
27
  import type { Embedder } from "./embedder.js";
28
+ import { logWarn } from "./stderr-log.js";
28
29
 
29
30
  // ---------------------------------------------------------------------------
30
31
  // Config & Types
@@ -224,8 +225,18 @@ export class ConsolidationEngine {
224
225
  full.vector, 10, clusterThreshold, [scope],
225
226
  );
226
227
 
228
+ // Seeds are active-only (above), but vectorSearch filters by scope alone, so
229
+ // superseded belief-history rows surface here too. A rephrased belief sits well
230
+ // above mergeThreshold from its own archived version, and canonicalScore has no
231
+ // recency term — importance × access count are both inherited by the history row,
232
+ // so the two tie and the "weaker" one is picked arbitrarily. Lose that coin flip
233
+ // and the live belief gets marked consolidated, dropping out of default retrieval
234
+ // while the abandoned version stands in as canonical.
227
235
  const members = similar.filter(
228
- s => s.entry.id !== entry.id && !clustered.has(s.entry.id) && s.entry.category === entry.category,
236
+ s => s.entry.id !== entry.id
237
+ && !clustered.has(s.entry.id)
238
+ && s.entry.category === entry.category
239
+ && isActive(s.entry),
229
240
  );
230
241
 
231
242
  if (members.length === 0) continue;
@@ -354,6 +365,14 @@ export interface ClusterConsolidationResult {
354
365
  entriesLinked: number;
355
366
  /** CC-9: Set when consecutive low-yield rounds trigger early termination */
356
367
  earlyStop?: "diminishing_returns";
368
+ /**
369
+ * 本轮被吸收的 LLM 调用失败数(超时 / 网络 / 熔断抛出)。
370
+ *
371
+ * 计出来是为了让「吸收」不等于「静默」:吸收前,单个 cluster 撞 15s 超时会把整个
372
+ * scope 的 dream 炸掉(2026-08-09 memory 轮次连挂两次的根因);吸收后若不计数,
373
+ * 「LLM 全程不可用」和「今天没料可炼」会长得一模一样。
374
+ */
375
+ llmFailures: number;
357
376
  }
358
377
 
359
378
  /**
@@ -398,6 +417,7 @@ export async function clusterAndConsolidate(params: {
398
417
  insightsGenerated: 0,
399
418
  patternsExtracted: 0,
400
419
  entriesLinked: 0,
420
+ llmFailures: 0,
401
421
  };
402
422
 
403
423
  // Filter to active entries with vectors
@@ -462,8 +482,26 @@ export async function clusterAndConsolidate(params: {
462
482
  }
463
483
 
464
484
  // Generate insight via LLM from cluster member texts
485
+ //
486
+ // 单个 cluster 的 LLM 失败不该炸掉整个 scope。llm-client 的 chat() 在 catch 里是
487
+ // `throw err`(记完熔断计数后原样抛),所以 15s AbortController 超时会一路冒到
488
+ // runDream 之外 —— 2026-08-09 memory 轮次连挂两次正是这条路径:memory 是最大
489
+ // scope、cluster 最多,撞上一次超时的概率接近 1,且两次挂的位置不同(不是某对
490
+ // 内容有毒,是这一阶段本来就没有防护)。隔壁 llm-consolidation.ts:68 的同类调用
491
+ // 一直有 try/catch,本处缺的是同一层保护。
492
+ //
493
+ // 吸收成 null 而不是新开一条分支:generateL0 返回 null(熔断打开 / 空响应)本来
494
+ // 就是合法路径,下面 `if (!insight)` 已经按「零产出轮次」处理。失败另计 llmFailures,
495
+ // 避免吸收变静默。
465
496
  const combinedText = cluster.map(m => m.text).join("\n---\n");
466
- const insight = await llm.generateL0(combinedText);
497
+ let insight: string | null;
498
+ try {
499
+ insight = await llm.generateL0(combinedText);
500
+ } catch (err) {
501
+ result.llmFailures++;
502
+ logWarn(`[WARN] cluster insight LLM 调用失败,本簇跳过: ${err instanceof Error ? err.message : String(err)}`);
503
+ insight = null;
504
+ }
467
505
 
468
506
  if (!insight) {
469
507
  // CC-9: No insight produced — this is a zero-yield round
@@ -536,9 +574,24 @@ export async function clusterAndConsolidate(params: {
536
574
 
537
575
  // HP-5: Cross-memory pattern extraction
538
576
  if (extractPatterns && cluster.length >= 3) {
539
- const patternText = await llm.extractPattern(cluster.map(m => m.text));
577
+ // 同上:pattern 抽取失败也只丢这一簇的 pattern,不连坐已经写好的 insight。
578
+ let patternText: string | null;
579
+ try {
580
+ patternText = await llm.extractPattern(cluster.map(m => m.text));
581
+ } catch (err) {
582
+ result.llmFailures++;
583
+ logWarn(`[WARN] pattern 抽取 LLM 调用失败,本簇跳过: ${err instanceof Error ? err.message : String(err)}`);
584
+ patternText = null;
585
+ }
540
586
  if (patternText) {
541
- const patternImportance = Math.min(maxImportance + 0.1, 1.0);
587
+ // 与同函数上方的 cluster insight 取同一个值:派生物继承源里最高的 importance,
588
+ // 不额外加成。原本这里是 maxImportance + 0.1,会让 LLM 抽出的 pattern 比它的
589
+ // 任何一条源记忆都重要——而 importance >= 0.95 在本仓库里是「人工 pin」的专用
590
+ // 语义(cli.ts pin 操作把值抬到 0.95;auto-gc 视其为永不归档、decay-engine 视其
591
+ // 为永不衰减)。于是任何一条源 >= 0.85 的 cluster,其派生 pattern 都会自动跨进
592
+ // 人类锚点频段。加成想表达的「pattern 比源更有价值」若要保留,应走独立通道
593
+ // (检索 boost / 独立 tier / 显式 flag),不该占用这个频段。
594
+ const patternImportance = maxImportance;
542
595
  const patternVector = await params.embedder.embedPassage(patternText);
543
596
  const patternEntry = await store.store({
544
597
  id: deterministicId(scope, `cluster-pattern:${sortedSourceIds.join(",")}`),
@@ -607,6 +660,89 @@ export function formatConsolidationResult(result: ConsolidationResult): string {
607
660
  // LC-P2: Cluster-aware deduplication for retrieval results
608
661
  // ---------------------------------------------------------------------------
609
662
 
663
+ /**
664
+ * Whether an entry is a consolidation derivative — a cluster insight or a
665
+ * cross-memory pattern the LLM wrote from other entries, rather than something
666
+ * captured from the outside world.
667
+ *
668
+ * Single source of truth for the two metadata flags: retrieval uses it to
669
+ * collapse a derivative with its sources, and the dream gather uses it to keep
670
+ * derivatives from being re-consolidated as if they were raw material.
671
+ */
672
+ export function isDerivedInsight(metadata: string | undefined): boolean {
673
+ if (!metadata) return false;
674
+ try {
675
+ const meta = JSON.parse(metadata) as Record<string, unknown>;
676
+ return meta.cluster_insight === true || meta.cross_memory_pattern === true;
677
+ } catch {
678
+ return false;
679
+ }
680
+ }
681
+
682
+ // ---------------------------------------------------------------------------
683
+ // Synthesis uptake (Artel archivist_metrics.synthesis_uptake_rate 的对应物)
684
+ // ---------------------------------------------------------------------------
685
+
686
+ export interface SynthesisUptakeStats {
687
+ /** Entries scanned across the store (may be capped). */
688
+ scanned: number;
689
+ /** Derived insights found (cluster_insight / cross_memory_pattern). */
690
+ derivedTotal: number;
691
+ /** Derived insights with accessCount > 0 — actually read after synthesis. */
692
+ derivedRead: number;
693
+ /** derivedRead / derivedTotal; null when no derived insights exist. */
694
+ uptakeRate: number | null;
695
+ /** True when the scan hit the cap before exhausting the store. */
696
+ truncated: boolean;
697
+ }
698
+
699
+ /**
700
+ * Measure how many consolidation/dream products were ever read back.
701
+ * 把"升华产物没人用"从体感变成能报警的数字——uptake 长期为 0 说明
702
+ * 升华管线在产出无人消费的内容(或读打点断链)。
703
+ */
704
+ export async function computeSynthesisUptake(
705
+ store: { listPage(opts: { limit?: number; offset?: number; includeVector?: boolean }): Promise<MemoryEntry[]> },
706
+ // 50K 覆盖当前 ~34K 生产库全量(2026-07-23 实测 cap 20K 只扫 57%,truncated 结论不完整);
707
+ // metadata-only 分页扫描秒级,memory_stats 是低频人工工具,扫全比抽样值钱。
708
+ scanCap = 50_000,
709
+ pageSize = 1_000,
710
+ ): Promise<SynthesisUptakeStats> {
711
+ let scanned = 0;
712
+ let derivedTotal = 0;
713
+ let derivedRead = 0;
714
+ let offset = 0;
715
+ let truncated = false;
716
+
717
+ for (;;) {
718
+ const page = await store.listPage({ limit: pageSize, offset, includeVector: false });
719
+ if (page.length === 0) break;
720
+ for (const e of page) {
721
+ scanned++;
722
+ if (!isDerivedInsight(e.metadata)) continue;
723
+ derivedTotal++;
724
+ try {
725
+ const meta = JSON.parse(e.metadata || "{}") as Record<string, unknown>;
726
+ if (typeof meta.accessCount === "number" && meta.accessCount > 0) derivedRead++;
727
+ } catch { /* broken metadata → counts as unread */ }
728
+ }
729
+ offset += page.length;
730
+ if (page.length < pageSize) break;
731
+ if (offset >= scanCap) {
732
+ truncated = true;
733
+ break;
734
+ }
735
+ }
736
+
737
+ return {
738
+ scanned,
739
+ derivedTotal,
740
+ derivedRead,
741
+ uptakeRate: derivedTotal > 0 ? derivedRead / derivedTotal : null,
742
+ truncated,
743
+ };
744
+ }
745
+
610
746
  /**
611
747
  * LC-P2: When a cluster insight and its source memories both appear in
612
748
  * retrieval results, keep only the cluster insight (it subsumes the
@@ -621,15 +757,13 @@ export function deduplicateByClusterInsight<T extends { entry: MemoryEntry; scor
621
757
  const coveredByInsight = new Set<string>();
622
758
 
623
759
  for (const r of results) {
624
- if (!r.entry.metadata) continue;
760
+ if (!isDerivedInsight(r.entry.metadata)) continue;
625
761
  try {
626
- const meta = JSON.parse(r.entry.metadata);
627
- if (meta.cluster_insight === true || meta.cross_memory_pattern === true) {
628
- const sources: unknown[] = meta.evolution?.sourceMemories;
629
- if (Array.isArray(sources)) {
630
- for (const id of sources) {
631
- if (typeof id === "string") coveredByInsight.add(id);
632
- }
762
+ const meta = JSON.parse(r.entry.metadata as string);
763
+ const sources: unknown[] = meta.evolution?.sourceMemories;
764
+ if (Array.isArray(sources)) {
765
+ for (const id of sources) {
766
+ if (typeof id === "string") coveredByInsight.add(id);
633
767
  }
634
768
  }
635
769
  } catch { /* skip unparseable */ }