recallnest 2.6.0 → 3.0.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 (74) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/README.md +131 -14
  3. package/README_CN.md +121 -17
  4. package/config.json.example +8 -1
  5. package/docs/api-reference.md +1 -1
  6. package/integrations/README.md +1 -1
  7. package/integrations/agy/README.md +73 -0
  8. package/integrations/agy/setup.sh +75 -0
  9. package/integrations/setup-all.sh +1 -1
  10. package/package.json +7 -5
  11. package/src/__tests__/access-tracker-novelty.test.ts +22 -2
  12. package/src/__tests__/activity-counter.test.ts +38 -38
  13. package/src/__tests__/age-label.test.ts +33 -0
  14. package/src/__tests__/cluster-consolidation.test.ts +313 -22
  15. package/src/__tests__/consolidation-engine.test.ts +90 -0
  16. package/src/__tests__/contracts.test.ts +2 -2
  17. package/src/__tests__/data-checkup.test.ts +31 -3
  18. package/src/__tests__/dream-pipeline.test.ts +41 -1
  19. package/src/__tests__/dream-scope-isolation.test.ts +386 -0
  20. package/src/__tests__/dream-write-batching.test.ts +211 -0
  21. package/src/__tests__/env-config-invariant.test.ts +80 -0
  22. package/src/__tests__/env-config.test.ts +60 -0
  23. package/src/__tests__/ingest-scope-prefix.test.ts +69 -0
  24. package/src/__tests__/maintenance-locks.test.ts +8 -0
  25. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +17 -0
  26. package/src/__tests__/memory-output.test.ts +9 -0
  27. package/src/__tests__/memory-utility.test.ts +256 -0
  28. package/src/__tests__/openai-contract.test.ts +513 -0
  29. package/src/__tests__/promotion-distinct-sources.test.ts +162 -0
  30. package/src/__tests__/retrieve-audit-revision.test.ts +255 -0
  31. package/src/__tests__/source-heartbeat.test.ts +6 -2
  32. package/src/__tests__/store-patch-metadata.test.ts +30 -0
  33. package/src/__tests__/synthesis-contract.test.ts +252 -0
  34. package/src/__tests__/synthesis-promotion.test.ts +369 -0
  35. package/src/__tests__/trusted-memory.test.ts +28 -0
  36. package/src/__tests__/utility-weight.test.ts +57 -0
  37. package/src/__tests__/verbatim-self-recall.test.ts +279 -0
  38. package/src/__tests__/version-manager.test.ts +35 -0
  39. package/src/access-tracker.ts +10 -1
  40. package/src/activity-counter.ts +69 -11
  41. package/src/age-label.ts +29 -0
  42. package/src/audit-log.ts +35 -0
  43. package/src/auto-gc.ts +97 -30
  44. package/src/cli.ts +224 -10
  45. package/src/consolidation-engine.ts +317 -123
  46. package/src/data-checkup.ts +17 -3
  47. package/src/dream-pipeline.ts +90 -12
  48. package/src/embedder.ts +20 -5
  49. package/src/env-config.ts +53 -0
  50. package/src/forget-engine.ts +8 -2
  51. package/src/gateway-server.ts +272 -0
  52. package/src/ingest.ts +23 -5
  53. package/src/llm-client.ts +86 -25
  54. package/src/mcp-server.ts +2 -1
  55. package/src/mcp-tools-advanced.ts +7 -2
  56. package/src/mcp-tools-core.ts +11 -1
  57. package/src/mcp-tools-governance.ts +45 -3
  58. package/src/memory-evolution.ts +16 -0
  59. package/src/memory-output.ts +12 -8
  60. package/src/memory-promotion.ts +280 -0
  61. package/src/memory-store-port.ts +32 -3
  62. package/src/memory-utility.ts +314 -0
  63. package/src/recall-ledger.ts +76 -0
  64. package/src/retriever.ts +82 -9
  65. package/src/scope-policy.ts +22 -1
  66. package/src/session-output.ts +4 -3
  67. package/src/skill-promotion.ts +152 -1
  68. package/src/store.ts +231 -71
  69. package/src/synthesis-contract.ts +286 -0
  70. package/src/version-manager.ts +83 -0
  71. package/src/workflow-observation-schema.ts +26 -0
  72. package/integrations/gemini-cli/README.md +0 -57
  73. package/integrations/gemini-cli/setup.sh +0 -64
  74. /package/integrations/{gemini-cli/gemini-md-snippet.md → agy/agy-md-snippet.md} +0 -0
package/src/auto-gc.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  parseEvolution,
15
15
  computeDecayScore,
16
16
  computeUsageAdjustedDecayScore,
17
- buildArchivedMetadata,
17
+ patchEvolutionOnMeta,
18
18
  isActiveMemory,
19
19
  } from "./memory-evolution.js";
20
20
  import { loadRetentionPolicy, shouldArchiveByPolicy } from "./retention-policy.js";
@@ -23,6 +23,7 @@ import type { AuditLogger } from "./audit-log.js";
23
23
  import * as envConfig from "./env-config.js";
