recallnest 2.6.1 → 3.0.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 (207) hide show
  1. package/CHANGELOG.md +292 -0
  2. package/README.md +280 -195
  3. package/README_CN.md +266 -199
  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/access-tracker.ts +10 -1
  12. package/src/activity-counter.ts +69 -11
  13. package/src/age-label.ts +29 -0
  14. package/src/audit-log.ts +35 -0
  15. package/src/auto-gc.ts +97 -30
  16. package/src/capture-engine.ts +26 -4
  17. package/src/cli.ts +224 -10
  18. package/src/consolidation-engine.ts +317 -123
  19. package/src/context-composer-stable.ts +5 -1
  20. package/src/data-checkup.ts +17 -3
  21. package/src/dream-pipeline.ts +90 -12
  22. package/src/embedder.ts +44 -5
  23. package/src/env-config.ts +71 -0
  24. package/src/forget-engine.ts +8 -2
  25. package/src/gateway-server.ts +272 -0
  26. package/src/ingest.ts +248 -22
  27. package/src/kg-extractor.ts +17 -0
  28. package/src/llm-client.ts +86 -25
  29. package/src/mcp-server.ts +2 -1
  30. package/src/mcp-tools-advanced.ts +7 -2
  31. package/src/mcp-tools-core.ts +11 -1
  32. package/src/mcp-tools-governance.ts +45 -3
  33. package/src/memory-evolution.ts +16 -0
  34. package/src/memory-output.ts +47 -8
  35. package/src/memory-promotion.ts +280 -0
  36. package/src/memory-store-port.ts +32 -3
  37. package/src/memory-utility.ts +314 -0
  38. package/src/pivot-apply.ts +52 -1
  39. package/src/pivot-evidence.ts +128 -0
  40. package/src/ppr-traversal.ts +25 -1
  41. package/src/recall-ledger.ts +76 -0
  42. package/src/retriever.ts +82 -9
  43. package/src/runtime-config.ts +9 -0
  44. package/src/scope-policy.ts +22 -1
  45. package/src/session-engine.ts +11 -4
  46. package/src/session-output.ts +13 -4
  47. package/src/session-store.ts +2 -3
  48. package/src/skill-promotion.ts +218 -1
  49. package/src/store.ts +231 -71
  50. package/src/synthesis-contract.ts +286 -0
  51. package/src/version-manager.ts +83 -0
  52. package/src/workflow-observation-schema.ts +26 -0
  53. package/integrations/gemini-cli/README.md +0 -57
  54. package/integrations/gemini-cli/setup.sh +0 -64
  55. package/src/__tests__/access-tracker-novelty.test.ts +0 -304
  56. package/src/__tests__/activity-counter.test.ts +0 -125
  57. package/src/__tests__/adaptive-fidelity.test.ts +0 -102
  58. package/src/__tests__/admission-control.test.ts +0 -206
  59. package/src/__tests__/anchor-generator.test.ts +0 -67
  60. package/src/__tests__/api-server-security.test.ts +0 -102
  61. package/src/__tests__/auto-consolidation.test.ts +0 -168
  62. package/src/__tests__/auto-recall.test.ts +0 -131
  63. package/src/__tests__/babel-integration.test.ts +0 -144
  64. package/src/__tests__/batch-dedup.test.ts +0 -65
  65. package/src/__tests__/belief-history.test.ts +0 -220
  66. package/src/__tests__/canary-eval.test.ts +0 -114
  67. package/src/__tests__/capture-engine.test.ts +0 -1328
  68. package/src/__tests__/capture-heuristic.test.ts +0 -171
  69. package/src/__tests__/cascade-forget.test.ts +0 -210
  70. package/src/__tests__/category-min-scores.test.ts +0 -116
  71. package/src/__tests__/cc8-cc1-essential-context.test.ts +0 -298
  72. package/src/__tests__/circuit-breaker.test.ts +0 -99
  73. package/src/__tests__/cluster-consolidation.test.ts +0 -945
  74. package/src/__tests__/confidence-metadata.test.ts +0 -125
  75. package/src/__tests__/confidence-tracker.test.ts +0 -261
  76. package/src/__tests__/conflict-advisor.test.ts +0 -206
  77. package/src/__tests__/conflict-escalation.test.ts +0 -149
  78. package/src/__tests__/conflict-lifecycle.test.ts +0 -83
  79. package/src/__tests__/conflict-output.test.ts +0 -85
  80. package/src/__tests__/conflict-store.test.ts +0 -418
  81. package/src/__tests__/connector-v1.test.ts +0 -181
  82. package/src/__tests__/consolidation-engine.test.ts +0 -430
  83. package/src/__tests__/constructive-retrieval.test.ts +0 -509
  84. package/src/__tests__/context-collapse-renderer.test.ts +0 -214
  85. package/src/__tests__/context-composer-pins.test.ts +0 -64
  86. package/src/__tests__/context-composer-task-selection.test.ts +0 -24
  87. package/src/__tests__/context-reconstructor.test.ts +0 -65
  88. package/src/__tests__/context-renderer.test.ts +0 -91
  89. package/src/__tests__/continuity-eval.test.ts +0 -275
  90. package/src/__tests__/continuity-schema.test.ts +0 -97
  91. package/src/__tests__/contracts.test.ts +0 -262
  92. package/src/__tests__/conversation-importer.test.ts +0 -692
  93. package/src/__tests__/core-recall-eval.test.ts +0 -235
  94. package/src/__tests__/core-summary.test.ts +0 -109
  95. package/src/__tests__/data-checkup.test.ts +0 -276
  96. package/src/__tests__/decay-exemption.test.ts +0 -129
  97. package/src/__tests__/dedup-actions.test.ts +0 -58
  98. package/src/__tests__/dedup-guard.test.ts +0 -39
  99. package/src/__tests__/deterministic-id.test.ts +0 -39
  100. package/src/__tests__/distill-lock.test.ts +0 -305
  101. package/src/__tests__/distill-session-argshape.test.ts +0 -79
  102. package/src/__tests__/doctor.test.ts +0 -319
  103. package/src/__tests__/dream-pipeline.test.ts +0 -780
  104. package/src/__tests__/drill-down.test.ts +0 -108
  105. package/src/__tests__/embedder.test.ts +0 -122
  106. package/src/__tests__/emotion-decay.test.ts +0 -46
  107. package/src/__tests__/emotion-detector.test.ts +0 -56
  108. package/src/__tests__/emotion-phase1-integration.test.ts +0 -153
  109. package/src/__tests__/emotion-retrieval.test.ts +0 -56
  110. package/src/__tests__/entity-resolver.test.ts +0 -58
  111. package/src/__tests__/env-config.test.ts +0 -151
  112. package/src/__tests__/error-signature-boost.test.ts +0 -87
  113. package/src/__tests__/error-signature.test.ts +0 -95
  114. package/src/__tests__/error-taxonomy.test.ts +0 -129
  115. package/src/__tests__/ethics-layer.test.ts +0 -476
  116. package/src/__tests__/event-segmenter.test.ts +0 -153
  117. package/src/__tests__/feature-flags-integration.test.ts +0 -54
  118. package/src/__tests__/frequency-tracker.test.ts +0 -113
  119. package/src/__tests__/freshness.test.ts +0 -186
  120. package/src/__tests__/graph-export.test.ts +0 -489
  121. package/src/__tests__/hotness-blend.test.ts +0 -283
  122. package/src/__tests__/ingest-chunking.test.ts +0 -44
  123. package/src/__tests__/ingest.test.ts +0 -360
  124. package/src/__tests__/interference-detector.test.ts +0 -203
  125. package/src/__tests__/kg-extractor.test.ts +0 -192
  126. package/src/__tests__/kg-store.test.ts +0 -300
  127. package/src/__tests__/llm-client-pivot.test.ts +0 -628
  128. package/src/__tests__/llm-consolidation.test.ts +0 -226
  129. package/src/__tests__/maintenance-locks.test.ts +0 -189
  130. package/src/__tests__/mcp-efficiency.test.ts +0 -288
  131. package/src/__tests__/mcp-related-scopes.test.ts +0 -151
  132. package/src/__tests__/memory-boundaries.test.ts +0 -282
  133. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +0 -345
  134. package/src/__tests__/memory-evolution.test.ts +0 -271
  135. package/src/__tests__/memory-health-rebalance.test.ts +0 -144
  136. package/src/__tests__/memory-lint.test.ts +0 -421
  137. package/src/__tests__/memory-output.test.ts +0 -215
  138. package/src/__tests__/memory-promotion.test.ts +0 -315
  139. package/src/__tests__/migrate-dedup-ids.test.ts +0 -65
  140. package/src/__tests__/multi-vector.test.ts +0 -242
  141. package/src/__tests__/narrative-architecture.test.ts +0 -456
  142. package/src/__tests__/noise-filter.test.ts +0 -205
  143. package/src/__tests__/noise-prototype-bank.test.ts +0 -280
  144. package/src/__tests__/obsidian-connector.test.ts +0 -363
  145. package/src/__tests__/omitted-reporting.test.ts +0 -98
  146. package/src/__tests__/pending-review.test.ts +0 -77
  147. package/src/__tests__/pii-redact.test.ts +0 -333
  148. package/src/__tests__/pivot-apply.test.ts +0 -373
  149. package/src/__tests__/pivot-distill-supervisor.test.ts +0 -457
  150. package/src/__tests__/pivot-distill.test.ts +0 -1178
  151. package/src/__tests__/post-retrieval-filter.test.ts +0 -178
  152. package/src/__tests__/ppr-traversal.test.ts +0 -256
  153. package/src/__tests__/prediction-engine.test.ts +0 -349
  154. package/src/__tests__/predictive-prospective-memory.test.ts +0 -416
  155. package/src/__tests__/preference-guard-smoke.test.ts +0 -211
  156. package/src/__tests__/preference-matcher.test.ts +0 -282
  157. package/src/__tests__/preference-slots.test.ts +0 -205
  158. package/src/__tests__/prospective-memory.test.ts +0 -283
  159. package/src/__tests__/pull-from-macbook-sync-only.test.ts +0 -679
  160. package/src/__tests__/query-entity-detector.test.ts +0 -171
  161. package/src/__tests__/query-expander-alias.test.ts +0 -146
  162. package/src/__tests__/query-expander.test.ts +0 -34
  163. package/src/__tests__/recall-gate.test.ts +0 -266
  164. package/src/__tests__/recall-governor.test.ts +0 -227
  165. package/src/__tests__/recall-mode.test.ts +0 -64
  166. package/src/__tests__/resolve-db-path.test.ts +0 -130
  167. package/src/__tests__/resolve-tier.test.ts +0 -64
  168. package/src/__tests__/result-synthesizer.test.ts +0 -198
  169. package/src/__tests__/resume-context.test.ts +0 -4898
  170. package/src/__tests__/retrieval-eval.test.ts +0 -163
  171. package/src/__tests__/retrieval-trace.test.ts +0 -213
  172. package/src/__tests__/retriever-boundary-weight.test.ts +0 -136
  173. package/src/__tests__/retriever-lazy-init-dispatch.test.ts +0 -123
  174. package/src/__tests__/retroactive-boost.test.ts +0 -223
  175. package/src/__tests__/salience-scoring.test.ts +0 -106
  176. package/src/__tests__/scope-inventory.test.ts +0 -200
  177. package/src/__tests__/scope-policy.test.ts +0 -69
  178. package/src/__tests__/scope-suggester.test.ts +0 -80
  179. package/src/__tests__/session-checkpoint.test.ts +0 -291
  180. package/src/__tests__/session-distiller.test.ts +0 -640
  181. package/src/__tests__/session-gc.test.ts +0 -215
  182. package/src/__tests__/skill-engine.test.ts +0 -552
  183. package/src/__tests__/skill-promotion.test.ts +0 -654
  184. package/src/__tests__/skill-verifier.test.ts +0 -122
  185. package/src/__tests__/source-heartbeat.test.ts +0 -250
  186. package/src/__tests__/stable-context-length-contract.test.ts +0 -62
  187. package/src/__tests__/store-patch-metadata.test.ts +0 -205
  188. package/src/__tests__/store-read-consistency.test.ts +0 -122
  189. package/src/__tests__/store-schema-migration.test.ts +0 -87
  190. package/src/__tests__/store-scope-audit.test.ts +0 -81
  191. package/src/__tests__/store-upsert.test.ts +0 -201
  192. package/src/__tests__/supersede-chain.test.ts +0 -154
  193. package/src/__tests__/temporal-parser.test.ts +0 -241
  194. package/src/__tests__/temporal-validity.test.ts +0 -160
  195. package/src/__tests__/term-registry-terminal-roster.test.ts +0 -127
  196. package/src/__tests__/term-registry.test.ts +0 -121
  197. package/src/__tests__/tool-error-surfacing.test.ts +0 -98
  198. package/src/__tests__/tool-output-compressor.test.ts +0 -160
  199. package/src/__tests__/topic-tag.test.ts +0 -113
  200. package/src/__tests__/trusted-memory.test.ts +0 -839
  201. package/src/__tests__/ui-server-security.test.ts +0 -93
  202. package/src/__tests__/usage-tracker.test.ts +0 -287
  203. package/src/__tests__/version-manager.test.ts +0 -213
  204. package/src/__tests__/workflow-observation-skill-outcome.test.ts +0 -352
  205. package/src/__tests__/workflow-observation.test.ts +0 -433
  206. package/src/__tests__/write-verifier.test.ts +0 -122
  207. /package/integrations/{gemini-cli/gemini-md-snippet.md → agy/agy-md-snippet.md} +0 -0
