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
@@ -17,7 +17,7 @@
17
17
  import type { MemoryStorePort } from "./memory-store-port.js";
18
18
  import type { LLMClient } from "./llm-client.js";
19
19
  import type { Embedder } from "./embedder.js";
20
- import { ConsolidationEngine, clusterAndConsolidate, DEFAULT_CONSOLIDATION_CONFIG, type ConsolidationKGSource } from "./consolidation-engine.js";
20
+ import { ConsolidationEngine, clusterAndConsolidate, DEFAULT_CONSOLIDATION_CONFIG, isDerivedInsight, type ConsolidationKGSource } from "./consolidation-engine.js";
21
21
  import { maybeRunGc, type GcResult, type AutoGcConfig, DEFAULT_AUTO_GC_CONFIG } from "./auto-gc.js";
22
22
  import { getWriteCount, resetWriteCount } from "./activity-counter.js";
23
23
  import { deriveUsageStatus, getUsageMetadata, isUsageSignalActive } from "./usage-tracker.js";
@@ -33,12 +33,38 @@ export interface DreamConfig {
33
33
  minWritesForDream: number;
34
34
  /** Consolidation cluster threshold (default: 0.82) */
35
35
  clusterThreshold: number;
36
+ /** 3b semantic clustering threshold (default: 0.68). 独立于 clusterThreshold——
37
+ * 旧实现用魔法 offset(clusterThreshold - 0.07 = 0.75),2026-07-23 三 scope
38
+ * 真实数据造影:相似度 p99 = 0.736/0.739/0.986,0.75 卡在 p99 之上,semantic
39
+ * 簇恒 0、LLM 从未被调、2716 次运行零 insight。0.68 落在甜点区
40
+ * (实测每 scope 产 3~7 个 ≥3 簇);产物质量由 synthesis uptake 指标观察。 */
41
+ semanticClusterThreshold: number;
36
42
  /** Minimum cluster size for insight/pattern generation (default: 3) */
37
43
  minClusterSize: number;
38
44
  /** Enable cross-memory pattern extraction in consolidation (default: true) */
39
45
  extractPatterns: boolean;
40
46
  /** Max entries to scan per consolidation run (default: 500) */
41
47
  maxEntriesPerRun: number;
48
+ /** Wall-clock budget for one `dream --auto` sweep, ms (default: 6h).
49
+ * 防跨天兜底闸,不是调度器。2026-07-24 那轮跑了 4 天 15 小时(见 cli.ts --auto 分支
50
+ * 与 scripts/dream-consolidation.sh:57 的注释),期间堵死三天调度。
51
+ * 已知天花板:超预算后剩余 scope 直接丢弃,而每轮都从列表头重来 → 靠后的 scope 会被
52
+ * 系统性饿死。真解是 session scope TTL(只 dream 近 N 天活跃的),不是把预算调大——
53
+ * scope 数 07-10 是 133、07-29 是 475,只增不减,加预算追不上。 */
54
+ autoRunBudgetMs: number;
55
+ /** Scopes the `--auto` sweep hands off to a dedicated schedule instead of running inline.
56
+ * Exact match only — a prefix rule would silently swallow future `memory-*` scopes.
57
+ *
58
+ * 2026-07-30 实测的动机:那轮 6 小时只处理了 15/507 个 scope,而这 15 个的规模是
59
+ * 118/37/15/190/3075/84/136/76/14/31/39/15/10/6/55 —— `memory` 一个 scope 独占
60
+ * 3075 条(全部 3891 条的 79%),其余都是几十条的小 scope。配合上面那条「每轮都从
61
+ * 列表头重来」,等于每天花掉整个预算巩固同一个巨型 scope,靠后的 492 个永远排不到。
62
+ * 把它挪进 com.recallnest.dream-memory-weekly(周日 12:00,走 DREAM_SCOPE=memory)
63
+ * 后,日常轮次的预算全部留给小 scope。
64
+ *
65
+ * 这是缓解不是根治:scope 只增不减仍在(07-10 是 133、07-29 是 475、07-30 是 507),
66
+ * 真解仍是 session scope TTL —— 只 dream 近 N 天活跃的。 */
67
+ autoExcludeScopes: readonly string[];
42
68
  /** GC config for prune phase */
43
69
  gc: AutoGcConfig;
44
70
  }