24
24
  import { isUsageSignalActive } from "./usage-tracker.js";
25
25
  import { acquireLock, releaseLock, getLastDistillTime, stampLock, lockPathForKey } from "./distill-lock.js";
26
+ import { logInfo } from "./stderr-log.js";
26
27
 
27
28
  // ---------------------------------------------------------------------------
28
29
  // Config
@@ -54,6 +55,7 @@ export const DEFAULT_AUTO_GC_CONFIG: AutoGcConfig = {
54
55
  // ---------------------------------------------------------------------------
55
56
 
56
57
  let lastGcTimestamp = 0;
58
+ let lastVersionGroupRepairTimestamp = 0;
57
59
 
58
60
  // ---------------------------------------------------------------------------
59
61
  // Core
@@ -64,6 +66,7 @@ export interface GcResult {
64
66
  reason?: string;
65
67
  archivedCount: number;
66
68
  totalChecked: number;
69
+ dissolvedVersionGroups: number;
67
70
  }
68
71
 
69
72
  /**
@@ -85,38 +88,70 @@ export async function maybeRunGc(
85
88
  const stats = await store.stats();
86
89
  const totalMemories = stats.totalCount ?? 0;
87
90
 
88
- // Condition 1: enough memories to warrant GC
89
- if (totalMemories < config.minMemoryCount) {
90
- return { triggered: false, reason: "below_memory_threshold", archivedCount: 0, totalChecked: 0 };
91
- }
91
+ // Singleton version-group repair has its own persistent throttle. It must not be
92
+ // hidden behind minMemoryCount: a small store can lose one member of a pair too.
93
+ // Reuse the configured maintenance interval, but keep a separate timestamp so a
94
+ // repair-only pass does not postpone a later archive pass (or vice versa).
95
+ const repairStampPath = lockPathForKey("version-group-repair-last");
96
+ const lastRepair = Math.max(
97
+ lastVersionGroupRepairTimestamp,
98
+ getLastDistillTime({ lockPath: repairStampPath }),
99
+ );
100
+ const repairDue = totalMemories > 0
101
+ && (Date.now() - lastRepair) / 3_600_000 >= config.minHoursSinceLastGc;
92
102
 
93
- // Condition 2: enough time since last GC. P0-1: the throttle is cross-process now —
94
- // take the max of this process's in-memory timestamp and the shared gc-last stamp
95
- // file's mtime. The old module-level var alone let each of the 11 mcp-server
96
- // processes run gc on its own independent 24h clock.
103
+ // Archive GC retains its existing count and time gates. P0-1: the time throttle
104
+ // is cross-process, using the shared gc-last marker in addition to process state.
97
105
  const gcStampPath = lockPathForKey("gc-last");
98
106
  const lastGc = Math.max(lastGcTimestamp, getLastDistillTime({ lockPath: gcStampPath }));
99
107
  const hoursSinceLastGc = (Date.now() - lastGc) / 3_600_000;
100
- if (hoursSinceLastGc < config.minHoursSinceLastGc) {
101
- return { triggered: false, reason: "too_soon", archivedCount: 0, totalChecked: 0 };
108
+ const archiveSkipReason = totalMemories < config.minMemoryCount
109
+ ? "below_memory_threshold"
110
+ : hoursSinceLastGc < config.minHoursSinceLastGc
111
+ ? "too_soon"
112
+ : undefined;
113
+
114
+ // Nothing is due: preserve the old fast no-op path without taking a run lock.
115
+ if (!repairDue && archiveSkipReason) {
116
+ return { triggered: false, reason: archiveSkipReason, archivedCount: 0, totalChecked: 0, dissolvedVersionGroups: 0 };
102
117
  }
103
118
 
104
119
  // Concurrency guard: only one process scans the full corpus at a time. A process that
105
120
  // passed the throttle in the same instant loses the O_EXCL race here and skips.
106
121
  const gcRunLock = { lockPath: lockPathForKey("gc-run"), expireMs: 30 * 60_000 };
107
122
  if (!acquireLock(gcRunLock)) {
108
- return { triggered: false, reason: "locked_by_another_process", archivedCount: 0, totalChecked: 0 };
123
+ return { triggered: false, reason: "locked_by_another_process", archivedCount: 0, totalChecked: 0, dissolvedVersionGroups: 0 };
109
124
  }
110
125
 
111
- // All conditions met — run GC
112
- lastGcTimestamp = Date.now(); // in-process immediate throttle
113
-
114
126
  try {
127
+ let dissolvedVersionGroups = 0;
128
+ if (repairDue) {
129
+ dissolvedVersionGroups = await store.repairSingletonVersionGroups();
130
+ lastVersionGroupRepairTimestamp = Date.now();
131
+ stampLock({ lockPath: repairStampPath });
132
+ if (dissolvedVersionGroups > 0) {
133
+ logInfo(`[INFO] Auto-GC dissolved ${dissolvedVersionGroups} singleton version group(s)`);
134
+ }
135
+ }
136
+
137
+ // A repair-only pass is still useful maintenance, but `triggered` continues to
138
+ // mean that the archive scan ran so existing callers keep their old semantics.
139
+ if (archiveSkipReason) {
140
+ return {
141
+ triggered: false,
142
+ reason: archiveSkipReason,
143
+ archivedCount: 0,
144
+ totalChecked: 0,
145
+ dissolvedVersionGroups,
146
+ };
147
+ }
148
+
149
+ lastGcTimestamp = Date.now(); // in-process immediate throttle
115
150
  const { archivedCount, totalChecked } = await runGcScan(store, config, retentionConfigDir, auditLogger);
116
151
  // Stamp completion into the shared throttle marker (create-or-touch → mtime = now)
117
152
  // so the other processes observe "last run" via getLastDistillTime.
118
153
  stampLock({ lockPath: gcStampPath });
119
- return { triggered: true, archivedCount, totalChecked };
154
+ return { triggered: true, archivedCount, totalChecked, dissolvedVersionGroups };
120
155
  } finally {
121
156
  releaseLock(gcRunLock);
122
157
  }
@@ -169,13 +204,47 @@ async function runGcScan(
169
204
 
170
205
  // Pass 2: archive sweep.
171
206
  const useUsageAdjustedDecay = envConfig.usageDecay() && isUsageSignalActive();
207
+
208
+ // 2026-08-14 批量化(dream 写爆库根治):候选先收集,页末统一一次 patchMetadataBatch
209
+ // —— 原逐条 update 每行一个 LanceDB commit(每天最多 100 个 manifest),现在页数级。
210
+ // activeCounts 仍在"选中"时即刻预递减,保持旧路径"逐行归档、后续行的 retention 判断
211
+ // 看到已减的 activeCount"语义(互审 C6)。selected 与 archivedCount 分开:前者控
212
+ // maxArchivePerRun 上限(选中即占额度),后者按批量写的实际写入数累计。
213
+ let selected = 0;
214
+ let pendingArchive: Array<{ id: string; ageDays: number }> = [];
215
+ const flushArchiveBatch = async (): Promise<void> => {
216
+ if (pendingArchive.length === 0) return;
217
+ const batch = pendingArchive;
218
+ pendingArchive = [];
219
+ const written = await store.patchMetadataBatch(
220
+ batch.map(({ id }) => ({
221
+ id,
222
+ patchFn: (meta: Record<string, unknown>) => patchEvolutionOnMeta(meta, { status: "archived" }),
223
+ })),
224
+ );
225
+ archivedCount += written;
226
+ // F-1: Audit log — record archive operations (silent on failure)。按提交批记录:
227
+ // 批内个别行被并发删除时 audit 会多记一条,best-effort 语义可接受(旧路径里
228
+ // update 失败是直接炸掉整个 GC,连 audit 都到不了)。
229
+ for (const { id, ageDays } of batch) {
230
+ try {
231
+ auditLogger?.log({
232
+ operation: "archive",
233
+ memoryId: id,
234
+ actor: "system",
235
+ details: `auto-gc: age=${Math.floor(ageDays)}d`,
236
+ });
237
+ } catch { /* Audit must never block GC */ }
238
+ }
239
+ };
240
+
172
241
  outer:
173
242
  for (let offset = 0; ; offset += GC_PAGE_SIZE) {
174
243
  const page = await store.listPage({ limit: GC_PAGE_SIZE, offset });
175
244
  totalChecked += page.length;
176
245
 
177
246
  for (const entry of page) {
178
- if (archivedCount >= config.maxArchivePerRun) break outer;
247
+ if (selected >= config.maxArchivePerRun) break outer;
179
248
 
180
249
  // Only archive active memories
181
250
  if (!isActiveMemory(entry.metadata)) continue;
@@ -215,19 +284,10 @@ async function runGcScan(
215
284
  }
216
285
 
217
286
  if (shouldArchive) {
218
- const archivedMeta = buildArchivedMetadata(entry.metadata);
219
- await store.update(entry.id, { metadata: archivedMeta });
220
- archivedCount++;
221
- // F-1: Audit log — record archive operation (silent on failure)
222
- try {
223
- auditLogger?.log({
224
- operation: "archive",
225
- memoryId: entry.id,
226
- actor: "system",
227
- details: `auto-gc: age=${Math.floor(ageDays)}d`,
228
- });
229
- } catch { /* Audit must never block GC */ }
230
- // Decrement active count for the scope (memory is no longer active)
287
+ pendingArchive.push({ id: entry.id, ageDays });
288
+ selected++;
289
+ // Decrement active count for the scope(预递减:选中即视为不再 active,
290
+ // 同页后续行的 retention 判断与旧逐行路径看到同样的计数)
231
291
  const scope = entry.scope ?? "";
232
292
  const prev = activeCounts.get(scope) ?? 0;
233
293
  if (prev > 0) {
@@ -236,9 +296,14 @@ async function runGcScan(
236
296
  }
237
297
  }
238
298
 
299
+ // 页末提交本页候选(含 break outer 前最后一页扫过的部分)
300
+ await flushArchiveBatch();
239
301
  if (page.length < GC_PAGE_SIZE) break;
240
302
  }
241
303
 
304
+ // break outer 跳出时 pendingArchive 可能还有未提交的候选 —— 上限命中不弃单(互审 C6)
305
+ await flushArchiveBatch();
306
+
242
307
  return { archivedCount, totalChecked };
243
308
  }
244
309
 
@@ -248,9 +313,11 @@ async function runGcScan(
248
313
  */
249
314
  export function resetGcTimestamp(): void {
250
315
  lastGcTimestamp = 0;
316
+ lastVersionGroupRepairTimestamp = 0;
251
317
  try {
252
318
  releaseLock({ lockPath: lockPathForKey("gc-last") });
253
319
  releaseLock({ lockPath: lockPathForKey("gc-run") });
320
+ releaseLock({ lockPath: lockPathForKey("version-group-repair-last") });
254
321
  } catch {
255
322
  /* ignore — nothing to clear */
256
323
  }
package/src/cli.ts CHANGED
@@ -38,9 +38,18 @@ import {
38
38
  } from "./ingest.js";
39
39
  import { runDoctor, formatDoctorResults } from "./doctor.js";
40
40
  import { persistCaseMemory, persistMemory, persistWorkflowPattern } from "./capture-engine.js";
41
- import { scanMemoryPromotions, buildPromoteScanDeps, formatPromoteScanResult } from "./memory-promotion.js";
41
+ import {
42
+ scanMemoryPromotions,
43
+ scanSynthesisPromotions,
44
+ buildPromoteScanDeps,
45
+ formatPromoteScanResult,
46
+ formatSynthesisPromoteResult,
47
+ } from "./memory-promotion.js";
42
48
  import { runDream, formatDreamResult, formatDreamMetrics, DEFAULT_DREAM_CONFIG, classifyDreamFailure, shouldBlockDreamRun, partitionAutoDreamScopes } from "./dream-pipeline.js";
43
- import { listScopesAboveThreshold } from "./activity-counter.js";
49
+ import { dreamBudgetMs } from "./env-config.js";
50
+ import { listScopesAboveThreshold, pruneWriteCounts } from "./activity-counter.js";
51
+ import { isTranscriptScope } from "./memory-boundaries.js";
52
+ import { maybeRunGc } from "./auto-gc.js";
44
53
  import {
45
54
  CaseMemoryInputSchema,
46
55
  type CaseMemoryInput,
@@ -62,6 +71,7 @@ import { WorkflowObservationOutcomeSchema } from "./workflow-observation-schema.
62
71
  import { buildWorkflowEvidence, buildWorkflowObservationRecord, inspectWorkflowDashboard, inspectWorkflowHealth } from "./workflow-observation-engine.js";
63
72
  import { formatWorkflowEvidencePack, formatWorkflowHealthDashboard, formatWorkflowHealthReport, formatWorkflowObservationSaved } from "./workflow-observation-output.js";
64
73
  import { WorkflowObservationStore } from "./workflow-observation-store.js";
74
+ import { applyUtilityToMeta, computeMemoryUtility, formatMemoryUtility } from "./memory-utility.js";
65
75
  import {
66
76
  applyWorkflowObservationScopeSuggestions,
67
77
  formatWorkflowObservationScopeReview,
@@ -702,6 +712,28 @@ program
702
712
  console.log(formatPromoteScanResult(result));
703
713
  });
704
714
 
715
+ program
716
+ .command("promote-synthesis")
717
+ .description("扫描 dream 合成出的结论(evidence 层),把证据充分的那些晋升出一份 durable 副本")
718
+ .requiredOption("--scope <scope>", "要扫描的 scope,如 memory")
719
+ .option("--apply", "执行晋升;默认 dry-run 只预览")
720
+ .option("--min-evidence <n>", "最少几条仍存活的不同证据(默认 2)", "2")
721
+ .option("--min-importance <n>", "合成条目自身的最低 importance(默认 0.6)", "0.6")
722
+ .action(async (options) => {
723
+ const config = loadConfig();
724
+ const { store, embedder } = createComponents(config);
725
+ const conflictStore = new ConflictCandidateStore();
726
+ const deps = buildPromoteScanDeps({ store, embedder, conflictStore });
727
+ const minDistinctEvidence = Number(options.minEvidence);
728
+ const minImportance = Number(options.minImportance);
729
+ const result = await scanSynthesisPromotions(deps, options.scope, {
730
+ dryRun: !options.apply,
731
+ ...(Number.isFinite(minDistinctEvidence) ? { minDistinctEvidence } : {}),
732
+ ...(Number.isFinite(minImportance) ? { minImportance } : {}),
733
+ });
734
+ console.log(formatSynthesisPromoteResult(result));
735
+ });
736
+
705
737
  program
706
738
  .command("export <query>")
707
739
  .description("导出检索与蒸馏结果到 markdown/json")
@@ -1360,7 +1392,7 @@ program
1360
1392
  program
1361
1393
  .command("ingest")
1362
1394
  .description("导入对话记录到索引")
1363
- .option("-s, --source <source>", "数据源: cc / codex / gemini / kimi / memory / desktop / all", "all")
1395
+ .option("-s, --source <source>", "数据源: cc / codex / gemini / kimi / memory / desktop / minis / all", "all")
1364
1396
  .option("-l, --limit <n>", "限制处理文件数(调试用)")
1365
1397
  .option("-v, --verbose", "详细输出")
1366
1398
  .option("--no-dedup", "跳过向量去重")
@@ -1503,6 +1535,27 @@ program
1503
1535
  }
1504
1536
  }
1505
1537
 
1538
+ // Minis (iPhone) —— 手机 agent 自己把对话写进这个目录,ingest 顺带扫走。
1539
+ // 为什么是独立源而不是复用 desktop:scope 前缀决定了 memory-boundaries 怎么降权,
1540
+ // 混在 cc: 里的话来源在库里就分辨不出来(那正是 2026-08-12 起挂着的缺口)。
1541
+ if (source === "all" || source === "minis") {
1542
+ const minisSource = config.sources.minis;
1543
+ if (minisSource) {
1544
+ const minisPath = resolveSourcePath(minisSource.path, "minis");
1545
+ if (existsSync(minisPath)) {
1546
+ console.log("📱 导入 Minis 对话...");
1547
+ const r = await ingestCCTranscripts(store, embedder, minisPath, {
1548
+ ...ingestOpts,
1549
+ scopePrefix: "minis",
1550
+ });
1551
+ results.push(r);
1552
+ console.log(` ✅ Minis: ${formatIngestSummary(r)}`);
1553
+ } else if (source === "minis") {
1554
+ console.log(`⚠️ Minis 目录不存在: ${minisPath}`);
1555
+ }
1556
+ }
1557
+ }
1558
+
1506
1559
  // Memory markdown files
1507
1560
  if (source === "all" || source === "memory") {
1508
1561
  console.log("📚 导入记忆文件...");
@@ -1587,10 +1640,11 @@ program
1587
1640
  program
1588
1641
  .command("dream")
1589
1642
  .description("运行 dream 离线整固管线 (Orient/Gather/Consolidate/Prune)")
1590
- .option("--scope <scope>", "目标 scope(前缀或精确)", "cc")
1643
+ .option("--scope <scope>", "目标 scope(默认前缀家族匹配;加 --exact-scope 变精确)", "cc")
1644
+ .option("--exact-scope", "把 --scope 当成一个具体 scope 名精确匹配,不做前缀家族展开(调度入口必带)")
1591
1645
  .option("--auto", "扫描 activity-counter 中写计数达标的所有 scope,逐个跑 dream(单 scope 失败不阻断其他)")
1592
1646
  .option("--force", "跳过 min-writes 门槛强制运行")
1593
- .action(async (options: { scope: string; auto?: boolean; force?: boolean }) => {
1647
+ .action(async (options: { scope: string; auto?: boolean; force?: boolean; exactScope?: boolean }) => {
1594
1648
  const config = loadConfig();
1595
1649
  const { store, embedder, llm } = createComponents(config);
1596
1650
  // Same activity-stats file the store writes per-scope counts to (beside the LanceDB dir).
@@ -1602,20 +1656,76 @@ program
1602
1656
  const eligible = listScopesAboveThreshold(DEFAULT_DREAM_CONFIG.minWritesForDream, { statsPath: activityStatsPath });
1603
1657
  // 巨型 scope 交给专用 schedule,不占日常轮次的 wall-clock 预算(理由见
1604
1658
  // DreamConfig.autoExcludeScopes:07-30 那轮 6h 只跑了 15/507,而 memory 独占 79% 的条数)。
1605
- const { run: scopes, deferred } = partitionAutoDreamScopes(eligible, DEFAULT_DREAM_CONFIG.autoExcludeScopes);
1659
+ const { run: nonMemoryScopes, deferred } = partitionAutoDreamScopes(eligible, DEFAULT_DREAM_CONFIG.autoExcludeScopes);
1606
1660
  if (deferred.length > 0) {
1607
1661
  // 不静默:被挪走的 scope 必须在日志里看得见,否则"今天怎么没跑 memory"只能靠翻代码回答。
1608
1662
  // 前缀刻意用 `[dream]` 而不是 `dream --auto:`——后者是 dream-checkup.sh 抓「达标 scope 数」
1609
1663
  // 的锚点,多一行同前缀的会混进它的取数。
1610
1664
  console.log(`[dream] ${deferred.length} 个 scope 交给专用 schedule,本轮不跑 → ${deferred.join(", ")}`);
1611
1665
  }
1666
+
1667
+ // 2026-08-16 策略排除(互审 C2/K5):与上面的 deferred **语义不同,别合并**——
1668
+ // deferred 是"别处会跑",这里是"没有任何 schedule 会跑"。日志措辞必须分开,
1669
+ // 否则将来查"pivot 怎么没巩固"会被误导去找那个并不存在的专用 job。
1670
+ // 同时清写计数:runDream 对它返回 skipped 不会 resetWriteCount,不清则计数无限
1671
+ // 累积、每天都进达标队列白跑一次(这正是 07-02 那个"处理完不出队"bug 的形状)。
1672
+ const neverDream = DEFAULT_DREAM_CONFIG.neverDreamScopes;
1673
+ const policyExcluded = nonMemoryScopes.filter(s => neverDream.includes(s));
1674
+ const afterPolicy = nonMemoryScopes.filter(s => !neverDream.includes(s));
1675
+ if (policyExcluded.length > 0) {
1676
+ const prunedPolicy = await pruneWriteCounts(s => neverDream.includes(s), { statsPath: activityStatsPath });
1677
+ console.log(
1678
+ `[dream] ${policyExcluded.length} 个 scope 按策略不参与巩固(neverDreamScopes,无专用 schedule)` +
1679
+ ` → ${policyExcluded.join(", ")};已清写计数 ${prunedPolicy.length} 个`,
1680
+ );
1681
+ }
1682
+
1683
+ // 2026-08-14 transcript scope 整体出队(dream 写爆库根治的准入侧):
1684
+ // transcript 是原文层(shared-behaviors §5.3——原文归 Deja/ingest 管,提炼归
1685
+ // pivot 池),历史会话被 ingest 批量灌入就把写计数打过门槛,2026-08 实测达标
1686
+ // 队列 95% 是 codex/cc 会话 scope、dream 派生产物 96.6% 落在不参与日常检索的
1687
+ // transcript scope 上——巩固算力几乎全部空转,还把 _versions 写到 7.7GB。
1688
+ // 出队同时清扫计数(含未达标条目——历史会话只增不减,不清则 stats 文件无限膨胀;
1689
+ // ingest 白天会再写回、次日 04:00 再清,稳态保持小)。判定用 memory-boundaries
1690
+ // 的 isTranscriptScope(接新端时那边本来就要求同步前缀清单)。
1691
+ const scopes = afterPolicy.filter(s => !isTranscriptScope(s));
1692
+ const transcriptEligible = afterPolicy.length - scopes.length;
1693
+ const prunedTranscripts = await pruneWriteCounts(isTranscriptScope, { statsPath: activityStatsPath });
1694
+ if (prunedTranscripts.length > 0 || transcriptEligible > 0) {
1695
+ console.log(
1696
+ `[dream] transcript 原文层不参与巩固(§5.3):清扫 ${prunedTranscripts.length} 个 scope 的写计数` +
1697
+ `(其中达标 ${transcriptEligible} 个)→ 样例: ${prunedTranscripts.slice(0, 5).join(", ")}`,
1698
+ );
1699
+ }
1700
+
1701
+ // 2026-08-14 全库 GC 兜底(互审 C5-P0):dream 是 auto-gc 的唯一生产触发点,而
1702
+ // maybeRunGc 藏在 runDreamInner 里 —— transcript 出队后 durable 队列可为空,若在
1703
+ // 进循环前就 exit,全库 GC 检查会静默消失、归档管线停转。这里无条件补调一次:
1704
+ // GC 自带 24h stamp + 跨进程锁,循环内已触发过则本次 too_soon 跳过,幂等零成本。
1705
+ // 失败只记日志不并入 blocked:GC 是 best-effort 维护,明天还有机会。
1706
+ const runGcBackstop = async (): Promise<void> => {
1707
+ try {
1708
+ const gc = await maybeRunGc(store, DEFAULT_DREAM_CONFIG.gc);
1709
+ console.log(
1710
+ `[dream] 全库 GC 兜底: ${gc.triggered
1711
+ ? `archived ${gc.archivedCount}, dissolved version groups ${gc.dissolvedVersionGroups}`
1712
+ : gc.dissolvedVersionGroups > 0
1713
+ ? `dissolved version groups ${gc.dissolvedVersionGroups}; archive skipped — ${gc.reason}`
1714
+ : `skipped — ${gc.reason}`}`,
1715
+ );
1716
+ } catch (err) {
1717
+ console.error(`[dream] 全库 GC 兜底失败(不阻断本轮判定): ${err instanceof Error ? err.message : String(err)}`);
1718
+ }
1719
+ };
1720
+
1612
1721
  if (scopes.length === 0) {
1613
- // 两种空:一种是真没人达标,一种是达标的全被挪走了——混成一句会让排障从这里开始跑偏。
1722
+ // 三种空分开说:真没人达标 / 达标的全被挪走或全是 transcript——混成一句会让排障跑偏。
1614
1723
  console.log(
1615
1724
  eligible.length === 0
1616
1725
  ? "dream --auto: 无达标 scope(写计数均低于门槛)"
1617
- : "dream --auto: 达标 scope 全部交给专用 schedule,本轮无事可做",
1726
+ : "dream --auto: 达标 scope 均为 transcript(已清扫)、已交专用 schedule 或按策略排除,本轮无事可做",
1618
1727
  );
1728
+ await runGcBackstop();
1619
1729
  console.log("[[DREAM_STATUS]] skip");
1620
1730
  process.exit(0);
1621
1731
  }
@@ -1625,7 +1735,7 @@ program
1625
1735
  // 把 07-25~27 三天的调度全堵死(上一次没结束,launchd 起不了新的)。
1626
1736
  // 天花板见 DreamConfig.autoRunBudgetMs 的注释——截断会让靠后 scope 系统性饿死,
1627
1737
  // 真解是 session scope TTL,不是把预算调大。
1628
- const budgetMs = Number(process.env.RECALLNEST_DREAM_BUDGET_MS ?? DEFAULT_DREAM_CONFIG.autoRunBudgetMs);
1738
+ const budgetMs = dreamBudgetMs(DEFAULT_DREAM_CONFIG.autoRunBudgetMs);
1629
1739
  const deadline = Date.now() + budgetMs;
1630
1740
 
1631
1741
  let fatalFailures = 0;
@@ -1647,7 +1757,11 @@ program
1647
1757
  }
1648
1758
  processed++;
1649
1759
  try {
1650
- const result = await runDream({ store, llm, embedder, scope, force: Boolean(options.force), activityStatsPath, kgStore });
1760
+ // scopeMatch: "exact" —— 这些 scope 名直接来自 activity-stats 的键,是具体
1761
+ // scope 不是 family selector。无冒号的键(实测 11 个,含 `cc` / `global` /
1762
+ // `memory`)走前缀会把整个家族卷进来:`cc` 一旦达标就以 `LIKE 'cc%'` 把 08-14
1763
+ // 刚出队的 transcript 原文层整批捞回(2026-08-16 互审 K4)。
1764
+ const result = await runDream({ store, llm, embedder, scope, force: Boolean(options.force), activityStatsPath, kgStore, scopeMatch: "exact" });
1651
1765
  tally[result.output.kind]++;
1652
1766
  effectsTotal += result.output.effectsWritten;
1653
1767
  if (result.output.degraded) degradedScopes.push(`${scope}=${result.output.degraded}`);
@@ -1665,6 +1779,10 @@ program
1665
1779
  }
1666
1780
  }
1667
1781
 
1782
+ // 全库 GC 兜底:循环内的 per-scope GC 若已触发过,这里 too_soon 幂等跳过;
1783
+ // 若全部 scope 都 skip/失败没走到 prune 阶段,这里补上当天唯一一次 GC 检查。
1784
+ await runGcBackstop();
1785
+
1668
1786
  // 旧语义是「任一 scope 抛异常即 blocked」,在 475 个 scope 的规模下等于「有一个撞上
1669
1787
  // store-write 锁就整轮红」,而整轮红会让 shell 脚本重试 → 重跑全量。改为:代码/数据
1670
1788
  // 缺陷一票否决,环境性失败要成规模才算整轮失败。比例按已处理数算,不按总数。
@@ -1702,6 +1820,9 @@ program
1702
1820
  force: Boolean(options.force),
1703
1821
  activityStatsPath,
1704
1822
  kgStore,
1823
+ // 手动调用默认保留 family(`--scope cc` 扫 `cc:*` 是既有用法);
1824
+ // 调度入口(weekly job)必须显式带 --exact-scope。
1825
+ scopeMatch: options.exactScope ? "exact" : "family",
1705
1826
  });
1706
1827
 
1707
1828
  console.log(formatDreamResult(result));
@@ -1936,6 +2057,92 @@ program
1936
2057
 
1937
2058
  // ─── doctor ──────────────────────────────────────────────────────────────────
1938
2059
 
2060
+ program
2061
+ .command("memory-utility")
2062
+ .description("价值回溯:查一条 memory 参与过的任务成功率与 utility(不传 id 时按 scope 批量)")
2063
+ .argument("[memoryId]", "memory id(完整 UUID 或前缀)")
2064
+ .option("--scope <scope>", "批量模式:扫这个 scope 下的条目")
2065
+ .option("--category <category>", "批量模式:只看这个 category")
2066
+ .option("--limit <n>", "批量模式扫描上限", "500")
2067
+ .option("--min-samples <n>", "批量模式只列出有效样本量 ≥ 此值的条目", "0")
2068
+ .option("--links <n>", "展示最近 N 条参与记录", "5")
2069
+ .option("--apply", "把算出的 utility 写回 metadata(默认只读,不写)")
2070
+ .option("--observations-dir <dir>", "从指定目录读 workflow observations(默认生产目录)")
2071
+ .option("--json", "输出 JSON")
2072
+ .action(async (memoryId, options) => {
2073
+ const { store } = createComponents(loadConfig());
2074
+ const observationStore = options.observationsDir
2075
+ ? new WorkflowObservationStore(expandHome(options.observationsDir))
2076
+ : new WorkflowObservationStore();
2077
+ // 全量拉 observation:join 要看的是历史全集,不是最近 200 条。
2078
+ const observations = await observationStore.listRecent({ limit: 100_000 });
2079
+ const showLinks = Number(options.links) || 0;
2080
+
2081
+ if (memoryId) {
2082
+ const entry = await store.getById(memoryId);
2083
+ if (!entry) {
2084
+ console.error(`未找到 memory: ${memoryId}`);
2085
+ process.exitCode = 1;
2086
+ return;
2087
+ }
2088
+ const summary = computeMemoryUtility(entry, observations);
2089
+ if (options.apply) {
2090
+ await store.patchMetadata(entry.id, (meta) => applyUtilityToMeta(meta, summary));
2091
+ }
2092
+ if (options.json) {
2093
+ console.log(JSON.stringify({ ...summary, applied: Boolean(options.apply) }, null, 2));
2094
+ return;
2095
+ }
2096
+ console.log(formatMemoryUtility(summary, { showLinks }));
2097
+ console.log(` observations 池: ${observations.length} 条`);
2098
+ if (options.apply) console.log(" 已写回 metadata.utility");
2099
+ return;
2100
+ }
2101
+
2102
+ if (!options.scope) {
2103
+ console.error("需要 <memoryId> 或 --scope");
2104
+ process.exitCode = 1;
2105
+ return;
2106
+ }
2107
+
2108
+ const limit = Number(options.limit) || 500;
2109
+ const minSamples = Number(options.minSamples) || 0;
2110
+ const entries = await store.listPage({
2111
+ scopeFilter: [options.scope],
2112
+ category: options.category,
2113
+ limit,
2114
+ offset: 0,
2115
+ });
2116
+
2117
+ const summaries = entries
2118
+ .map((entry) => computeMemoryUtility(entry, observations))
2119
+ .filter((s) => s.effectiveSamples >= minSamples && s.total > 0)
2120
+ .sort((a, b) => (b.utility ?? 0) - (a.utility ?? 0));
2121
+
2122
+ if (options.apply) {
2123
+ for (const summary of summaries) {
2124
+ await store.patchMetadata(summary.memoryId, (meta) => applyUtilityToMeta(meta, summary));
2125
+ }
2126
+ }
2127
+
2128
+ if (options.json) {
2129
+ console.log(JSON.stringify({ scanned: entries.length, matched: summaries.length, summaries }, null, 2));
2130
+ return;
2131
+ }
2132
+
2133
+ console.log(`scope=${options.scope} 扫描 ${entries.length} 条,其中 ${summaries.length} 条有参与记录`);
2134
+ console.log(`observations 池: ${observations.length} 条`);
2135
+ for (const summary of summaries.slice(0, 20)) {
2136
+ const utility = summary.utility === null ? " null" : summary.utility.toFixed(4).padStart(7);
2137
+ console.log(
2138
+ `${utility} samples=${summary.effectiveSamples.toFixed(2).padStart(6)}`
2139
+ + ` exact=${String(summary.exactCount).padStart(3)} session=${String(summary.sessionCount).padStart(3)}`
2140
+ + ` ${summary.memoryId.slice(0, 8)}`,
2141
+ );
2142
+ }
2143
+ if (options.apply) console.log(`已写回 ${summaries.length} 条的 metadata.utility`);
2144
+ });
2145
+
1939
2146
  program
1940
2147
  .command("workflow-observe")
1941
2148
  .description("记录一条 workflow primitive observation,作为自进化观测层的 append-only 输入")
@@ -1949,9 +2156,14 @@ program
1949
2156
  .option("--tags <tags>", "逗号分隔 tags")
1950
2157
  .option("--tools <tools>", "逗号分隔 tools")
1951
2158
  .option("--idempotency-key <key>", "同一请求重试时用于替换旧 observation 的稳定键")
2159
+ .option("--reader-id <id>", "P0 join key:读者身份(默认本进程)。CLI 每次是新进程,跨会话关联要显式传")
2160
+ .option("--recalled-ids <ids>", "P0 join key:逗号分隔的 memory id,本次任务实际用到的记忆")
1952
2161
  .option("--json", "输出 JSON")
1953
2162
  .action(async (workflowId, summary, options) => {
1954
2163
  const store = new WorkflowObservationStore();
2164
+ // CLI 是一次性进程,检索账本必然是空的(这次进程没检索过),所以不做自动补,
2165
+ // 只认显式传入——自动补在这里只会写出一个恒空的字段,看着像记了其实没记。
2166
+ const recalledIds = splitCsvOption(options.recalledIds);
1955
2167
  const record = buildWorkflowObservationRecord({
1956
2168
  workflowId,
1957
2169
  summary,
@@ -1963,6 +2175,8 @@ program
1963
2175
  tags: splitCsvOption(options.tags),
1964
2176
  tools: splitCsvOption(options.tools),
1965
2177
  idempotencyKey: options.idempotencyKey,
2178
+ readerId: options.readerId,
2179
+ ...(recalledIds.length > 0 ? { recalledIds } : {}),
1966
2180
  });
1967
2181
  const stored = await store.save(record);
1968
2182
  if (options.json) {