package/src/mcp-server.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * Tool tiers:
10
10
  * - core: Always exposed (7 tools)
11
11
  * - advanced: Exposed by default, includes core (30 tools)
12
- * - full: All tools including governance (43 tools)
12
+ * - full: All tools including governance (44 tools)
13
13
  *
14
14
  * Control: RECALLNEST_MCP_TIER=core|advanced|full
15
15
  */
@@ -79,6 +79,7 @@ const TOOL_TIERS: Record<string, ToolTier> = {
79
79
  distill_session: "advanced",
80
80
  scan_skill_promotions: "governance",
81
81
  promote_scan: "governance",
82
+ promote_synthesis: "governance",
82
83
  manage_alias: "governance",
83
84
  forget_memory: "advanced",
84
85
 
@@ -633,10 +633,11 @@ registerTool(
633
633
  "dream",
634
634
  "Run a full memory consolidation cycle (Orient, Gather, Consolidate, Prune). Side effect: may archive low-value entries and generate insight memories. Use when memory count is high and you need periodic maintenance.",
635
635
  {
636
- scope: z.string().min(1).max(160).optional().describe("Scope to consolidate, e.g. 'project:myapp'. Omit to consolidate across all scopes"),
636
+ scope: z.string().min(1).max(160).optional().describe("Scope to consolidate, e.g. 'project:myapp'. Matched exactly by default — a scope name is treated as itself, not as a prefix family. Omit to consolidate across all scopes"),
637
637
  force: z.boolean().default(false).describe("Set to true to force consolidation even if recent write count is below the automatic threshold"),
638
+ familyMatch: z.boolean().default(false).describe("Set to true to also consolidate child scopes sharing this prefix (e.g. 'memory' would additionally pull in 'memory:pivot'). Off by default: merging sibling scopes into one clustering round mixes unrelated material."),
638
639
  },
639
- async ({ scope, force }) => {
640
+ async ({ scope, force, familyMatch }) => {
640
641
  const resolvedScope = scope || "project:default";
641
642
  const components = getComponents();
642
643
  const result = await runDream({
@@ -646,6 +647,10 @@ registerTool(
646
647
  scope: resolvedScope,
647
648
  force,
648
649
  kgStore: getKGStore(),
650
+ // MCP 调用方给的都是具体 scope 名,family 语义在这里是意外面:
651
+ // `dream({scope: "memory"})` 会连 `memory:pivot` 一起卷进去(2026-08-16 实测)。
652
+ // 默认改精确是**行为变更**,要 family 得显式要。
653
+ scopeMatch: familyMatch ? "family" : "exact",
649
654
  });
650
655
  return {
651
656
  content: [{ type: "text" as const, text: formatDreamResult(result) }],
@@ -13,6 +13,7 @@ import { buildSessionCheckpointResult } from "./session-engine.js";
13
13
  import { formatCheckpointSaved, formatCheckpointSummary, formatResumeContext } from "./session-output.js";
14
14
  import { matchesTemporalConstraint, type TemporalConstraint } from "./temporal-parser.js";
15
15
  import { buildManagedCheckpointObservation, buildManagedResumeObservation } from "./workflow-observation-managed.js";
16
+ import { PROCESS_READER_ID, recentRecallHits } from "./recall-ledger.js";
16
17
  import { buildWorkflowObservationRecord } from "./workflow-observation-engine.js";
17
18
  import type { ToolRegistryDeps } from "./mcp-tool-deps.js";
18
19
  import type { RetrievalResult } from "./retriever.js";
@@ -65,7 +66,16 @@ export function registerCoreTools(deps: ToolRegistryDeps): void {
65
66
 
66
67
  async function saveManagedObservation(observation: Parameters<typeof buildWorkflowObservationRecord>[0]): Promise<void> {
67
68
  try {
68
- const record = buildWorkflowObservationRecord(observation);
69
+ // P0 join key:managed observation 是最该带它的一类——`resume_context` 本身
70
+ // 就是「读了一批记忆」的动作,它的成败与那批记忆直接相关。
71
+ // (api-server 那份同名函数刻意不加:HTTP server 一个进程服务多个客户端,
72
+ // 「一进程 ≈ 一会话」的前提在那里不成立,加了只会 join 出假配对。)
73
+ const recalledIds = recentRecallHits();
74
+ const record = buildWorkflowObservationRecord({
75
+ readerId: PROCESS_READER_ID,
76
+ ...(recalledIds.length > 0 ? { recalledIds } : {}),
77
+ ...(observation as Record<string, unknown>),
78
+ });
69
79
  await workflowObservationStore.save(record);
70
80
  } catch (error) {
71
81
  console.error("[RecallNest MCP] Failed to persist managed workflow observation:", error);
@@ -8,13 +8,20 @@ import { formatConflictAudit, formatConflictClusters, formatConflictEscalation,
8
8
  import { ConflictStatusSchema } from "./conflict-schema.js";
9
9
  import { ConsolidationEngine, formatConsolidationResult } from "./consolidation-engine.js";
10
10
  import { archiveDirtyBriefAsset, listDirtyBriefAssets } from "./memory-assets.js";
11
- import { scanMemoryPromotions, buildPromoteScanDeps, formatPromoteScanResult } from "./memory-promotion.js";
11
+ import {
12
+ scanMemoryPromotions,
13
+ scanSynthesisPromotions,
14
+ buildPromoteScanDeps,
15
+ formatPromoteScanResult,
16
+ formatSynthesisPromoteResult,
17
+ } from "./memory-promotion.js";
12
18
  import { aliasMapFilePath, expandQuery, explainUserAliases, listUserAliases, removeUserAlias, upsertUserAlias } from "./query-expander.js";
13
19
  import { recordSkillOutcome } from "./skill-engine.js";
14
20
  import { scanForPromotions, formatPromotionResult } from "./skill-promotion.js";
15
21
  import { buildWorkflowEvidence, buildWorkflowObservationRecord, inspectWorkflowDashboard, inspectWorkflowHealth } from "./workflow-observation-engine.js";
16
22
  import { formatWorkflowEvidencePack, formatWorkflowHealthDashboard, formatWorkflowHealthReport, formatWorkflowObservationSaved } from "./workflow-observation-output.js";
17
23
  import { WorkflowObservationOutcomeSchema } from "./workflow-observation-schema.js";
24
+ import { PROCESS_READER_ID, recentRecallHits } from "./recall-ledger.js";
18
25
  import type { ToolRegistryDeps } from "./mcp-tool-deps.js";
19
26
  import { withLock } from "./distill-lock.js";
20
27
 
@@ -36,8 +43,14 @@ registerTool(
36
43
  tools: z.array(z.string().min(1).max(60)).max(6).default([]).describe("Optional tools involved"),
37
44
  skillId: z.string().min(1).max(128).optional().describe("Optional skill id to bump. When present and outcome is success, the skill's successCount increments; otherwise failureCount increments. Missing/corrupt skills are skipped silently (does not block observation write)."),
38
45
  idempotencyKey: z.string().min(1).max(160).optional().describe("Optional stable request key; repeated saves with the same key replace the prior observation"),
46
+ recalledIds: z.array(z.string().min(1).max(128)).max(64).optional().describe("Optional memory ids this task actually used. Omit to auto-fill from what this session retrieved in the last 30 minutes — that auto-fill is what makes (memory, outcome) value back-propagation possible."),
39
47
  },
40
- async ({ workflowId, outcome, summary, scope, source, signal, task, tags, tools, skillId, idempotencyKey }) => {
48
+ async ({ workflowId, outcome, summary, scope, source, signal, task, tags, tools, skillId, idempotencyKey, recalledIds }) => {
49
+ // P0 join key:不传就用本进程的检索账本自动补。这一步是价值回溯的前提——
50
+ // 少了它,库里就只剩「有结果没参与者」和「有参与者没结果」两堆对不上的数据。
51
+ const resolvedRecalledIds = recalledIds && recalledIds.length > 0
52
+ ? recalledIds
53
+ : recentRecallHits();
41
54
  const record = buildWorkflowObservationRecord({
42
55
  workflowId,
43
56
  outcome,
@@ -50,6 +63,8 @@ registerTool(
50
63
  tools,
51
64
  skillId,
52
65
  idempotencyKey,
66
+ readerId: PROCESS_READER_ID,
67
+ ...(resolvedRecalledIds.length > 0 ? { recalledIds: resolvedRecalledIds } : {}),
53
68
  });
54
69
  const stored = await workflowObservationStore.save(record);
55
70
 
@@ -332,6 +347,7 @@ registerTool(
332
347
  getById: store.getById.bind(store),
333
348
  vectorSearch: store.vectorSearch.bind(store),
334
349
  update: async () => null, // no-op in dry-run
350
+ patchMetadataBatch: async () => 0, // no-op in dry-run(3a 批量写通道,2026-08-14)
335
351
  };
336
352
  const engine = new ConsolidationEngine(readOnlyStore, { clusterThreshold, mergeThreshold, maxEntriesPerRun: maxEntries }, getKGStore());
337
353
  const result = await engine.run(scope);
@@ -377,11 +393,13 @@ registerTool(
377
393
  {
378
394
  scope: z.string().min(1).max(160).describe("Project scope to scan for promotion candidates"),
379
395
  minOccurrences: z.number().min(2).max(20).default(3).describe("Minimum similar cases to trigger a promotion suggestion"),
396
+ minDistinctSources: z.number().min(1).max(10).default(2).describe("How many DIFFERENT sources (episodes) a cluster must span. Counting similar entries is not the same as counting distinct experiences — the same pothole hit three times is one experience, not a rule. Set 1 to disable this check. Abstains (never rejects) when the entries carry no source pointer."),
380
397
  },
381
- async ({ scope, minOccurrences }) => {
398
+ async ({ scope, minOccurrences, minDistinctSources }) => {
382
399
  const { store } = getComponents();
383
400
  const result = await scanForPromotions(store, scope, {
384
401
  minCaseOccurrences: minOccurrences,
402
+ minDistinctSources,
385
403
  });
386
404
 
387
405
  return {
@@ -472,4 +490,28 @@ registerTool(
472
490
  };
473
491
  },
474
492
  );
493
+
494
+ registerTool(
495
+ "promote_synthesis",
496
+ "Scan dream-synthesized conclusions (cluster insights and cross-memory patterns, which are written on the evidence layer and therefore excluded from stable memory) and promote the well-supported ones into a durable copy. Eligibility comes from the conclusion's own validated evidence set, not from repetition. Default dry-run (no writes); when dryRun=false it writes durable entries through the same promote path as promote_memory, so canonicalKey dedup makes re-runs a revision rather than a duplicate. The synthesized row itself is never modified.",
497
+ {
498
+ scope: z.string().min(1).max(160).describe("Scope to scan, e.g. memory"),
499
+ dryRun: z.boolean().default(true).describe("When true (default), only report candidates without writing"),
500
+ minDistinctEvidence: z.number().int().min(2).max(20).default(2).describe("Min distinct, still-active evidence memories behind the conclusion"),
501
+ minImportance: z.number().min(0).max(1).default(0.6).describe("Min importance of the synthesized conclusion itself"),
502
+ },
503
+ async ({ scope, dryRun, minDistinctEvidence, minImportance }) => {
504
+ const { store, embedder } = getComponents();
505
+ const kgExtractor = getKGExtractor();
506
+ const deps = buildPromoteScanDeps({ store, embedder, conflictStore, kgExtractor });
507
+ const result = await scanSynthesisPromotions(deps, scope, { dryRun, minDistinctEvidence, minImportance });
508
+
509
+ return {
510
+ content: [{
511
+ type: "text" as const,
512
+ text: formatSynthesisPromoteResult(result),
513
+ }],
514
+ };
515
+ },
516
+ );
475
517
  }
@@ -115,6 +115,22 @@ export function patchEvolution(
115
115
  return JSON.stringify(parsed);
116
116
  }
117
117
 
118
+ /**
119
+ * patchEvolution 的对象版:给 patchMetadataBatch 的 patchFn 用(那条通道进出都是
120
+ * 已解析的 metadata 对象,不该为了复用字符串版而反复 stringify/parse)。
121
+ * 就地修改并返回同一对象,语义与 patchEvolution 一致(浅合并 evolution 子对象)。
122
+ */
123
+ export function patchEvolutionOnMeta(
124
+ meta: Record<string, unknown>,
125
+ patch: Partial<EvolutionMetadata>,
126
+ ): Record<string, unknown> {
127
+ const existing = meta.evolution !== null && typeof meta.evolution === "object" && !Array.isArray(meta.evolution)
128
+ ? (meta.evolution as Partial<EvolutionMetadata>)
129
+ : undefined;
130
+ meta.evolution = { ...existing, ...patch };
131
+ return meta;
132
+ }
133
+
118
134
  // ---------------------------------------------------------------------------
119
135
  // Predicates
120
136
  // ---------------------------------------------------------------------------
@@ -1,3 +1,4 @@
1
+ import { formatAgeLabel } from "./age-label.js";
1
2
  import type { MemoryEntry } from "./store.js";
2
3
  import type { RetrievalResult } from "./retriever.js";
3
4
  import type { RetrievalProfileName } from "./retrieval-profiles.js";
@@ -41,6 +42,7 @@ export interface DistilledEvidence {
41
42
  source: string;
42
43
  scope: string;
43
44
  date: string;
45
+ age: string;
44
46
  retrievalPath: string;
45
47
  snippet: string;
46
48
  }
@@ -82,6 +84,37 @@ function getTierLabel(result: RetrievalResult): string {
82
84
  return String(meta.tier || "peripheral");
83
85
  }
84
86
 
87
+ /**
88
+ * 所在 session 里有用户贴图时,多给一行提示。
89
+ *
90
+ * 图本体不在库里,正文里通常也没有对图的描述——这一行是唯一能让读到这条
91
+ * 记忆的人意识到「这附近还有图没看」的信号。判断值不值得看,回 Deja 按
92
+ * session 读原文。
93
+ *
94
+ * ⚠️ 这是 **session 级**标记,不是「本条记忆有图」:同一个 session 切出来
95
+ * 的每条记忆都带同一个数,图未必跟眼前这条有关。粒度换覆盖,明知而为——
96
+ * 图所在的那一轮常因正文太短根本没进库,只有盖到同 session 的其他轮次上
97
+ * 才可能被搜到。
98
+ *
99
+ * 无图时返回 null,调用方据此不输出这一行——不给无图记忆添噪音。
100
+ */
101
+ function formatSessionImages(result: RetrievalResult): string | null {
102
+ const meta = parseMetadata(result.entry);
103
+ const own = typeof meta.sessionImages === "number" ? meta.sessionImages : 0;
104
+ const tool = typeof meta.sessionToolImages === "number" ? meta.sessionToolImages : 0;
105
+ if (own <= 0 && tool <= 0) return null;
106
+
107
+ // 两类分开报,因为它们答的是不同的问题:找「我发过的那张截图」看前者,
108
+ // 回溯「我当时生成的图 / 那个页面当时的样子」看后者。
109
+ // 措辞用英文,与同一块里的 prov / fresh 行保持一致(这个输出面是英文的)。
110
+ const parts: string[] = [];
111
+ if (own > 0) parts.push(`${own} user-pasted`);
112
+ if (tool > 0) parts.push(`${tool} agent-made`);
113
+ const sess = typeof meta.sessionId === "string" ? meta.sessionId.slice(0, 8) : "?";
114
+
115
+ return `${parts.join(", ")} in this session · read sess=${sess}`;
116
+ }
117
+
85
118
  function getProvenanceSummary(result: RetrievalResult): string {
86
119
  const provenance = extractMemoryProvenance({
87
120
  scope: result.entry.scope,
@@ -332,6 +365,7 @@ function buildSearchRow(index: number, query: string, result: RetrievalResult):
332
365
  getTierLabel(result).padEnd(10),
333
366
  getSourceLabel(result).padEnd(7),
334
367
  getDateLabel(result.entry.timestamp),
368
+ formatAgeLabel(result.entry.timestamp).padEnd(5),
335
369
  getRetrievalPath(result).padEnd(20),
336
370
  getFileLabel(result),
337
371
  cleanSnippet(pickBestSnippet(query, result.entry.text), 120),
@@ -374,14 +408,16 @@ export function formatFullResults(
374
408
  `Profile : ${context.profile}`,
375
409
  `Hits : ${results.length}`,
376
410
  "",
377
- "# ID Score Category Tier Source Date Retrieval Path File / Snippet",
378
- "-- -------- ----- ------------ ---------- ------- ---------- -------------------- --------------",
411
+ "# ID Score Category Tier Source Date Age Retrieval Path File / Snippet",
412
+ "-- -------- ----- ------------ ---------- ------- ---------- ----- -------------------- --------------",
379
413
  ];
380
414
 
381
415
  for (let i = 0; i < results.length; i++) {
382
416
  const row = buildSearchRow(i, context.query, results[i]);
383
- lines.push(`${row[0]} ${row[1]} ${row[2]} ${row[3]} ${row[4]} ${row[5]} ${row[6]} ${row[7]} ${row[8]} | ${row[9]}`);
417
+ lines.push(`${row[0]} ${row[1]} ${row[2]} ${row[3]} ${row[4]} ${row[5]} ${row[6]} ${row[7]} ${row[8]} ${row[9]} | ${row[10]}`);
384
418
  lines.push(` prov : ${getProvenanceSummary(results[i])}`);
419
+ const imgs = formatSessionImages(results[i]);
420
+ if (imgs) lines.push(` imgs : ${imgs}`);
385
421
  // Full mode: append metadata details
386
422
  const meta = parseMetadata(results[i].entry);
387
423
  const evolution = typeof meta.evolutionStatus === "string" ? meta.evolutionStatus : "-";
@@ -424,14 +460,16 @@ export function formatSearchResults(
424
460
  `Profile : ${context.profile}`,
425
461
  `Hits : ${results.length}`,
426
462
  "",
427
- "# ID Score Category Tier Source Date Retrieval Path File / Snippet",
428
- "-- -------- ----- ------------ ---------- ------- ---------- -------------------- --------------",
463
+ "# ID Score Category Tier Source Date Age Retrieval Path File / Snippet",
464
+ "-- -------- ----- ------------ ---------- ------- ---------- ----- -------------------- --------------",
429
465
  ];
430
466
 
431
467
  for (let i = 0; i < results.length; i++) {
432
468
  const row = buildSearchRow(i, context.query, results[i]);
433
- lines.push(`${row[0]} ${row[1]} ${row[2]} ${row[3]} ${row[4]} ${row[5]} ${row[6]} ${row[7]} ${row[8]} | ${row[9]}`);
469
+ lines.push(`${row[0]} ${row[1]} ${row[2]} ${row[3]} ${row[4]} ${row[5]} ${row[6]} ${row[7]} ${row[8]} ${row[9]} | ${row[10]}`);
434
470
  lines.push(` prov : ${getProvenanceSummary(results[i])}`);
471
+ const imgs = formatSessionImages(results[i]);
472
+ if (imgs) lines.push(` imgs : ${imgs}`);
435
473
  // Freshness: only shown for memories that declared dependsOn (opt-in, cheap check).
436
474
  const fresh = evaluateEntryFreshness(results[i].entry.metadata, freshnessCache);
437
475
  if (fresh) lines.push(` fresh: ${fresh}`);
@@ -529,7 +567,7 @@ export function formatExplainResults(
529
567
  const session = getSessionLabel(result);
530
568
  const why = buildWhyMatched(context.query, result);
531
569
 
532
- lines.push(`${i + 1}. ${result.entry.id.slice(0, 8)} | ${score} | ${getSourceLabel(result)} | ${getDateLabel(result.entry.timestamp)}`);
570
+ lines.push(`${i + 1}. ${result.entry.id.slice(0, 8)} | ${score} | ${getSourceLabel(result)} | ${getDateLabel(result.entry.timestamp)} (${formatAgeLabel(result.entry.timestamp)})`);
533
571
  lines.push(` category: ${getCategoryLabel(result)}`);
534
572
  lines.push(` tier : ${getTierLabel(result)}`);
535
573
  lines.push(` path : ${retrieval}`);
@@ -592,6 +630,7 @@ export function summarizeResults(
592
630
  source: getSourceLabel(result),
593
631
  scope: result.entry.scope,
594
632
  date: getDateLabel(result.entry.timestamp),
633
+ age: formatAgeLabel(result.entry.timestamp),
595
634
  retrievalPath: getRetrievalPath(result),
596
635
  snippet: pickBestSnippet(context.query, normalizeRecallText(result)),
597
636
  });
@@ -654,7 +693,7 @@ export function distillResults(
654
693
 
655
694
  lines.push("", "Evidence");
656
695
  summary.evidence.forEach((item, index) => {
657
- lines.push(`${index + 1}. ${item.source} | ${item.date} | ${item.retrievalPath} | ${item.snippet}`);
696
+ lines.push(`${index + 1}. ${item.source} | ${item.date} (${item.age}) | ${item.retrievalPath} | ${item.snippet}`);
658
697
  });
659
698
 
660
699
  lines.push("", "Reusable Memory Candidates");
@@ -244,3 +244,283 @@ export function formatPromoteScanResult(result: PromoteScanResult): string {
244
244
  }
245
245
  return lines.join("\n");
246
246
  }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Synthesis promotion: evidence → durable for dream-synthesized conclusions
250
+ // ---------------------------------------------------------------------------
251
+
252
+ /**
253
+ * The layering this implements is "evidence retained → current projection → eligible for
254
+ * recall → ranked result". `scanMemoryPromotions` above covers one road into the durable
255
+ * layer: transcript-downgraded facts that recur often enough to be worth keeping. This
256
+ * covers the other one, which had no road at all.
257
+ *
258
+ * `buildDerivedBoundary` (consolidation-engine) stamps every dream-synthesized insight and
259
+ * cross-memory pattern as `layer: "evidence"`, deliberately — a model re-reading its own
260
+ * memories is a lead to its sources, not authority over them. But
261
+ * `shouldUseStableMemoryResult` refuses anything on the evidence layer, so the consequence
262
+ * was absolute: **a synthesized conclusion could never take part in stable memory, no
263
+ * matter how well supported it was.** The synthesis contract added in `efdb043` made those
264
+ * conclusions checkable (a verdict may abstain, and evidence ids are validated against the
265
+ * cluster) without giving them anywhere to go.
266
+ *
267
+ * ## Why the gate is not the one above
268
+ *
269
+ * The recurrence gate — "the same fact showed up in N separate places" — is the wrong
270
+ * question to ask a synthesis. A synthesis is *already* a cross-entry aggregate; demanding
271
+ * that the dream produce the same conclusion three times would be counting one thing three
272
+ * ways. What it carries instead is its own support set, so that is what gets checked:
273
+ * enough distinct sources, still live, under a contract version that validated them.
274
+ *
275
+ * ## What promotion does and does not touch
276
+ *
277
+ * The synthesis row is never modified — archive-first, and the evidence has to stay
278
+ * readable as evidence. Promotion writes a *separate* durable entry through the same
279
+ * `promoteMemory` path everything else uses, so it inherits canonicalKey dedup (re-running
280
+ * revises rather than duplicating), secret redaction, and `promotedFrom` provenance
281
+ * pointing back at the synthesis. The durable copy is what later recall can lean on.
282
+ */
283
+
284
+ /** Synthesis contract version required before a derived row is eligible. */
285
+ export const MIN_PROMOTABLE_CONTRACT_VERSION = 2;
286
+
287
+ export interface SynthesisPromoteScanConfig {
288
+ /** Min distinct, still-live evidence memories behind the conclusion (default 2). */
289
+ minDistinctEvidence: number;
290
+ /** Min importance of the synthesis itself (default 0.6). */
291
+ minImportance: number;
292
+ /** Max derived rows fetched from the store (default 2000). */
293
+ listLimit: number;
294
+ /** When true (default), report candidates without writing anything. */
295
+ dryRun: boolean;
296
+ }
297
+
298
+ export const DEFAULT_SYNTHESIS_PROMOTE_CONFIG: SynthesisPromoteScanConfig = {
299
+ minDistinctEvidence: 2,
300
+ minImportance: 0.6,
301
+ listLimit: 2000,
302
+ dryRun: true,
303
+ };
304
+
305
+ /** Why an otherwise-derived row was not promoted. Reported, never silent. */
306
+ export type SynthesisSkipReason =
307
+ | "pre_contract"
308
+ | "already_promoted"
309
+ | "evidence_unresolvable"
310
+ | "too_few_distinct_evidence"
311
+ | "below_importance";
312
+
313
+ export interface SynthesisPromoteCandidate {
314
+ seedId: string;
315
+ seedText: string;
316
+ /** Durable category the conclusion is promoted into (from boundary.originalCategory). */
317
+ category: DurableMemoryCategory;
318
+ contractVersion: number;
319
+ /** Distinct evidence ids that are still active in the store. */
320
+ evidenceIds: string[];
321
+ importance: number;
322
+ /** Filled after a real promotion; null in dryRun mode. */
323
+ promoted: StoredPromotedMemoryRecord | null;
324
+ }
325
+
326
+ export interface SynthesisPromoteScanResult {
327
+ candidates: SynthesisPromoteCandidate[];
328
+ /** Derived rows examined (carrying a distillation boundary), before gating. */
329
+ scannedSyntheses: number;
330
+ /** Per-reason counts for everything examined and rejected. */
331
+ skipped: Record<SynthesisSkipReason, number>;
332
+ promoted: number;
333
+ dryRun: boolean;
334
+ }
335
+
336
+ export interface SynthesisPromoteScanDeps {
337
+ store: Pick<MemoryStore, "list"> & {
338
+ getById?(id: string): Promise<MemoryEntry | null>;
339
+ get?(id: string): Promise<MemoryEntry | null>;
340
+ };
341
+ promote: PromoteFn;
342
+ }
343
+
344
+ function readContractVersion(metadata?: string): number | null {
345
+ const parsed = parseMetadataObject(metadata);
346
+ const raw = parsed?.synthesis_contract;
347
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : null;
348
+ }
349
+
350
+ /** Ids the model itself named as support, deduplicated and order-preserved. */
351
+ function readEvidenceIds(metadata?: string): string[] | null {
352
+ const parsed = parseMetadataObject(metadata);
353
+ const raw = parsed?.evidenceMemories;
354
+ if (!Array.isArray(raw)) return null;
355
+ const ids = raw.filter((id): id is string => typeof id === "string" && id.trim().length > 0);
356
+ return [...new Set(ids)];
357
+ }
358
+
359
+ /** A dream-synthesized derivative — evidence layer written by the distillation path. */
360
+ function isSynthesisRow(entry: MemoryEntry): boolean {
361
+ const boundary = extractBoundaryMetadata(entry.metadata);
362
+ return boundary?.layer === "evidence" && boundary.authority === "distillation";
363
+ }
364
+
365
+ async function loadEntry(
366
+ deps: SynthesisPromoteScanDeps,
367
+ id: string,
368
+ ): Promise<MemoryEntry | null> {
369
+ if (deps.store.getById) return deps.store.getById(id);
370
+ if (deps.store.get) return deps.store.get(id);
371
+ return null;
372
+ }
373
+
374
+ /**
375
+ * Find synthesized conclusions whose own support set justifies a durable copy.
376
+ *
377
+ * Abstains rather than guesses: a row whose evidence ids cannot be resolved at all is
378
+ * counted under `evidence_unresolvable` and left alone. That mirrors the abstention path
379
+ * in `scanForPromotions`' cross-source check — when the provenance needed for the judgement
380
+ * is missing, not judging is the correct answer, not judging leniently.
381
+ */
382
+ export async function scanSynthesisPromotions(
383
+ deps: SynthesisPromoteScanDeps,
384
+ scope: string,
385
+ config?: Partial<SynthesisPromoteScanConfig>,
386
+ ): Promise<SynthesisPromoteScanResult> {
387
+ const cfg: SynthesisPromoteScanConfig = { ...DEFAULT_SYNTHESIS_PROMOTE_CONFIG, ...config };
388
+
389
+ const result: SynthesisPromoteScanResult = {
390
+ candidates: [],
391
+ scannedSyntheses: 0,
392
+ skipped: {
393
+ pre_contract: 0,
394
+ already_promoted: 0,
395
+ evidence_unresolvable: 0,
396
+ too_few_distinct_evidence: 0,
397
+ below_importance: 0,
398
+ },
399
+ promoted: 0,
400
+ dryRun: cfg.dryRun,
401
+ };
402
+
403
+ // Synthesized insights take the cluster's majority category, so this cannot be narrowed
404
+ // to one category the way the downgraded-evidence scan is.
405
+ const rows = await deps.store.list([scope], undefined, cfg.listLimit, 0);
406
+ const syntheses = rows.filter((e) => isActiveMemory(e.metadata) && isSynthesisRow(e));
407
+ result.scannedSyntheses = syntheses.length;
408
+
409
+ for (const entry of syntheses) {
410
+ const contractVersion = readContractVersion(entry.metadata);
411
+ // Derivatives written before the synthesis contract were never validated — the whole
412
+ // point of the version stamp is that "which code produced this" stops being a guess.
413
+ if (contractVersion === null || contractVersion < MIN_PROMOTABLE_CONTRACT_VERSION) {
414
+ result.skipped.pre_contract += 1;
415
+ continue;
416
+ }
417
+
418
+ if (hasPromotedToMarker(entry.metadata)) {
419
+ result.skipped.already_promoted += 1;
420
+ continue;
421
+ }
422
+
423
+ const declared = readEvidenceIds(entry.metadata);
424
+ if (declared === null || declared.length === 0) {
425
+ result.skipped.evidence_unresolvable += 1;
426
+ continue;
427
+ }
428
+
429
+ // Evidence that has since been superseded or archived no longer supports anything.
430
+ const resolved: string[] = [];
431
+ let lookupFailed = false;
432
+ for (const id of declared) {
433
+ const source = await loadEntry(deps, id);
434
+ if (source === null) {
435
+ lookupFailed = true;
436
+ continue;
437
+ }
438
+ if (isActiveMemory(source.metadata)) resolved.push(id);
439
+ }
440
+
441
+ // Cannot see any of the support: abstain instead of promoting on an unchecked claim.
442
+ if (resolved.length === 0 && lookupFailed) {
443
+ result.skipped.evidence_unresolvable += 1;
444
+ continue;
445
+ }
446
+
447
+ if (resolved.length < cfg.minDistinctEvidence) {
448
+ result.skipped.too_few_distinct_evidence += 1;
449
+ continue;
450
+ }
451
+
452
+ if ((entry.importance ?? 0) < cfg.minImportance) {
453
+ result.skipped.below_importance += 1;
454
+ continue;
455
+ }
456
+
457
+ const boundary = extractBoundaryMetadata(entry.metadata);
458
+ const category = (boundary?.originalCategory ?? "events") as DurableMemoryCategory;
459
+
460
+ const candidate: SynthesisPromoteCandidate = {
461
+ seedId: entry.id,
462
+ seedText: entry.text,
463
+ category,
464
+ contractVersion,
465
+ evidenceIds: resolved,
466
+ importance: entry.importance,
467
+ promoted: null,
468
+ };
469
+
470
+ if (!cfg.dryRun) {
471
+ candidate.promoted = await deps.promote({
472
+ memoryId: entry.id,
473
+ category,
474
+ scope,
475
+ importance: entry.importance,
476
+ });
477
+ result.promoted += 1;
478
+ }
479
+
480
+ result.candidates.push(candidate);
481
+ }
482
+
483
+ return result;
484
+ }
485
+
486
+ export function formatSynthesisPromoteResult(result: SynthesisPromoteScanResult): string {
487
+ const skippedTotal = Object.values(result.skipped).reduce((a, b) => a + b, 0);
488
+ const lines = [
489
+ `Synthesis promote-scan: ${result.scannedSyntheses} synthesized conclusion(s) examined, ${skippedTotal} skipped.`,
490
+ ];
491
+
492
+ // Always print the rejection breakdown: a scan that promotes nothing should still say
493
+ // why, or "nothing found" and "everything filtered" look the same from outside.
494
+ const reasons = (Object.entries(result.skipped) as Array<[SynthesisSkipReason, number]>)
495
+ .filter(([, n]) => n > 0)
496
+ .map(([reason, n]) => `${reason}=${n}`);
497
+ if (reasons.length > 0) lines.push(`Skipped: ${reasons.join(", ")}`);
498
+
499
+ if (result.candidates.length === 0) {
500
+ lines.push("No promotion candidates found.");
501
+ return lines.join("\n");
502
+ }
503
+
504
+ lines.push(
505
+ result.dryRun
506
+ ? `Found ${result.candidates.length} candidate(s) (dry-run, nothing written):\n`
507
+ : `Promoted ${result.promoted} of ${result.candidates.length} candidate(s):\n`,
508
+ );
509
+
510
+ for (const [i, c] of result.candidates.entries()) {
511
+ lines.push(`### ${i + 1}. [${c.category}] ${c.seedText.split("\n")[0].slice(0, 80)}`);
512
+ lines.push(
513
+ `Evidence: ${c.evidenceIds.length} distinct (${c.evidenceIds.map((id) => id.slice(0, 8)).join(", ")})`,
514
+ );
515
+ lines.push(`Contract: v${c.contractVersion} | importance: ${c.importance.toFixed(2)} | seed: ${c.seedId.slice(0, 8)}`);
516
+ if (c.promoted) {
517
+ lines.push(
518
+ c.promoted.disposition === "conflict" && c.promoted.conflictId
519
+ ? `-> conflict ${c.promoted.conflictId.slice(0, 8)} (manual review)`
520
+ : `-> durable ${c.promoted.id.slice(0, 8)} (${c.promoted.disposition}, key ${c.promoted.canonicalKey})`,
521
+ );
522
+ }
523
+ lines.push("");
524
+ }
525
+ return lines.join("\n");
526
+ }
@@ -1,4 +1,5 @@
1
1
  import type { MemoryEntry, MemorySearchResult } from "./store.js";
2
+ import type { ScopeMatchMode } from "./scope-policy.js";
2
3
 
3
4
  export interface MemoryStoreStats {
4
5
  totalCount: number;
@@ -18,8 +19,16 @@ export type MemoryStoreUpdate = {
18
19
  };
19
20
 
20
21
  export interface MemoryStorePort {
21
- stats(scopeFilter?: string[]): Promise<MemoryStoreStats>;
22
- list(scopeFilter?: string[], category?: string, limit?: number, offset?: number): Promise<MemoryEntry[]>;
22
+ /** scopeMatch 见 scope-policy.ts 的 ScopeMatchMode:缺省 "family"(历史行为,
23
+ * 无冒号 scope 按前缀),"exact" 由调用方在"我给的是一个具体 scope 名"时显式声明。 */
24
+ stats(scopeFilter?: string[], scopeMatch?: ScopeMatchMode): Promise<MemoryStoreStats>;
25
+ list(
26
+ scopeFilter?: string[],
27
+ category?: string,
28
+ limit?: number,
29
+ offset?: number,
30
+ scopeMatch?: ScopeMatchMode,
31
+ ): Promise<MemoryEntry[]>;
23
32
  /** 按 id 批量取真实向量。list()/listPage() 为性能恒返回 vector:[](假空数组),
24
33
  * 任何要做相似度/聚类的消费者必须用这个回填——promote_scan 与 dream 3b 都栽过同一坑。 */
25
34
  getVectors(ids: string[]): Promise<Map<string, number[]>>;
@@ -42,5 +51,25 @@ export interface MemoryStorePort {
42
51
  patchFn: (meta: Record<string, unknown>, entry: MemoryEntry) => Record<string, unknown>,
43
52
  scopeFilter?: string[],
44
53
  ): Promise<MemoryEntry | null>;
45
- vectorSearch(vector: number[], limit?: number, minScore?: number, scopeFilter?: string[]): Promise<MemorySearchResult[]>;
54
+ /** metadata 批量读改写:单锁单 commit 写一批行(≤200/批)。patchFn 在锁内以库中
55
+ * 最新行为底座、按数组序依次应用(同批闭包可传递决策)。单条失败跳过不炸整批,
56
+ * 返回成功写入数。dream 3a/3b/auto-gc 的写放大治理入口(2026-08-14)。 */
57
+ patchMetadataBatch(
58
+ patches: Array<{
59
+ id: string;
60
+ patchFn: (meta: Record<string, unknown>, entry: MemoryEntry) => Record<string, unknown>;
61
+ }>,
62
+ scopeFilter?: string[],
63
+ ): Promise<number>;
64
+ /** Remove version-group metadata from groups with only one surviving member.
65
+ * Implementations should take one membership snapshot and apply repairs under
66
+ * the same write lock so concurrent group creation cannot invalidate the plan. */
67
+ repairSingletonVersionGroups(): Promise<number>;
68
+ vectorSearch(
69
+ vector: number[],
70
+ limit?: number,
71
+ minScore?: number,
72
+ scopeFilter?: string[],
73
+ scopeMatch?: ScopeMatchMode,
74
+ ): Promise<MemorySearchResult[]>;
46
75
  }