@@ -46,9 +72,12 @@ export interface DreamConfig {
46
72
  export const DEFAULT_DREAM_CONFIG: DreamConfig = {
47
73
  minWritesForDream: 10,
48
74
  clusterThreshold: 0.82,
75
+ semanticClusterThreshold: 0.68,
49
76
  minClusterSize: 3,
50
77
  extractPatterns: true,
51
78
  maxEntriesPerRun: 500,
79
+ autoRunBudgetMs: 6 * 60 * 60 * 1000,
80
+ autoExcludeScopes: ["memory"],
52
81
  gc: DEFAULT_AUTO_GC_CONFIG,
53
82
  };
54
83
 
@@ -57,19 +86,78 @@ export interface DreamPhaseResult {
57
86
  detail: string;
58
87
  }
59
88
 
89
+ /**
90
+ * 一轮 dream 的产出归类(2026-08-12 加)。
91
+ *
92
+ * 存在的理由:在此之前 dream 只报 `status=ok`,而 ok 只证明「没崩」,不证明
93
+ * 「产出了东西」。后台阶段的失败形态是「什么都没发生」,它和「跑完了但确实没
94
+ * 东西可做」在日志里长得一模一样 —— 2026-07-23 查出的语义阈值卡在 p99 之上,
95
+ * 就是这样静默跑了 2716 次零 insight 而无人察觉。
96
+ *
97
+ * 刻意**不**做的事:不设「insight 数 ≥ N」这类闸门。数据真不成簇时零产出是
98
+ * 合法的,给它设下限等于造第三个阈值陷阱(前两个是 0.75 语义阈值和 0.35
99
+ * novelty 门,都因为阈值落在真实数据分布之外而静默归零)。这里只区分
100
+ * 「确有副作用」和「合法空转」,两者都不需要任何可调参数。
101
+ */
102
+ export type DreamOutputKind =
103
+ /** 至少写入了一个真实的生命周期副作用(合并/关联/insight/pattern/归档) */
104
+ | "produced"
105
+ /** 四阶段完整跑过,但确实没有值得写入的结果 —— 合法状态,不是失败 */
106
+ | "noop"
107
+ /** 必经阶段没跑(如 consolidate 锁被占),本轮结论不完整 */
108
+ | "partial"
109
+ /** 压根没进管线(写计数不足 / dream 锁被占) */
110
+ | "skipped";
111
+
112
+ export interface DreamOutput {
113
+ kind: DreamOutputKind;
114
+ /** 为什么不是 produced;produced 时省略 */
115
+ reason?: string;
116
+ /** 本轮真实写入的副作用总数。硬不变量:kind === "produced" <=> effectsWritten > 0 */
117
+ effectsWritten: number;
118
+ /**
119
+ * 结构性健康降级 —— **与 kind 正交,不要合并**。
120
+ *
121
+ * 2026-08-12 实测教训:3a 确定性去重不需要向量,3b 语义聚类需要。向量管线整段
122
+ * 断裂时 3a 照样合并、照样产出副作用,于是 effects > 0 把 kind 顶成了 produced,
123
+ * **一条路径的产出掩盖了另一条路径的完全断裂**。这是"跑完了 ≠ 做成了"的变体:
124
+ * 部分做成了,比全都没做更容易蒙混过关。
125
+ *
126
+ * 所以健康必须单列:produced 也可以是 degraded 的。
127
+ */
128
+ degraded?: "no_llm_configured" | "vector_pipeline_empty";
129
+ }
130
+
60
131
  export interface DreamResult {
61
132
  ran: boolean;
62
133
  reason?: string;
63
134
  phases: DreamPhaseResult[];
135
+ /** 本轮到底产出了什么(2026-08-12 加,见 DreamOutputKind 注释) */
136
+ output: DreamOutput;
64
137
  stats: {
65
138
  totalMemories: number;
66
139
  activeMemories: number;
67
140
  writesSinceLastDream: number;
141
+ /** 两条聚类路径的总和,保持向后兼容;排障要看下面拆开的两个。 */
68
142
  clustersFound: number;
143
+ /** 3a 确定性去重找到的簇(不需要 LLM)。 */
144
+ dedupeClustersFound: number;
145
+ /** 3b 语义聚类里达到 minClusterSize 的簇——只有这些会去调 LLM 生成 insight。 */
146
+ semanticClustersFound: number;
69
147
  insightsGenerated: number;
70
148
  patternsExtracted: number;
71
149
  mergedCount: number;
72
150
  archivedCount: number;
151
+ /** 3a 去重路径新建的关联数。此前引擎返回了但被 dream 丢弃,导致「只加了关联」
152
+ * 的一轮会被误判成零产出。 */
153
+ relationsAdded: number;
154
+ /** gather 之后可参与合并的条目数(已排除 dream 自己的派生 insight)。 */
155
+ consolidatableCount: number;
156
+ /** 真正带着非空向量喂进 3b 语义聚类的条目数。
157
+ * 它与 consolidatableCount 的落差就是向量管线的健康读数:
158
+ * consolidatable > 0 而 vectorFed === 0 是结构性故障、不可能是合法的
159
+ * ——2026-07-23 那次 store.list() 恒返回假空向量、84/84 全被滤光,就是这个形状。 */
160
+ semanticVectorFed: number;
73
161
  };
74
162
  }
75
163
 
@@ -100,10 +188,15 @@ async function runDreamInner(params: {
100
188
  activeMemories: 0,
101
189
  writesSinceLastDream: 0,
102
190
  clustersFound: 0,
191
+ dedupeClustersFound: 0,
192
+ semanticClustersFound: 0,
103
193
  insightsGenerated: 0,
104
194
  patternsExtracted: 0,
105
195
  mergedCount: 0,
106
196
  archivedCount: 0,
197
+ relationsAdded: 0,
198
+ consolidatableCount: 0,
199
+ semanticVectorFed: 0,
107
200
  };
108
201
 
109
202
  // =========================================================================
@@ -119,6 +212,7 @@ async function runDreamInner(params: {
119
212
  return {
120
213
  ran: false,
121
214
  reason: `insufficient_writes (${writeCount}/${config.minWritesForDream})`,
215
+ output: { kind: "skipped", reason: "insufficient_writes", effectsWritten: 0 },
122
216
  phases: [{
123
217
  phase: "orient",
124
218
  detail: `${stats.totalMemories} memories, ${writeCount} writes since last dream — below threshold`,
@@ -139,6 +233,18 @@ async function runDreamInner(params: {
139
233
  const active = entries.filter(e => isActiveMemory(e.metadata));
140
234
  stats.activeMemories = active.length;
141
235
 
236
+ // Consolidation derivatives are kept out of the *input* to the next round.
237
+ // A cluster insight is written by the LLM from other entries, so feeding it
238
+ // back as raw material means the next run summarises a summary, and each
239
+ // generation drifts further from what was actually captured with nothing
240
+ // anchoring it back. Their sources stay eligible, so real material still gets
241
+ // re-consolidated as it accumulates.
242
+ //
243
+ // They stay in `active` on purpose: stats and the usage snapshot below are
244
+ // observational, and derivatives are exactly what we want more visibility
245
+ // into, not less. Only consolidation skips them.
246
+ const consolidatable = active.filter(e => !isDerivedInsight(e.metadata));
247
+
142
248
  // P0 B-1 观察:离线持久化 usageStatus 快照。写入路径不写 status(写入时
143
249
  // useCount 必 >0,永远写不出 cold),所以 cold 只能由离线批量 derive 产生。
144
250
  // 持久化值仅供 data_checkup/RIF 等观察视图——任何决策路径必须现场
@@ -164,17 +270,27 @@ async function runDreamInner(params: {
164
270
  } catch { /* observation snapshot must never block dream */ }
165
271
  }
166
272
 
273
+ stats.consolidatableCount = consolidatable.length;
274
+ const derivedCount = active.length - consolidatable.length;
167
275
  phases.push({
168
276
  phase: "gather",
169
277
  detail: `${active.length} active entries gathered from ${entries.length} total`
278
+ + (derivedCount > 0 ? `; ${derivedCount} derivatives held back from consolidation` : "")
170
279
  + (usageSnapshots > 0 ? `; usage snapshot: ${usageSnapshots} statuses persisted` : ""),
171
280
  });
172
281
 
173
- if (active.length < config.minClusterSize) {
282
+ if (consolidatable.length < config.minClusterSize) {
174
283
  phases.push({ phase: "consolidate", detail: "skipped — too few active entries" });
175
284
  phases.push({ phase: "prune", detail: "skipped — too few entries for GC" });
176
285
  resetWriteCount(scope, statsCfg);
177
- return { ran: true, reason: "completed_early", phases, stats };
286
+ return {
287
+ ran: true,
288
+ reason: "completed_early",
289
+ // 素材不足是合法的空转,不是失败 —— 这正是「不设 insight 数下限」的理由所在。
290
+ output: { kind: "noop", reason: "too_few_active_entries", effectsWritten: 0 },
291
+ phases,
292
+ stats,
293
+ };
178
294
  }
179
295
 
180
296
  // =========================================================================
@@ -193,21 +309,33 @@ async function runDreamInner(params: {
193
309
  maxEntriesPerRun: config.maxEntriesPerRun,
194
310
  }, params.kgStore ?? null);
195
311
  const consolidation = await engine.run(scope);
312
+ stats.dedupeClustersFound += consolidation.clustersFound;
196
313
  stats.clustersFound += consolidation.clustersFound;
197
314
  stats.mergedCount += consolidation.mergedCount;
315
+ stats.relationsAdded += consolidation.relationsAdded;
198
316
 
199
317
  // 3b: LLM-driven cluster consolidation (insights + patterns) — requires LLM
200
318
  if (llm) {
319
+ // gather 走 store.list(),其性能优化恒返回 vector:[](假空数组)——
320
+ // clusterAndConsolidate 进门就按 vector?.length>0 过滤,不回填则 84/84 全被
321
+ // 滤光、聚类空转(2026-07-23 生产实锤:门槛修对后 semantic 仍恒 0 的第二真凶;
322
+ // promote_scan 同款坑 07-21 已修,dream 这条漏了)。
323
+ const vectorMap = await store.getVectors(consolidatable.map(e => e.id));
324
+ const withVectors = consolidatable.map(e => ({ ...e, vector: vectorMap.get(e.id) ?? [] }));
325
+ // 回填之后仍为空 = 向量管线断了(见 semanticVectorFed 字段注释)。这里只记数,
326
+ // 判定留给 assertDreamSweepHealth,保持本函数无分支。
327
+ stats.semanticVectorFed = withVectors.filter(e => e.vector.length > 0).length;
201
328
  const clusterResult = await clusterAndConsolidate({
202
- entries: active,
329
+ entries: withVectors,
203
330
  embedder,
204
331
  llm,
205
332
  store,
206
333
  scope,
207
334
  minClusterSize: config.minClusterSize,
208
- clusterThreshold: config.clusterThreshold - 0.07, // slightly lower for semantic clustering
335
+ clusterThreshold: config.semanticClusterThreshold,
209
336
  extractPatterns: config.extractPatterns,
210
337
  });
338
+ stats.semanticClustersFound += clusterResult.clustersFound;
211
339
  stats.clustersFound += clusterResult.clustersFound;
212
340
  stats.insightsGenerated = clusterResult.insightsGenerated;
213
341
  stats.patternsExtracted = clusterResult.patternsExtracted;
@@ -217,7 +345,12 @@ async function runDreamInner(params: {
217
345
  phases.push({
218
346
  phase: "consolidate",
219
347
  detail: consolidateOutcome.ran
220
- ? `${stats.clustersFound} clusters, ${stats.mergedCount} merged, ${stats.insightsGenerated} insights, ${stats.patternsExtracted} patterns`
348
+ // 两条路径分开报:dedupe 那条不需要 LLM,semantic 那条才会去调 LLM 生成 insight。
349
+ // 合成一个数字的话,「semantic 一个合格簇都没凑出来」和「凑出来了但 LLM 没产出」
350
+ // 长得一模一样——2026-07 排查时正是被这个加总带偏的。
351
+ ? `${stats.dedupeClustersFound} dedupe-clusters (${stats.mergedCount} merged), `
352
+ + `${stats.semanticClustersFound} semantic-clusters -> `
353
+ + `${stats.insightsGenerated} insights, ${stats.patternsExtracted} patterns`
221
354
  : `skipped — another process holds the consolidate lock for scope ${scope}`,
222
355
  });
223
356
 
@@ -234,10 +367,37 @@ async function runDreamInner(params: {
234
367
  : `skipped — ${gcResult.reason}`,
235
368
  });
236
369
 
237
- // Reset activity counter after successful dream
238
- resetWriteCount();
370
+ // Reset activity counter after successful dream.
371
+ //
372
+ // 只在 consolidate 真跑过时才清:锁被占时(onBusy: "skip")这个 scope 并没有被真正
373
+ // 处理完,计数必须留着等下一轮,否则它会被当成"已处理"而丢掉本轮积累的写入。
374
+ //
375
+ // 2026-08-12 修复:此处原为无参 `resetWriteCount()`,而签名 (activity-counter.ts:110)
376
+ // 要求 scope 必填 —— 运行时不报错,只是静默执行 `delete stats.scopes[undefined]`,
377
+ // 目标 scope 的计数纹丝不动。后果:成功处理的 scope 永远不出队,达标数只增不减
378
+ // (生产实测 85 → 107 → 135 → 563 → 572),每轮从队首重扫同一批最老的 scope,
379
+ // 6h 预算耗尽后排在后面的永远轮不到(日志:已处理 167/563,剩余 396 本轮跳过)。
380
+ // 早退路径 (:226) 一直是对的。源于 d269159(2026-07-02 per-scope 写计数重构)漏改一处,
381
+ // 该 commit message 自己写着「reset 在末尾清该 scope(含 dream 自写→防永动)」——
382
+ // 意图正确、实现漏了一个参数,活了 41 天。codex + kimi 双路独立冷读同时查出。
383
+ //
384
+ // 注意:清对之后,listScopesAboveThreshold 的 FIFO 自组织才真正开始工作 ——
385
+ // 处理完的 scope 被 delete、下次攒够写入重新插入时排到队尾,被跳过的带原计数留在
386
+ // 队首下轮优先。所以这里不需要额外的游标 / checkpoint 文件。
387
+ if (consolidateOutcome.ran) resetWriteCount(scope, statsCfg);
388
+
389
+ const health = assertDreamSweepHealth({
390
+ llmPresent: llm != null,
391
+ consolidatable: stats.consolidatableCount,
392
+ vectorFed: stats.semanticVectorFed,
393
+ });
394
+ const output = classifyDreamOutput({
395
+ consolidateRan: consolidateOutcome.ran,
396
+ effects: countDreamEffects(stats),
397
+ health,
398
+ });
239
399
 
240
- return { ran: true, phases, stats };
400
+ return { ran: true, output, phases, stats };
241
401
  }
242
402
 
243
403
  /**
@@ -269,20 +429,193 @@ export async function runDream(params: {
269
429
  return {
270
430
  ran: false,
271
431
  reason: "locked_by_another_process",
432
+ output: { kind: "skipped", reason: "dream_lock_held", effectsWritten: 0 },
272
433
  phases: [{ phase: "orient", detail: `another process holds the dream lock for scope ${params.scope}` }],
273
434
  stats: {
274
435
  totalMemories: 0,
275
436
  activeMemories: 0,
276
437
  writesSinceLastDream: 0,
277
438
  clustersFound: 0,
439
+ dedupeClustersFound: 0,
440
+ semanticClustersFound: 0,
278
441
  insightsGenerated: 0,
279
442
  patternsExtracted: 0,
280
443
  mergedCount: 0,
281
444
  archivedCount: 0,
445
+ relationsAdded: 0,
446
+ consolidatableCount: 0,
447
+ semanticVectorFed: 0,
282
448
  },
283
449
  };
284
450
  }
285
451
 
452
+ // ---------------------------------------------------------------------------
453
+ // Output classification (2026-08-12)
454
+ // ---------------------------------------------------------------------------
455
+
456
+ export interface DreamHealthVerdict {
457
+ healthy: boolean;
458
+ /** 不健康时的机器可读原因;健康时省略 */
459
+ reason?: "no_llm_configured" | "vector_pipeline_empty";
460
+ }
461
+
462
+ /**
463
+ * 结构性健康断言 —— **只认两种确定性零值**,零歧义、免调参、误伤面≈0。
464
+ *
465
+ * 为什么是这两个而不是「insight 数 < N」:这个库已经被阈值坑过两次
466
+ * (0.75 语义聚类阈值卡在真实 p99 之上;0.35 novelty 门把最相关的命中全排除),
467
+ * 共同形状都是**断言条件依赖数据分布**,分布一偏就静默归零。下面两条都不依赖
468
+ * 任何分布,它们描述的是结构断裂,不是数据稀疏:
469
+ *
470
+ * 1. `llmPresent === false` —— 没配 LLM 时 3b 整段不跑,insight 恒为 0。
471
+ * 这是确定性的配置缺陷,不是「今天数据不成簇」。
472
+ * 2. `consolidatable > 0 但 vectorFed === 0` —— 有素材可聚,却一条带向量的都没有。
473
+ * dream 在调用前刚做完 getVectors 回填,回填后还全空只有一种解释:向量管线断了。
474
+ * 这正是 2026-07-23 第二真凶(store.list() 恒返回假空向量)的复发通道。
475
+ *
476
+ * 刻意留在门外的:`maxPairSim < threshold`(能结构性判定「阈值又落在分布之上」)
477
+ * 只适合当观察字段,不适合当闸门 —— 数据真分散时它会天天告警,而告警疲劳比没有
478
+ * 告警更糟。
479
+ */
480
+ export function assertDreamSweepHealth(input: {
481
+ llmPresent: boolean;
482
+ consolidatable: number;
483
+ vectorFed: number;
484
+ }): DreamHealthVerdict {
485
+ if (!input.llmPresent) return { healthy: false, reason: "no_llm_configured" };
486
+ if (input.consolidatable > 0 && input.vectorFed === 0) {
487
+ return { healthy: false, reason: "vector_pipeline_empty" };
488
+ }
489
+ return { healthy: true };
490
+ }
491
+
492
+ /** 本轮真实写入的生命周期副作用总数。五项都是「库里确实多/少了东西」,
493
+ * 不含任何只读统计 —— 这是 produced/noop 之分的唯一依据。 */
494
+ export function countDreamEffects(stats: DreamResult["stats"]): number {
495
+ return stats.mergedCount
496
+ + stats.relationsAdded
497
+ + stats.insightsGenerated
498
+ + stats.patternsExtracted
499
+ + stats.archivedCount;
500
+ }
501
+
502
+ /**
503
+ * 产出计量行的唯一格式来源。
504
+ *
505
+ * 抽出来是因为它有一个**进程外的消费者**:`dream-consolidation.sh` 的存在性闸靠
506
+ * 字面量 `[[DREAM_METRICS]]` 匹配,报了 ok 却没有这行就判整轮失败。两条 CLI 路径
507
+ * (`--auto` 与单 scope)各写一份格式,迟早漂;漂了的那条会被闸当成"断言缺失"误杀。
508
+ *
509
+ * 2026-08-13 补单 scope 路径时发现:`--scope memory` 分支**从来没打过这行**,
510
+ * 而闸是 08-12 加的 —— 也就是说 08-16 周日的 memory 轮次即使跑成功也会被判失败。
511
+ * 这是那道闸的第一个真实误伤(它自称"误伤面≈0",成立的前提是所有 ok 路径都打这行)。
512
+ */
513
+ export function formatDreamMetrics(input: {
514
+ tally: Record<DreamOutputKind, number>;
515
+ degraded: number;
516
+ effects: number;
517
+ processed: number;
518
+ total: number;
519
+ }): string {
520
+ return `[[DREAM_METRICS]] produced=${input.tally.produced} noop=${input.tally.noop} `
521
+ + `partial=${input.tally.partial} skipped=${input.tally.skipped} `
522
+ + `degraded=${input.degraded} effects=${input.effects} `
523
+ + `processed=${input.processed}/${input.total}`;
524
+ }
525
+
526
+ /** 把一轮 dream 归类。硬不变量:`kind === "produced"` <=> `effectsWritten > 0`。 */
527
+ export function classifyDreamOutput(input: {
528
+ consolidateRan: boolean;
529
+ effects: number;
530
+ health: DreamHealthVerdict;
531
+ }): DreamOutput {
532
+ // 必经阶段没跑完,本轮结论不完整 —— 不能算 noop(那会掩盖「没跑」和「跑了没东西」的区别),
533
+ // 也不能算 produced。partial 同时是「不要清写计数」的信号。
534
+ // degraded 独立于 kind 计算:结构断裂不该被另一条路径的产出掩盖(见 DreamOutput.degraded)。
535
+ const degraded = input.health.healthy ? undefined : input.health.reason;
536
+
537
+ if (!input.consolidateRan) {
538
+ return { kind: "partial", reason: "consolidate_lock_held", effectsWritten: input.effects, degraded };
539
+ }
540
+ if (input.effects > 0) return { kind: "produced", effectsWritten: input.effects, degraded };
541
+ return {
542
+ kind: "noop",
543
+ // 健康的零产出叫 no_qualified_work(合法);不健康的零产出直接把故障原因摆在 reason 上。
544
+ reason: degraded ?? "no_qualified_work",
545
+ effectsWritten: 0,
546
+ degraded,
547
+ };
548
+ }
549
+
550
+ // ---------------------------------------------------------------------------
551
+ // Failure triage (--auto sweep)
552
+ // ---------------------------------------------------------------------------
553
+
554
+ export type DreamFailureKind = "transient" | "fatal";
555
+
556
+ /** 已知的环境性失败——重跑同一轮救不了,也不代表代码/数据有缺陷。
557
+ * 故意用精确 pattern 而非宽泛关键词:分类错成 transient 会静默真 bug,
558
+ * 错成 fatal 只是多一次告警。拿不准就让它落到 fatal。 */
559
+ const TRANSIENT_FAILURE_PATTERNS: readonly RegExp[] = [
560
+ /lock '[^']*' timed out/i, // distill-lock.ts 跨进程 store-write 锁竞争
561
+ /request was aborted/i, // llm-client.ts:757 的 15s AbortController 超时
562
+ /econnreset|etimedout|socket hang up|fetch failed/i, // 网络层
563
+ ];
564
+
565
+ /**
566
+ * 把单个 scope 的 dream 失败分成「环境性瞬态」和「代码/数据缺陷」。
567
+ *
568
+ * 2026-07-29 生产分诊依据(dream-consolidation-launchd.log):
569
+ * fatal — `TypeError: Array.from requires an array-like object`(store.ts 病行,d1a2a9c 已修)
570
+ * transient — `lock 'store-write' timed out after 10000ms (held by another process)`
571
+ * transient — `Error: Request was aborted.`
572
+ */
573
+ export function classifyDreamFailure(message: string): DreamFailureKind {
574
+ return TRANSIENT_FAILURE_PATTERNS.some(re => re.test(message)) ? "transient" : "fatal";
575
+ }
576
+
577
+ /** transient 失败占比超过这个比例仍判整轮失败——否则"475 个全被锁挡住"会假绿。 */
578
+ export const DREAM_TRANSIENT_BLOCK_RATIO = 0.2;
579
+
580
+ /**
581
+ * 一轮 `dream --auto` 是否该判 blocked。
582
+ *
583
+ * 旧语义是「任意一个 scope 抛异常即 blocked」,在 475 个 scope 的规模下等于
584
+ * 「有一个撞上锁就整轮红」,而红了会让 shell 脚本走重试 → 重跑全量。
585
+ * 新语义:代码/数据缺陷一票否决;环境性失败要成规模才算整轮失败。
586
+ */
587
+ export function shouldBlockDreamRun(params: {
588
+ totalScopes: number;
589
+ fatalFailures: number;
590
+ transientFailures: number;
591
+ }): boolean {
592
+ if (params.fatalFailures > 0) return true;
593
+ if (params.totalScopes <= 0) return false;
594
+ return params.transientFailures / params.totalScopes > DREAM_TRANSIENT_BLOCK_RATIO;
595
+ }
596
+
597
+ /**
598
+ * 把 `--auto` 的达标 scope 列表拆成「本轮跑」和「交给专用 schedule」两份。
599
+ *
600
+ * 精确匹配,不做前缀——前缀规则会连带吞掉将来可能出现的 `memory-*`。
601
+ * 保持原顺序:调用方靠列表顺序决定谁先跑,排序策略的改动不该藏进这个函数。
602
+ *
603
+ * 用处见 DreamConfig.autoExcludeScopes 的注释:2026-07-30 实测 `memory` 一个 scope
604
+ * 独占 79% 的记忆条数,把它挪走后日常轮次的 6h 预算才轮得到靠后的小 scope。
605
+ */
606
+ export function partitionAutoDreamScopes(
607
+ scopes: readonly string[],
608
+ excluded: readonly string[],
609
+ ): { run: string[]; deferred: string[] } {
610
+ const excludeSet = new Set(excluded);
611
+ const run: string[] = [];
612
+ const deferred: string[] = [];
613
+ for (const scope of scopes) {
614
+ (excludeSet.has(scope) ? deferred : run).push(scope);
615
+ }
616
+ return { run, deferred };
617
+ }
618
+
286
619
  // ---------------------------------------------------------------------------
287
620
  // Formatting
288
621
  // ---------------------------------------------------------------------------
@@ -293,18 +626,33 @@ export function formatDreamResult(result: DreamResult): string {
293
626
  }
294
627
 
295
628
  const lines = [
296
- "Dream completed",
629
+ // 只有真写入了副作用才说 completed;合法空转说 noop,必经阶段没跑说 partial。
630
+ // 「跑完了」和「做成了」从这里开始就不再是同一句话。
631
+ result.output.kind === "produced" ? "Dream completed" : `Dream ${result.output.kind}`,
297
632
  "",
298
633
  ...result.phases.map(p => `[${p.phase}] ${p.detail}`),
299
634
  "",
300
635
  "Stats:",
301
636
  ` Total memories: ${result.stats.totalMemories}`,
302
637
  ` Active: ${result.stats.activeMemories}`,
303
- ` Clusters: ${result.stats.clustersFound}`,
638
+ ` Clusters: ${result.stats.clustersFound} (dedupe ${result.stats.dedupeClustersFound} + semantic ${result.stats.semanticClustersFound})`,
304
639
  ` Insights: ${result.stats.insightsGenerated}`,
305
640
  ` Patterns: ${result.stats.patternsExtracted}`,
306
641
  ` Merged: ${result.stats.mergedCount}`,
642
+ ` Relations: ${result.stats.relationsAdded}`,
307
643
  ` Archived: ${result.stats.archivedCount}`,
644
+ // 漏斗读数:consolidatable → vector-fed → semantic 簇 → insight。
645
+ // 哪一级掉到 0,断在哪一级就一目了然,不用再去猜。
646
+ ` Funnel: ${result.stats.consolidatableCount} consolidatable -> `
647
+ + `${result.stats.semanticVectorFed} vector-fed -> `
648
+ + `${result.stats.semanticClustersFound} semantic-clusters -> `
649
+ + `${result.stats.insightsGenerated} insights`,
650
+ ` Output: ${result.output.kind}`
651
+ + (result.output.reason ? ` (${result.output.reason})` : "")
652
+ + ` — ${result.output.effectsWritten} effects written`,
653
+ ...(result.output.degraded
654
+ ? [` ⚠ DEGRADED: ${result.output.degraded} — 有产出不代表没断,见上面的 Funnel`]
655
+ : []),
308
656
  ];
309
657
 
310
658
  return lines.join("\n");
package/src/env-config.ts CHANGED
@@ -61,6 +61,27 @@ export const errorSignatureBoost = (): boolean =>
61
61
 
62
62
  export const usageDecay = (): boolean => process.env.RECALLNEST_USAGE_DECAY === "true";
63
63
 
64
+ // --- LA-1: Layer admission (tri-state, defaults to off) ---
65
+
66
+ /**
67
+ * 资格层准入模式:默认检索是否只召回 durable 层(提炼结论),把 evidence
68
+ * (transcript 碎片)留给 deja / 显式溯源。
69
+ *
70
+ * off — 默认,完全不生效(现网行为不变)
71
+ * observe — 只计算并记录"会过滤掉什么",返回结果不变(影子期)
72
+ * on — 真正过滤
73
+ */
74
+ export const layerAdmission = (): "off" | "observe" | "on" => {
75
+ const v = process.env.RECALLNEST_LAYER_ADMISSION;
76
+ return v === "on" || v === "observe" ? v : "off";
77
+ };
78
+
79
+ /** durable 层命中数低于此值时回退到全量候选池,避免"宁缺毋滥"变成"什么都没有"。 */
80
+ export const layerAdmissionMin = (): number => {
81
+ const n = Number(process.env.RECALLNEST_LAYER_ADMISSION_MIN);
82
+ return Number.isFinite(n) && n > 0 ? n : 3;
83
+ };
84
+
64
85
  // --- String settings with `||` default (empty string falls through) ---
65
86
 
66
87
  export const dataDir = (): string => process.env.RECALLNEST_DATA_DIR || "data";
@@ -68,6 +89,26 @@ export const dataDir = (): string => process.env.RECALLNEST_DATA_DIR || "data";
68
89
  export const mcpTier = (): "core" | "advanced" | "full" =>
69
90
  (process.env.RECALLNEST_MCP_TIER || "advanced") as "core" | "advanced" | "full";
70
91
 
92
+ // --- LanceDB read consistency (cross-process visibility) ---
93
+
94
+ /**
95
+ * Read consistency interval (seconds) resolved for lancedb.connect().
96
+ * Without it a long-lived table handle pins its manifest version and never
97
+ * sees writes committed by other processes (CLI ingest vs resident MCP/API/UI
98
+ * servers). Explicit StoreConfig.readConsistencyInterval wins over this env.
99
+ *
100
+ * Unset / empty → 0 (strong consistency: check for external commits per read)
101
+ * "off" | "none" → undefined (legacy unchecked-handle behavior, escape hatch)
102
+ * number ≥ 0 → bounded staleness window in seconds (invalid values → 0)
103
+ */
104
+ export const readConsistencyInterval = (): number | undefined => {
105
+ const raw = process.env.RECALLNEST_READ_CONSISTENCY_INTERVAL;
106
+ if (raw === undefined || raw === "") return 0;
107
+ if (raw === "off" || raw === "none") return undefined;
108
+ const n = Number(raw);
109
+ return Number.isFinite(n) && n >= 0 ? n : 0;
110
+ };
111
+
71
112
  // --- Raw env values (caller validates / clamps / falls back to config) ---
72
113
 
73
114
  export const recallModeRaw = (): string | undefined => process.env.RECALLNEST_RECALL_MODE;