@remnic/core 9.3.744 → 9.3.745

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 (241) hide show
  1. package/dist/access-boundary.d.ts +17 -15
  2. package/dist/access-boundary.js +14 -15
  3. package/dist/access-cli.js +92 -33
  4. package/dist/access-cli.js.map +1 -1
  5. package/dist/access-http.d.ts +17 -15
  6. package/dist/access-http.js +20 -21
  7. package/dist/access-mcp.d.ts +17 -15
  8. package/dist/access-mcp.js +18 -19
  9. package/dist/access-operations-batch.js +16 -17
  10. package/dist/access-operations.d.ts +17 -15
  11. package/dist/access-operations.js +17 -18
  12. package/dist/access-schema.js +3 -3
  13. package/dist/{access-service-OT3Gk79W.d.ts → access-service-CdY88L86.d.ts} +3 -30
  14. package/dist/access-service.d.ts +7 -5
  15. package/dist/access-service.js +13 -14
  16. package/dist/access-surface-catalog.d.ts +17 -15
  17. package/dist/action-confidence.d.ts +1 -1
  18. package/dist/active-memory-bridge.d.ts +1 -1
  19. package/dist/active-recall.d.ts +1 -1
  20. package/dist/{auto-sync-XHSKKQAH.js → auto-sync-IEBKPGAM.js} +2 -2
  21. package/dist/behavior-learner.d.ts +1 -1
  22. package/dist/behavior-signals.d.ts +1 -1
  23. package/dist/bootstrap.d.ts +6 -4
  24. package/dist/briefing.d.ts +1 -1
  25. package/dist/briefing.js +3 -3
  26. package/dist/buffer-surprise-report.d.ts +1 -1
  27. package/dist/buffer.d.ts +1 -1
  28. package/dist/calibration.d.ts +1 -1
  29. package/dist/capabilities.d.ts +1 -1
  30. package/dist/{catalog-CX0CjOaf.d.ts → catalog-D_ZDxu8Y.d.ts} +1 -1
  31. package/dist/causal-behavior.d.ts +1 -1
  32. package/dist/causal-consolidation.d.ts +1 -1
  33. package/dist/causal-consolidation.js +4 -4
  34. package/dist/causal-trajectory-graph.d.ts +1 -1
  35. package/dist/{chunk-WPLRJ4XM.js → chunk-24YDB5QP.js} +2098 -230
  36. package/dist/chunk-24YDB5QP.js.map +1 -0
  37. package/dist/{chunk-6XG3NQRC.js → chunk-3IJGF6K2.js} +2 -2
  38. package/dist/{chunk-U5WVRMCA.js → chunk-5IN26V6W.js} +2 -2
  39. package/dist/{chunk-XEADGIAA.js → chunk-DIHCFJVV.js} +7 -7
  40. package/dist/{chunk-C7LDPZJJ.js → chunk-EHYB325U.js} +227 -227
  41. package/dist/chunk-EHYB325U.js.map +1 -0
  42. package/dist/{chunk-E7RGJQWP.js → chunk-HIHCB2S6.js} +2 -2
  43. package/dist/{chunk-JFCS6PQU.js → chunk-JHQUIH3W.js} +5 -5
  44. package/dist/{chunk-MXDG6SRH.js → chunk-JRDPT6DA.js} +2 -2
  45. package/dist/{chunk-BYLC4DKI.js → chunk-KMNYXLPL.js} +2 -2
  46. package/dist/{chunk-TQPSOTC7.js → chunk-L4N7SVNN.js} +8 -10
  47. package/dist/{chunk-TQPSOTC7.js.map → chunk-L4N7SVNN.js.map} +1 -1
  48. package/dist/{chunk-DYK27X7V.js → chunk-LMFS64GY.js} +3 -3
  49. package/dist/{chunk-FWXLKH2H.js → chunk-NVYF5OB6.js} +3 -3
  50. package/dist/{chunk-G5FP7VSQ.js → chunk-P4FW3ZU5.js} +58 -60
  51. package/dist/{chunk-G5FP7VSQ.js.map → chunk-P4FW3ZU5.js.map} +1 -1
  52. package/dist/{chunk-56F6EAQ6.js → chunk-Q3X7GUAQ.js} +2 -2
  53. package/dist/{chunk-7N7S2RKF.js → chunk-R2OAN6IP.js} +2 -2
  54. package/dist/{chunk-CHELXDTO.js → chunk-RB6A7OUN.js} +2 -2
  55. package/dist/{chunk-3EA5LIFJ.js → chunk-SIV3JZPP.js} +2 -2
  56. package/dist/{chunk-IP5SANO2.js → chunk-UIKZZ242.js} +2 -2
  57. package/dist/{chunk-UGFPGWJB.js → chunk-URQLO7BX.js} +2 -2
  58. package/dist/{chunk-5UNSGRCR.js → chunk-WK3NK6SP.js} +162 -16
  59. package/dist/chunk-WK3NK6SP.js.map +1 -0
  60. package/dist/{chunk-VE54E6RP.js → chunk-WWVXXSLF.js} +4 -6
  61. package/dist/{chunk-VE54E6RP.js.map → chunk-WWVXXSLF.js.map} +1 -1
  62. package/dist/{chunk-WO22UEM3.js → chunk-XTEWVF3C.js} +2 -2
  63. package/dist/{chunk-SODQQCAZ.js → chunk-YQX63P3W.js} +18011 -12597
  64. package/dist/chunk-YQX63P3W.js.map +1 -0
  65. package/dist/{chunk-FCQ7ZYCQ.js → chunk-ZGAJB74V.js} +2 -2
  66. package/dist/{chunk-NBP3YHHS.js → chunk-ZGZNLRKF.js} +2 -2
  67. package/dist/{chunk-C7C5WIIF.js → chunk-ZPZ4VVLT.js} +2 -2
  68. package/dist/{cli-BQiTZ7Zs.d.ts → cli-BNJtn0R0.d.ts} +3 -3
  69. package/dist/cli.d.ts +9 -7
  70. package/dist/cli.js +44 -46
  71. package/dist/compounding/engine.d.ts +1 -1
  72. package/dist/compounding/engine.js +3 -3
  73. package/dist/compounding/preference-consolidator.d.ts +1 -1
  74. package/dist/compression-optimizer.d.ts +1 -1
  75. package/dist/config.d.ts +1 -1
  76. package/dist/connectors/codex-materialize-runner.d.ts +1 -1
  77. package/dist/connectors/codex-materialize-runner.js +3 -3
  78. package/dist/connectors/codex-materialize.d.ts +1 -1
  79. package/dist/connectors/index.d.ts +1 -1
  80. package/dist/connectors/index.js +3 -3
  81. package/dist/consolidation-provenance-check.d.ts +1 -1
  82. package/dist/consolidation-undo.d.ts +1 -1
  83. package/dist/contradiction/index.d.ts +1 -1
  84. package/dist/contradiction/index.js +4 -4
  85. package/dist/conversation-index/backend.d.ts +1 -1
  86. package/dist/conversation-index/chunker.d.ts +1 -1
  87. package/dist/conversation-index/faiss-adapter.d.ts +1 -1
  88. package/dist/conversation-index/indexer.d.ts +1 -1
  89. package/dist/conversation-index/search.d.ts +1 -1
  90. package/dist/day-summary.d.ts +1 -1
  91. package/dist/delinearize.d.ts +1 -1
  92. package/dist/direct-answer-wiring.d.ts +1 -1
  93. package/dist/direct-answer.d.ts +1 -1
  94. package/dist/embedding-fallback.d.ts +1 -1
  95. package/dist/enrichment/index.d.ts +1 -1
  96. package/dist/entity-retrieval.d.ts +1 -1
  97. package/dist/entity-retrieval.js +3 -3
  98. package/dist/entity-schema.d.ts +1 -1
  99. package/dist/explicit-capture.d.ts +6 -4
  100. package/dist/extraction-faithfulness.d.ts +1 -1
  101. package/dist/extraction-judge-telemetry.d.ts +1 -1
  102. package/dist/extraction-judge-training.d.ts +1 -1
  103. package/dist/extraction-judge.d.ts +1 -1
  104. package/dist/extraction.d.ts +1 -1
  105. package/dist/fallback-llm.d.ts +1 -1
  106. package/dist/graph-dashboard-diff.d.ts +1 -1
  107. package/dist/graph-dashboard-key.d.ts +1 -1
  108. package/dist/graph-dashboard-parser.d.ts +1 -1
  109. package/dist/graph-edge-reinforcement.d.ts +1 -1
  110. package/dist/graph-snapshot.d.ts +1 -1
  111. package/dist/graph.d.ts +1 -1
  112. package/dist/identity-continuity.d.ts +1 -1
  113. package/dist/importance.d.ts +1 -1
  114. package/dist/index.d.ts +12 -10
  115. package/dist/index.js +271 -5362
  116. package/dist/index.js.map +1 -1
  117. package/dist/intent.d.ts +1 -1
  118. package/dist/lcm/engine.d.ts +1 -1
  119. package/dist/lcm/index.d.ts +1 -1
  120. package/dist/lcm/tools.d.ts +1 -1
  121. package/dist/lifecycle.d.ts +1 -1
  122. package/dist/live-connectors-runner.d.ts +1 -1
  123. package/dist/local-llm.d.ts +1 -1
  124. package/dist/local-model-endpoint.d.ts +1 -1
  125. package/dist/maintenance/memory-governance.d.ts +1 -1
  126. package/dist/maintenance/memory-governance.js +3 -3
  127. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -3
  128. package/dist/maintenance/rebuild-memory-projection.js +4 -4
  129. package/dist/mcp-memory-inspector-app.d.ts +16 -14
  130. package/dist/memory-action-policy.d.ts +1 -1
  131. package/dist/memory-cache.d.ts +1 -1
  132. package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
  133. package/dist/memory-projection-store.d.ts +1 -1
  134. package/dist/memory-provenance.d.ts +1 -1
  135. package/dist/memory-worth-outcomes.d.ts +1 -1
  136. package/dist/models-json.d.ts +1 -1
  137. package/dist/namespaces/migrate.d.ts +2 -2
  138. package/dist/namespaces/migrate.js +4 -4
  139. package/dist/namespaces/principal.d.ts +1 -1
  140. package/dist/namespaces/search.d.ts +1 -1
  141. package/dist/namespaces/storage.d.ts +2 -2
  142. package/dist/namespaces/storage.js +3 -3
  143. package/dist/native-knowledge.d.ts +1 -1
  144. package/dist/operator-toolkit.d.ts +1 -1
  145. package/dist/operator-toolkit.js +10 -11
  146. package/dist/orchestration/compression-guideline-coordinator.d.ts +1 -1
  147. package/dist/orchestration/maintenance.d.ts +2 -2
  148. package/dist/orchestration/maintenance.js +5 -5
  149. package/dist/{orchestrator-BPQGHpJz.d.ts → orchestrator-BBGXLd20.d.ts} +169 -7
  150. package/dist/orchestrator.d.ts +6 -4
  151. package/dist/orchestrator.js +119 -16
  152. package/dist/patterns-cli.d.ts +1 -1
  153. package/dist/policy-runtime.d.ts +1 -1
  154. package/dist/provenance.d.ts +1 -1
  155. package/dist/qmd-recall-cache.d.ts +1 -1
  156. package/dist/qmd.d.ts +1 -1
  157. package/dist/recall-disclosure-escalation.d.ts +1 -1
  158. package/dist/recall-explain-renderer.d.ts +1 -1
  159. package/dist/recall-planner-llm.d.ts +1 -1
  160. package/dist/recall-state.d.ts +1 -1
  161. package/dist/recall-tag-filter.d.ts +1 -1
  162. package/dist/recall-xray-cli.d.ts +1 -1
  163. package/dist/recall-xray-renderer.d.ts +1 -1
  164. package/dist/recall-xray.d.ts +1 -1
  165. package/dist/resolve-auth-token.d.ts +1 -1
  166. package/dist/retrieval-agents.d.ts +1 -1
  167. package/dist/retrieval-tiers.d.ts +1 -1
  168. package/dist/routing/engine.d.ts +1 -1
  169. package/dist/routing/store.d.ts +1 -1
  170. package/dist/search/embed-helper.d.ts +1 -1
  171. package/dist/search/factory.d.ts +1 -1
  172. package/dist/search/index.d.ts +1 -1
  173. package/dist/search/lancedb-backend.d.ts +1 -1
  174. package/dist/search/meilisearch-backend.d.ts +1 -1
  175. package/dist/search/noop-backend.d.ts +1 -1
  176. package/dist/search/orama-backend.d.ts +1 -1
  177. package/dist/search/port.d.ts +1 -1
  178. package/dist/search/remote-backend.d.ts +1 -1
  179. package/dist/{semantic-consolidation-ByzGwC9c.d.ts → semantic-consolidation-D3aSiu9t.d.ts} +1 -1
  180. package/dist/semantic-consolidation.d.ts +2 -2
  181. package/dist/semantic-consolidation.js +4 -4
  182. package/dist/semantic-rule-promotion.js +3 -3
  183. package/dist/semantic-rule-verifier.d.ts +1 -1
  184. package/dist/semantic-rule-verifier.js +3 -3
  185. package/dist/session-observer-bands.d.ts +1 -1
  186. package/dist/session-observer-state.d.ts +1 -1
  187. package/dist/shared-context/manager.d.ts +1 -1
  188. package/dist/signal.d.ts +1 -1
  189. package/dist/storage.d.ts +1 -1
  190. package/dist/storage.js +2 -2
  191. package/dist/summarizer.d.ts +1 -1
  192. package/dist/summary-snapshot.d.ts +1 -1
  193. package/dist/temporal-supersession.d.ts +1 -1
  194. package/dist/temporal-validity.d.ts +1 -1
  195. package/dist/threading.d.ts +1 -1
  196. package/dist/tier-migration.d.ts +1 -1
  197. package/dist/tier-routing.d.ts +1 -1
  198. package/dist/topics.d.ts +1 -1
  199. package/dist/transcript.d.ts +1 -1
  200. package/dist/transfer/backup.js +2 -2
  201. package/dist/transfer/capsule-export.js +2 -2
  202. package/dist/transfer/capsule-import.js +1 -1
  203. package/dist/trust-score-stage.d.ts +1 -1
  204. package/dist/trust-score.d.ts +1 -1
  205. package/dist/{types-5Jik3r4G.d.ts → types-CZm6rx9-.d.ts} +1 -1
  206. package/dist/types.d.ts +1 -1
  207. package/dist/utility-runtime.d.ts +1 -1
  208. package/dist/verified-recall.js +3 -3
  209. package/package.json +2 -2
  210. package/src/index.ts +2 -0
  211. package/src/orchestration/extraction-persist.ts +2785 -0
  212. package/src/orchestration/extraction-run.ts +874 -0
  213. package/src/orchestrator.ts +167 -3277
  214. package/dist/chunk-5UNSGRCR.js.map +0 -1
  215. package/dist/chunk-A2KKLCOS.js +0 -1889
  216. package/dist/chunk-A2KKLCOS.js.map +0 -1
  217. package/dist/chunk-AVBT5WEW.js +0 -153
  218. package/dist/chunk-AVBT5WEW.js.map +0 -1
  219. package/dist/chunk-C7LDPZJJ.js.map +0 -1
  220. package/dist/chunk-SODQQCAZ.js.map +0 -1
  221. package/dist/chunk-WPLRJ4XM.js.map +0 -1
  222. /package/dist/{auto-sync-XHSKKQAH.js.map → auto-sync-IEBKPGAM.js.map} +0 -0
  223. /package/dist/{chunk-6XG3NQRC.js.map → chunk-3IJGF6K2.js.map} +0 -0
  224. /package/dist/{chunk-U5WVRMCA.js.map → chunk-5IN26V6W.js.map} +0 -0
  225. /package/dist/{chunk-XEADGIAA.js.map → chunk-DIHCFJVV.js.map} +0 -0
  226. /package/dist/{chunk-E7RGJQWP.js.map → chunk-HIHCB2S6.js.map} +0 -0
  227. /package/dist/{chunk-JFCS6PQU.js.map → chunk-JHQUIH3W.js.map} +0 -0
  228. /package/dist/{chunk-MXDG6SRH.js.map → chunk-JRDPT6DA.js.map} +0 -0
  229. /package/dist/{chunk-BYLC4DKI.js.map → chunk-KMNYXLPL.js.map} +0 -0
  230. /package/dist/{chunk-DYK27X7V.js.map → chunk-LMFS64GY.js.map} +0 -0
  231. /package/dist/{chunk-FWXLKH2H.js.map → chunk-NVYF5OB6.js.map} +0 -0
  232. /package/dist/{chunk-56F6EAQ6.js.map → chunk-Q3X7GUAQ.js.map} +0 -0
  233. /package/dist/{chunk-7N7S2RKF.js.map → chunk-R2OAN6IP.js.map} +0 -0
  234. /package/dist/{chunk-CHELXDTO.js.map → chunk-RB6A7OUN.js.map} +0 -0
  235. /package/dist/{chunk-3EA5LIFJ.js.map → chunk-SIV3JZPP.js.map} +0 -0
  236. /package/dist/{chunk-IP5SANO2.js.map → chunk-UIKZZ242.js.map} +0 -0
  237. /package/dist/{chunk-UGFPGWJB.js.map → chunk-URQLO7BX.js.map} +0 -0
  238. /package/dist/{chunk-WO22UEM3.js.map → chunk-XTEWVF3C.js.map} +0 -0
  239. /package/dist/{chunk-FCQ7ZYCQ.js.map → chunk-ZGAJB74V.js.map} +0 -0
  240. /package/dist/{chunk-NBP3YHHS.js.map → chunk-ZGZNLRKF.js.map} +0 -0
  241. /package/dist/{chunk-C7C5WIIF.js.map → chunk-ZPZ4VVLT.js.map} +0 -0
@@ -0,0 +1,874 @@
1
+ /**
2
+ * Extraction-run coordinator — extracted from the orchestrator
3
+ * (issue #1526, seam 15).
4
+ *
5
+ * Owns the buffer→extract→persist pipeline orchestration:
6
+ * - `runExtraction` — the main extraction pipeline coordinator
7
+ * (normalize → threshold gate → namespace resolve → dedupe
8
+ * fingerprint → extract → validate → persist → post-write hooks)
9
+ * - dedupe support: `shouldQueueExtraction`,
10
+ * `buildExtractionFingerprint`, `normalizeExtractionFingerprintTurns`
11
+ * - `recordProcessedExtractionFingerprint` — meta recording
12
+ *
13
+ * The orchestrator constructs one instance and delegates the extraction
14
+ * run to it. `persistExtraction`, `maybeCapturePassiveCorrections`, and
15
+ * all post-write side-effect helpers arrive as injectable delegates so
16
+ * the orchestrator keeps ownership of cross-subsystem routing and the
17
+ * behavior-critical chokepoints it hosts.
18
+ *
19
+ * Behavior-preserving move from orchestrator.ts. No logic changes — the
20
+ * orchestrator keeps thin delegating methods so existing call sites
21
+ * and tests that exercise the public API continue to work.
22
+ */
23
+
24
+ import { createHash } from "node:crypto";
25
+ import type { StorageManager } from "../index.js";
26
+ import { NamespaceStorageRouter } from "../namespaces/storage.js";
27
+ import type { ExtractionEngine } from "../extraction.js";
28
+ import type { SmartBuffer } from "../buffer.js";
29
+ import type { ThreadingManager } from "../threading.js";
30
+ import type { BoxBuilder } from "../boxes.js";
31
+ import {
32
+ resolvePrincipal,
33
+ defaultNamespaceForPrincipal,
34
+ } from "../namespaces/principal.js";
35
+ import {
36
+ resolveScopeProfilePlan,
37
+ type ResolvedScopeProfilePlan,
38
+ } from "../namespaces/scope-profiles.js";
39
+ import { resolveCodingNamespaceOverlay } from "../coding/coding-namespace.js";
40
+ import type { CodingContext } from "../types.js";
41
+ import {
42
+ resolvePresentationCapabilities,
43
+ resolveMemoryLifecycleCapabilities,
44
+ type GraphConstructionCapabilitySet,
45
+ type MemoryLifecycleCapabilitySet,
46
+ } from "../capabilities.js";
47
+ import { throwIfAborted as sharedThrowIfAborted, abortError as sharedAbortError } from "../abort-error.js";
48
+ import { parseFlexibleIsoTimestamp } from "../utils/iso-timestamp.js";
49
+ import { log } from "../logger.js";
50
+ import type {
51
+ PluginConfig,
52
+ BufferTurn,
53
+ ExtractionResult,
54
+ } from "../types.js";
55
+ import type { TierMigrationCycleSummary } from "../recall-state.js";
56
+
57
+ export interface ExtractionRunResult {
58
+ status: "completed" | "skipped";
59
+ reason?: string;
60
+ persistedCount: number;
61
+ durableOutputCount: number;
62
+ postPersistMetadataFailed?: boolean;
63
+ }
64
+
65
+ /** Dependencies injected by the orchestrator. All stable references or
66
+ * live accessors — lazy getters for anything tests reassign
67
+ * post-construction (buffer, extraction, storageRouter, threading). */
68
+ export interface ExtractionRunCoordinatorDeps {
69
+ config: PluginConfig;
70
+ getBuffer: () => SmartBuffer;
71
+ getExtraction: () => ExtractionEngine;
72
+ getStorageRouter: () => NamespaceStorageRouter;
73
+ getThreading: () => ThreadingManager;
74
+
75
+ persistExtraction: (
76
+ result: ExtractionResult,
77
+ storage: StorageManager,
78
+ threadIdForExtraction?: string | null,
79
+ sourceContext?: { sessionKey?: string; principal?: string; validAt?: string },
80
+ baseNamespace?: string,
81
+ scopeProfileWritePlan?: ResolvedScopeProfilePlan | null,
82
+ sourceText?: string,
83
+ graphCaps?: GraphConstructionCapabilitySet,
84
+ lifecycleCaps?: MemoryLifecycleCapabilitySet,
85
+ ) => Promise<string[]>;
86
+
87
+ maybeCapturePassiveCorrections: (
88
+ turns: readonly BufferTurn[],
89
+ opts: {
90
+ sessionKey: string;
91
+ principal?: string;
92
+ namespace: string;
93
+ bufferKey: string;
94
+ isLiveSession: boolean;
95
+ },
96
+ ) => Promise<void>;
97
+
98
+ resolveSelfNamespace: (sessionKey?: string) => string;
99
+ getCodingContextForSession: (sessionKey: string) => CodingContext | null;
100
+ applyCodingNamespaceOverlay: (sessionKey: string, namespace: string) => string;
101
+ boxBuilderFor: (storage: StorageManager) => BoxBuilder;
102
+ appendPersistedThreadEpisodes: (
103
+ threadId: string,
104
+ ids: string[],
105
+ ) => Promise<void>;
106
+ maybeScheduleConsolidation: (nonZero: boolean) => void;
107
+ requestQmdMaintenance: () => void;
108
+ runTierMigrationCycle: (
109
+ storage: StorageManager,
110
+ trigger: "extraction" | "maintenance" | "manual",
111
+ options?: {
112
+ dryRun?: boolean;
113
+ limitOverride?: number;
114
+ force?: boolean;
115
+ },
116
+ ) => Promise<TierMigrationCycleSummary>;
117
+ getLastPersistExtractionDeferredCount: () => number;
118
+ recordProcessedExtractionFingerprint: (
119
+ storage: StorageManager,
120
+ fingerprint: string,
121
+ preloadedMeta?: Awaited<ReturnType<StorageManager["loadMeta"]>>,
122
+ ) => Promise<void>;
123
+ }
124
+
125
+ // Recall/extraction-specific abort helpers. Thin wrappers over the shared
126
+ // `abort-error.ts` module.
127
+ const abortRecallError = sharedAbortError;
128
+
129
+ function throwIfRecallAborted(
130
+ signal?: AbortSignal,
131
+ message = "recall aborted",
132
+ ): void {
133
+ sharedThrowIfAborted(signal, message);
134
+ }
135
+
136
+ async function raceRecallAbort<T>(
137
+ promise: Promise<T>,
138
+ signal?: AbortSignal,
139
+ message = "recall aborted",
140
+ ): Promise<T> {
141
+ throwIfRecallAborted(signal, message);
142
+ if (!signal) return promise;
143
+
144
+ let onAbort: (() => void) | null = null;
145
+ const abortPromise = new Promise<T>((_resolve, reject) => {
146
+ onAbort = () => reject(abortRecallError(message));
147
+ signal.addEventListener("abort", onAbort, { once: true });
148
+ });
149
+
150
+ try {
151
+ return await Promise.race([promise, abortPromise]);
152
+ } finally {
153
+ if (onAbort) {
154
+ signal.removeEventListener("abort", onAbort);
155
+ }
156
+ }
157
+ }
158
+
159
+ export function deriveTopicsFromExtraction(result: ExtractionResult): string[] {
160
+ const topics = new Set<string>();
161
+ for (const fact of result.facts ?? []) {
162
+ for (const tag of fact.tags ?? []) {
163
+ if (tag && tag.length >= 2) topics.add(tag.toLowerCase());
164
+ }
165
+ if (fact.entityRef) topics.add(fact.entityRef.toLowerCase());
166
+ if (fact.category) topics.add(fact.category);
167
+ }
168
+ for (const entity of (result as any).entities ?? []) {
169
+ if (typeof entity.name === "string" && entity.name.length >= 2) {
170
+ topics.add(entity.name.toLowerCase());
171
+ }
172
+ }
173
+ return [...topics].slice(0, 16);
174
+ }
175
+
176
+ /**
177
+ * Coordinates the extraction run pipeline. Holds the dedupe fingerprint
178
+ * cache (`recentExtractionFingerprints`) and delegates all side effects
179
+ * to injected orchestrator methods.
180
+ */
181
+ export class ExtractionRunCoordinator {
182
+ private readonly recentExtractionFingerprints = new Map<string, number>();
183
+
184
+ constructor(
185
+ private readonly deps: ExtractionRunCoordinatorDeps,
186
+ ) {}
187
+
188
+ private get config(): PluginConfig {
189
+ return this.deps.config;
190
+ }
191
+
192
+ // -------------------------------------------------------------------------
193
+ // Source-valid-at helpers (moved from orchestrator.ts module scope)
194
+ // -------------------------------------------------------------------------
195
+
196
+ private latestSourceValidAtFromTurns(turns: readonly BufferTurn[]): string | undefined {
197
+ let latestMs: number | null = null;
198
+ for (const turn of turns) {
199
+ if (turn.extractionContextOnly === true) continue;
200
+ if (typeof turn.sourceValidAt !== "string") continue;
201
+ const parsed = parseFlexibleIsoTimestamp(turn.sourceValidAt.trim());
202
+ if (parsed === null) continue;
203
+ if (latestMs === null || parsed > latestMs) {
204
+ latestMs = parsed;
205
+ }
206
+ }
207
+ return latestMs === null ? undefined : new Date(latestMs).toISOString();
208
+ }
209
+
210
+
211
+ // -------------------------------------------------------------------------
212
+ // Extraction dedupe fingerprint helpers
213
+ // -------------------------------------------------------------------------
214
+
215
+ normalizeExtractionFingerprintTurns(turns: BufferTurn[]): string[] {
216
+ if (!Array.isArray(turns) || turns.length === 0) return [];
217
+ return turns
218
+ .filter((turn) => turn.role === "user" || turn.role === "assistant")
219
+ .map((turn) => {
220
+ if (
221
+ typeof turn.turnFingerprint === "string" &&
222
+ turn.turnFingerprint.length > 0
223
+ ) {
224
+ return `fp:${turn.turnFingerprint}`;
225
+ }
226
+ return `${turn.role}:${(turn.content ?? "").replace(/\s+/g, " ").trim().slice(0, this.config.extractionMaxTurnChars)}`;
227
+ })
228
+ .filter((value) => value.length > 0);
229
+ }
230
+
231
+
232
+ buildExtractionFingerprint(
233
+ turns: BufferTurn[],
234
+ bufferKey: string,
235
+ ): string | null {
236
+ const normalized = this.normalizeExtractionFingerprintTurns(turns).join("\n");
237
+ if (!normalized) return null;
238
+ return createHash("sha256")
239
+ .update(`${bufferKey}\n${normalized}`)
240
+ .digest("hex");
241
+ }
242
+
243
+
244
+
245
+ shouldQueueExtraction(
246
+ turns: BufferTurn[],
247
+ options: { commit?: boolean; bufferKey?: string } = {},
248
+ ): boolean {
249
+ const lifecycleCaps = resolveMemoryLifecycleCapabilities(this.config);
250
+ if (!lifecycleCaps.extractionDedupe) return true;
251
+ if (!Array.isArray(turns) || turns.length === 0) return false;
252
+
253
+ const bufferKey = options.bufferKey ?? turns[0]?.sessionKey ?? "default";
254
+ const fingerprint = this.buildExtractionFingerprint(turns, bufferKey);
255
+ if (!fingerprint) return false;
256
+ const now = Date.now();
257
+ const seenAt = this.recentExtractionFingerprints.get(fingerprint);
258
+ if (seenAt && now - seenAt < this.config.extractionDedupeWindowMs) {
259
+ log.debug("extraction dedupe: skipped duplicate buffered turn set");
260
+ return false;
261
+ }
262
+
263
+ if (options.commit !== false) {
264
+ this.recentExtractionFingerprints.set(fingerprint, now);
265
+ }
266
+ // Keep this cache bounded to avoid unbounded growth.
267
+ if (
268
+ options.commit !== false &&
269
+ this.recentExtractionFingerprints.size > 200
270
+ ) {
271
+ const entries = Array.from(
272
+ this.recentExtractionFingerprints.entries(),
273
+ ).sort((a, b) => a[1] - b[1]);
274
+ for (const [key] of entries.slice(0, entries.length - 200)) {
275
+ this.recentExtractionFingerprints.delete(key);
276
+ }
277
+ }
278
+
279
+ return true;
280
+ }
281
+
282
+
283
+ // -------------------------------------------------------------------------
284
+ // Processed-fingerprint recording
285
+ // -------------------------------------------------------------------------
286
+
287
+ async recordProcessedExtractionFingerprint(
288
+ storage: StorageManager,
289
+ fingerprint: string,
290
+ preloadedMeta?: Awaited<ReturnType<StorageManager["loadMeta"]>>,
291
+ ): Promise<void> {
292
+ const meta = preloadedMeta ?? (await storage.loadMeta());
293
+ const observedAt = new Date().toISOString();
294
+ const seen = new Map(
295
+ (meta.processedExtractionFingerprints ?? []).map((entry) => [
296
+ entry.fingerprint,
297
+ entry.observedAt,
298
+ ]),
299
+ );
300
+ seen.set(fingerprint, observedAt);
301
+ meta.processedExtractionFingerprints = Array.from(seen.entries())
302
+ .map(([value, at]) => ({ fingerprint: value, observedAt: at }))
303
+ .sort((left, right) => left.observedAt.localeCompare(right.observedAt))
304
+ .slice(-500);
305
+ if (!preloadedMeta) {
306
+ await storage.saveMeta(meta);
307
+ }
308
+ }
309
+
310
+
311
+ // -------------------------------------------------------------------------
312
+ // Main extraction pipeline
313
+ // -------------------------------------------------------------------------
314
+
315
+ async runExtraction(
316
+ turns: BufferTurn[],
317
+ options: {
318
+ clearBufferAfterExtraction?: boolean;
319
+ skipCharThreshold?: boolean;
320
+ skipUserTurnThreshold?: boolean;
321
+ deadlineMs?: number;
322
+ bufferKey?: string;
323
+ abortSignal?: AbortSignal;
324
+ failOnExtractionFailure?: boolean;
325
+ /**
326
+ * Explicit namespace override for the write path (#460). When set,
327
+ * extraction writes go to this namespace instead of the one derived
328
+ * from `defaultNamespaceForPrincipal(resolvePrincipal(sessionKey))`.
329
+ * The resolved `principal` is still threaded into memory metadata
330
+ * for provenance; only the storage target is overridden.
331
+ */
332
+ writeNamespaceOverride?: string;
333
+ /**
334
+ * Pin the provenance principal instead of deriving it from
335
+ * `resolvePrincipal(sessionKey)` (#1495 thread 1). When set, this is the
336
+ * identity an access surface already authenticated; used so observed-turn
337
+ * provenance is correct even though `turn.sessionKey` is the ORIGINAL
338
+ * (un-prefixed) key and storage is pinned via `writeNamespaceOverride`.
339
+ */
340
+ principalOverride?: string;
341
+ } = {},
342
+ ): Promise<ExtractionRunResult> {
343
+ log.debug(`running extraction on ${turns.length} turns`);
344
+ const clearBufferAfterExtraction =
345
+ options.clearBufferAfterExtraction ?? true;
346
+ const skipCharThreshold = options.skipCharThreshold ?? false;
347
+ const skipUserTurnThreshold = options.skipUserTurnThreshold ?? false;
348
+ const deadlineMs =
349
+ typeof options.deadlineMs === "number" &&
350
+ Number.isFinite(options.deadlineMs)
351
+ ? options.deadlineMs
352
+ : undefined;
353
+ const bufferKey = options.bufferKey ?? turns[0]?.sessionKey ?? "default";
354
+ const throwIfDeadlineExceeded = (stage: string): void => {
355
+ if (typeof deadlineMs === "number" && Date.now() > deadlineMs) {
356
+ throw new Error(`replay extraction deadline exceeded (${stage})`);
357
+ }
358
+ };
359
+ const throwIfAborted = (stage: string): void => {
360
+ throwIfRecallAborted(options.abortSignal, `extraction aborted (${stage})`);
361
+ };
362
+ const clearBuffer = async (options?: { ignoreAbort?: boolean }) => {
363
+ if (options?.ignoreAbort !== true) {
364
+ throwIfAborted("before_clear_buffer");
365
+ }
366
+ if (clearBufferAfterExtraction) {
367
+ await this.deps.getBuffer().clearAfterExtraction(bufferKey, turns);
368
+ }
369
+ };
370
+
371
+ // Skip extraction for cron job sessions - these are system operations, not user conversations
372
+ const sessionKey = turns[0]?.sessionKey ?? "";
373
+ if (sessionKey.includes(":cron:")) {
374
+ log.debug(`skipping extraction for cron session: ${sessionKey}`);
375
+ await clearBuffer();
376
+ return {
377
+ status: "skipped",
378
+ reason: "cron_session",
379
+ persistedCount: 0,
380
+ durableOutputCount: 0,
381
+ };
382
+ }
383
+
384
+ const normalizedTurns = turns
385
+ .filter(
386
+ (t) =>
387
+ (t.role === "user" || t.role === "assistant") &&
388
+ typeof t.content === "string",
389
+ )
390
+ .map((t) => ({
391
+ ...t,
392
+ content: t.content.trim().slice(0, this.config.extractionMaxTurnChars),
393
+ }))
394
+ .filter((t) => t.content.length > 0);
395
+ const targetTurns = normalizedTurns.filter(
396
+ (turn) => turn.extractionContextOnly !== true,
397
+ );
398
+ if (targetTurns.length === 0) {
399
+ log.debug("skipping extraction: no non-context turns after normalization");
400
+ // Context-only turns may still contain corrections (review: "context-only
401
+ // turns skip capture"). Scan normalizedTurns before clearing the buffer.
402
+ if (normalizedTurns.length > 0) {
403
+ await this.deps.maybeCapturePassiveCorrections(normalizedTurns as BufferTurn[], { sessionKey, principal: resolvePrincipal(sessionKey, this.config), namespace: this.deps.resolveSelfNamespace(sessionKey), bufferKey, isLiveSession: clearBufferAfterExtraction });
404
+ }
405
+ await clearBuffer();
406
+ return { status: "skipped", reason: "empty_normalized_turns", persistedCount: 0, durableOutputCount: 0 };
407
+ }
408
+ const sourceValidAt = this.latestSourceValidAtFromTurns(targetTurns);
409
+ throwIfDeadlineExceeded("before_extract");
410
+ throwIfAborted("before_extract");
411
+
412
+ const userTurns = targetTurns.filter((t) => t.role === "user");
413
+ const totalChars = targetTurns.reduce(
414
+ (sum, t) => sum + t.content.length,
415
+ 0,
416
+ );
417
+ const belowCharThreshold = totalChars < this.config.extractionMinChars;
418
+ const belowUserTurnThreshold =
419
+ !skipUserTurnThreshold &&
420
+ userTurns.length < this.config.extractionMinUserTurns;
421
+ if ((!skipCharThreshold && belowCharThreshold) || belowUserTurnThreshold) {
422
+ log.debug(
423
+ `skipping extraction: below threshold (totalChars=${totalChars}, userTurns=${userTurns.length})`,
424
+ );
425
+ await clearBuffer();
426
+ // Passive correction capture runs even when extraction is skipped for
427
+ // being below the char/user-turn threshold (review: "skipped extraction
428
+ // skips capture"). A short correction like "stop using Vim" is under
429
+ // extractionMinChars but is exactly the high-value case this feature
430
+ // exists for. The write namespace resolves through the SAME scope-profile
431
+ // plan as the full extraction path so corrections target the active
432
+ // profile's write layer, not just resolveSelfNamespace (review:
433
+ // "below-threshold wrong namespace"). The ACL in passiveCorrectionService
434
+ // authorizes the actual plan/apply.
435
+ {
436
+ const capturePrincipal =
437
+ typeof options.principalOverride === "string" && options.principalOverride.length > 0
438
+ ? options.principalOverride
439
+ : resolvePrincipal(sessionKey, this.config);
440
+ const captureWO = typeof options.writeNamespaceOverride === "string" && options.writeNamespaceOverride.length > 0
441
+ ? options.writeNamespaceOverride : undefined;
442
+ const captureCodingCtx = sessionKey ? this.deps.getCodingContextForSession(sessionKey) : null;
443
+ const captureCodingOv = resolveCodingNamespaceOverlay(captureCodingCtx, this.config.codingMode, this.config.defaultNamespace);
444
+ const captureScopePlan = resolveScopeProfilePlan({ config: this.config, principal: capturePrincipal, codingContext: captureCodingCtx, codingOverlay: captureCodingOv });
445
+ const captureNamespace = captureWO ?? captureScopePlan?.writeNamespace ?? this.deps.resolveSelfNamespace(sessionKey);
446
+ await this.deps.maybeCapturePassiveCorrections(normalizedTurns as BufferTurn[], {
447
+ sessionKey, principal: capturePrincipal, namespace: captureNamespace, bufferKey, isLiveSession: clearBufferAfterExtraction,
448
+ });
449
+ }
450
+ return {
451
+ status: "skipped",
452
+ reason: "below_threshold",
453
+ persistedCount: 0,
454
+ durableOutputCount: 0,
455
+ };
456
+ }
457
+
458
+ // Provenance principal honours the access-surface override (#1495 thread 1,
459
+ // mirroring the recall path's `principalOverride`, issue #570 PR 4). Access
460
+ // surfaces that authenticated the caller at the transport layer pass their
461
+ // resolved principal so provenance uses the SAME identity the surface
462
+ // authorized, instead of `resolvePrincipal(sessionKey)` — which on a
463
+ // namespace-prefixed key would collapse to `default`. The ORIGINAL,
464
+ // un-prefixed session key still drives threading.
465
+ const principal =
466
+ typeof options.principalOverride === "string" &&
467
+ options.principalOverride.length > 0
468
+ ? options.principalOverride
469
+ : resolvePrincipal(sessionKey, this.config);
470
+ // Write path — explicit callers still win. Otherwise, an active hosted
471
+ // scope profile owns the extraction write target so hook-captured turns land
472
+ // in the same layer that profile recall searches. Without a profile, preserve
473
+ // the existing coding-agent overlay behavior (issue #569).
474
+ const explicitWriteNamespace =
475
+ typeof options.writeNamespaceOverride === "string" &&
476
+ options.writeNamespaceOverride.length > 0
477
+ ? options.writeNamespaceOverride
478
+ : undefined;
479
+ const codingContextForWrite = sessionKey
480
+ ? this.deps.getCodingContextForSession(sessionKey)
481
+ : null;
482
+ const codingOverlayForWrite = resolveCodingNamespaceOverlay(
483
+ codingContextForWrite,
484
+ this.config.codingMode,
485
+ this.config.defaultNamespace,
486
+ );
487
+ const scopeProfileGatePlan = resolveScopeProfilePlan({
488
+ config: this.config,
489
+ principal,
490
+ codingContext: codingContextForWrite,
491
+ codingOverlay: codingOverlayForWrite,
492
+ });
493
+ const scopeProfileWritePlan = explicitWriteNamespace ? null : scopeProfileGatePlan;
494
+ if (scopeProfileWritePlan) {
495
+ const selectedLayer = scopeProfileWritePlan.layers.find(
496
+ (layer) => layer.id === scopeProfileWritePlan.writeLayer,
497
+ );
498
+ const writeNamespaceReadable = scopeProfileWritePlan.readNamespaces.includes(
499
+ scopeProfileWritePlan.writeNamespace,
500
+ );
501
+ if (!selectedLayer?.writable || !writeNamespaceReadable) {
502
+ log.warn(
503
+ `runExtraction: skipping scope profile ${scopeProfileWritePlan.profileId} because write layer ${scopeProfileWritePlan.writeLayer} is not writable inside the profile read stack`,
504
+ );
505
+ await clearBuffer();
506
+ return {
507
+ status: "skipped",
508
+ reason: "scope_profile_no_writable_layer",
509
+ persistedCount: 0,
510
+ durableOutputCount: 0,
511
+ };
512
+ }
513
+ }
514
+ const selfNamespace =
515
+ explicitWriteNamespace ??
516
+ scopeProfileWritePlan?.writeNamespace ??
517
+ this.deps.applyCodingNamespaceOverlay(
518
+ sessionKey,
519
+ defaultNamespaceForPrincipal(principal, this.config),
520
+ );
521
+ const storage = await this.deps.getStorageRouter().storageFor(selfNamespace);
522
+ const shouldPersistProcessedFingerprint = targetTurns.some(
523
+ (turn) => turn.persistProcessedFingerprint === true,
524
+ );
525
+ const extractionFingerprint = this.buildExtractionFingerprint(
526
+ targetTurns,
527
+ bufferKey,
528
+ );
529
+ let meta =
530
+ extractionFingerprint && shouldPersistProcessedFingerprint
531
+ ? await storage.loadMeta()
532
+ : null;
533
+ if (
534
+ extractionFingerprint &&
535
+ shouldPersistProcessedFingerprint &&
536
+ (meta?.processedExtractionFingerprints ?? []).some(
537
+ (entry) => entry.fingerprint === extractionFingerprint,
538
+ )
539
+ ) {
540
+ log.debug(
541
+ `runExtraction: skipping already-processed extraction fingerprint for ${bufferKey}`,
542
+ );
543
+ await clearBuffer();
544
+ return {
545
+ status: "skipped",
546
+ reason: "processed_fingerprint",
547
+ persistedCount: 0,
548
+ durableOutputCount: 0,
549
+ };
550
+ }
551
+
552
+ // Pass existing entity names so the LLM can reuse them instead of inventing variants
553
+ const existingEntities = await storage.listEntityNames();
554
+ const result = await raceRecallAbort(
555
+ this.deps.getExtraction().extract(
556
+ normalizedTurns,
557
+ existingEntities,
558
+ ),
559
+ options.abortSignal,
560
+ "extraction aborted (during_extract)",
561
+ );
562
+ throwIfDeadlineExceeded("before_persist");
563
+ throwIfAborted("before_persist");
564
+
565
+ // Defensive: validate extraction result before processing. Explicit
566
+ // fail-closed callers, such as flush-plan import, must not observe
567
+ // malformed extractor output as a successful skip.
568
+ if (!result) {
569
+ log.warn("runExtraction: extraction returned null/undefined");
570
+ if (options.failOnExtractionFailure) {
571
+ throw new Error("extraction failed: invalid_extraction_result");
572
+ }
573
+ await clearBuffer();
574
+ return {
575
+ status: "skipped",
576
+ reason: "invalid_extraction_result",
577
+ persistedCount: 0,
578
+ durableOutputCount: 0,
579
+ };
580
+ }
581
+ const invalidExtractionResultFields = [
582
+ ["facts", result.facts],
583
+ ["entities", result.entities],
584
+ ["questions", result.questions],
585
+ ["profileUpdates", result.profileUpdates],
586
+ ]
587
+ .filter(([, value]) => !Array.isArray(value))
588
+ .map(([field]) => field);
589
+ if (invalidExtractionResultFields.length > 0) {
590
+ log.warn(
591
+ "runExtraction: extraction returned invalid collection fields",
592
+ {
593
+ invalidFields: invalidExtractionResultFields,
594
+ resultKeys:
595
+ typeof result === "object" && result !== null
596
+ ? Object.keys(result)
597
+ : [],
598
+ },
599
+ );
600
+ if (options.failOnExtractionFailure) {
601
+ throw new Error("extraction failed: invalid_extraction_result");
602
+ }
603
+ await clearBuffer();
604
+ return {
605
+ status: "skipped",
606
+ reason: "invalid_extraction_result",
607
+ persistedCount: 0,
608
+ durableOutputCount: 0,
609
+ };
610
+ }
611
+ const extractionFailure =
612
+ typeof result.extractionFailure === "string" &&
613
+ result.extractionFailure.trim().length > 0
614
+ ? result.extractionFailure
615
+ : undefined;
616
+ if (options.failOnExtractionFailure && extractionFailure) {
617
+ throw new Error(`extraction failed: ${extractionFailure}`);
618
+ }
619
+ if (
620
+ result.facts.length === 0 &&
621
+ result.entities.length === 0 &&
622
+ result.questions.length === 0 &&
623
+ result.profileUpdates.length === 0
624
+ ) {
625
+ log.debug(
626
+ "runExtraction: extraction produced no durable outputs; skipping persistence",
627
+ );
628
+ if (extractionFailure) {
629
+ log.warn(
630
+ "runExtraction: extraction reported failure with no durable outputs; not marking fingerprint processed",
631
+ { extractionFailure },
632
+ );
633
+ }
634
+ if (
635
+ extractionFingerprint &&
636
+ shouldPersistProcessedFingerprint &&
637
+ !extractionFailure
638
+ ) {
639
+ meta ??= await storage.loadMeta();
640
+ await this.deps.recordProcessedExtractionFingerprint(
641
+ storage,
642
+ extractionFingerprint,
643
+ meta,
644
+ );
645
+ meta.extractionCount += 1;
646
+ meta.lastExtractionAt = new Date().toISOString();
647
+ await storage.saveMeta(meta);
648
+ }
649
+ // Correction-only turns that meet char/user-turn thresholds but yield
650
+ // zero facts still need passive capture (review: "empty extraction skips
651
+ // capture"). selfNamespace/principal already resolved above.
652
+ await this.deps.maybeCapturePassiveCorrections(normalizedTurns as BufferTurn[], { sessionKey, principal, namespace: selfNamespace, bufferKey, isLiveSession: clearBufferAfterExtraction });
653
+ await clearBuffer();
654
+ return { status: "skipped", reason: "empty_extraction_result", persistedCount: 0, durableOutputCount: 0 };
655
+ }
656
+
657
+ let threadIdForExtraction: string | null = null;
658
+ if (resolvePresentationCapabilities(this.config).threading && turns.length > 0) {
659
+ const lastTurn = turns[turns.length - 1];
660
+ try {
661
+ threadIdForExtraction = await this.deps.getThreading().processTurn(lastTurn, []);
662
+ } catch (err) {
663
+ // Fail-open: threading errors must not block memory persistence.
664
+ log.warn(
665
+ "[threading] processTurn failed before persistence (non-fatal)",
666
+ err,
667
+ );
668
+ }
669
+ }
670
+
671
+ const persistedIds = await this.deps.persistExtraction(
672
+ result,
673
+ storage,
674
+ threadIdForExtraction,
675
+ { sessionKey, principal, validAt: sourceValidAt },
676
+ // Pass the KNOWN base namespace (NHIdx) so the catalog write touch records the
677
+ // real namespace rather than a guess decoded from the storage dir.
678
+ selfNamespace,
679
+ scopeProfileGatePlan,
680
+ // Verbatim source turns for the faithfulness gate (#1576) so it can
681
+ // locate a quote per fact when #1575 spans are absent.
682
+ normalizedTurns.map((t) => t.content).join("\n\n"),
683
+ );
684
+ let postPersistMetadataFailed = false;
685
+ meta ??= await storage.loadMeta();
686
+ if (extractionFingerprint && shouldPersistProcessedFingerprint) {
687
+ try {
688
+ await this.deps.recordProcessedExtractionFingerprint(
689
+ storage,
690
+ extractionFingerprint,
691
+ meta,
692
+ );
693
+ } catch (error) {
694
+ log.warn(
695
+ "runExtraction: failed to persist processed extraction fingerprint; continuing with buffer clear",
696
+ error,
697
+ );
698
+ postPersistMetadataFailed = true;
699
+ }
700
+ }
701
+ // Persist extraction counters and processed fingerprints before running
702
+ // follow-on helpers so replay dedupe survives any later non-essential
703
+ // failure. If this aggregate meta write fails, still clear the buffer:
704
+ // the durable memories are already written and replaying the same turns
705
+ // would duplicate them.
706
+ meta.extractionCount += 1;
707
+ meta.lastExtractionAt = new Date().toISOString();
708
+ meta.totalMemories += Array.isArray(result?.facts)
709
+ ? result.facts.length
710
+ : 0;
711
+ meta.totalEntities += Array.isArray(result?.entities)
712
+ ? result.entities.length
713
+ : 0;
714
+ try {
715
+ await storage.saveMeta(meta);
716
+ } catch (error) {
717
+ log.warn(
718
+ "runExtraction: failed to save extraction metadata after durable persistence; continuing with buffer clear",
719
+ error,
720
+ );
721
+ postPersistMetadataFailed = true;
722
+ }
723
+
724
+ const durableOutputCount =
725
+ result.facts.length +
726
+ result.entities.length +
727
+ result.questions.length +
728
+ result.profileUpdates.length;
729
+
730
+ // Buffer retention for defer verdicts (issue #562, PR 2). When the judge
731
+ // deferred at least one candidate, retain the tail of the current turn
732
+ // window so the next extraction pass has the surrounding context that
733
+ // may disambiguate the deferred fact. Non-defer runs clear the slot.
734
+ //
735
+ // Gated on:
736
+ // - `clearBufferAfterExtraction` — replay / bulk-import paths call
737
+ // `runExtraction` with this false and do not operate on live buffer
738
+ // state. Writing retention there would create synthetic buffer
739
+ // entries and cross-contaminate future live extractions.
740
+ // - NOT `extractionJudgeShadow` — in shadow mode the judge is only
741
+ // advisory; facts are still persisted regardless of verdict, so
742
+ // retaining the turn window on top of a persisted write would both
743
+ // waste buffer space and cause the same facts to re-enter the
744
+ // pipeline on the next pass.
745
+ try {
746
+ if (
747
+ clearBufferAfterExtraction &&
748
+ !this.config.extractionJudgeShadow
749
+ ) {
750
+ const deferredCount = this.deps.getLastPersistExtractionDeferredCount();
751
+ if (deferredCount > 0 && normalizedTurns.length > 0) {
752
+ await this.deps.getBuffer().retainDeferredTurns(
753
+ bufferKey,
754
+ normalizedTurns as BufferTurn[],
755
+ 10,
756
+ );
757
+ } else {
758
+ await this.deps.getBuffer().retainDeferredTurns(bufferKey, [], 0);
759
+ }
760
+ }
761
+ } catch (err) {
762
+ // Fail-open: retention is a nice-to-have. If it fails the judge will
763
+ // still cap deferrals and convert to reject on the next pass.
764
+ log.debug(
765
+ `extraction-judge: defer retention failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`,
766
+ );
767
+ }
768
+
769
+ await clearBuffer({ ignoreAbort: true });
770
+
771
+ // Passive correction capture (issue #1581) — detect corrections expressed
772
+ // passively in the extracted turns and route to the Correction Contract.
773
+ // Runs AFTER persistence + buffer clear so a capture failure never blocks
774
+ // the extraction return. `clearBufferAfterExtraction` gates live-session
775
+ // auto-apply (replay/import → queue-only).
776
+ await this.deps.maybeCapturePassiveCorrections(normalizedTurns as BufferTurn[], {
777
+ sessionKey,
778
+ principal,
779
+ namespace: selfNamespace,
780
+ bufferKey,
781
+ isLiveSession: clearBufferAfterExtraction,
782
+ });
783
+
784
+ // Build memory box from this extraction (v8.0 Phase 2A)
785
+ // Topics are derived from the current extraction's facts and entities only —
786
+ // not from readAllMemories() — so box topics accurately reflect the current
787
+ // session window and the call is free of expensive full-corpus I/O.
788
+ if (resolvePresentationCapabilities(this.config).memoryBoxes && persistedIds.length > 0) {
789
+ const extractionTopics = deriveTopicsFromExtraction(result);
790
+ // Derive episodic metadata from buffer turns (REMem-inspired)
791
+ const firstUserTurn = turns.find((t) => t.role === "user");
792
+ const boxGoal =
793
+ firstUserTurn?.content?.slice(0, 100)?.trim() || undefined;
794
+ await this.deps.boxBuilderFor(storage)
795
+ .onExtraction({
796
+ topics: extractionTopics,
797
+ memoryIds: persistedIds,
798
+ timestamp: new Date().toISOString(),
799
+ goal: boxGoal,
800
+ })
801
+ .catch((err) =>
802
+ log.warn("[boxes] onExtraction failed (non-fatal)", err),
803
+ );
804
+ }
805
+
806
+ // Batch-append persisted IDs so non-fact memories (entities/questions) are
807
+ // always attached to the thread. The helper excludes pending_review ids (#1635).
808
+ if (
809
+ resolvePresentationCapabilities(this.config).threading &&
810
+ threadIdForExtraction &&
811
+ persistedIds.length > 0
812
+ ) {
813
+ await this.deps.appendPersistedThreadEpisodes(
814
+ threadIdForExtraction,
815
+ persistedIds,
816
+ );
817
+ }
818
+
819
+ // Thread title update for the already-established thread context.
820
+ if (resolvePresentationCapabilities(this.config).threading && threadIdForExtraction) {
821
+ const conversationContent = turns.map((t) => t.content).join(" ");
822
+ try {
823
+ await this.deps.getThreading().updateThreadTitle(
824
+ threadIdForExtraction,
825
+ conversationContent,
826
+ );
827
+ } catch (err) {
828
+ log.warn(
829
+ "[threading] updateThreadTitle failed after persistence (non-fatal)",
830
+ err,
831
+ );
832
+ }
833
+ }
834
+
835
+ // Check if consolidation is needed (debounced + non-zero gated).
836
+ const nonZeroExtraction = durableOutputCount > 0;
837
+ try {
838
+ // The increment of nonZeroExtractionsSinceConsolidation moved into
839
+ // the scheduler with the rest of the cadence state (issue #1526 PR1).
840
+ this.deps.maybeScheduleConsolidation(nonZeroExtraction);
841
+ } catch (err) {
842
+ log.warn(
843
+ "runExtraction: consolidation scheduling failed after persistence (non-fatal)",
844
+ err,
845
+ );
846
+ }
847
+
848
+ try {
849
+ this.deps.requestQmdMaintenance();
850
+ } catch (err) {
851
+ log.warn(
852
+ "runExtraction: QMD maintenance scheduling failed after persistence (non-fatal)",
853
+ err,
854
+ );
855
+ }
856
+
857
+ try {
858
+ await this.deps.runTierMigrationCycle(storage, "extraction");
859
+ } catch (err) {
860
+ log.warn(
861
+ "runExtraction: tier migration failed after persistence (non-fatal)",
862
+ err,
863
+ );
864
+ }
865
+
866
+ return {
867
+ status: "completed",
868
+ persistedCount: persistedIds.length,
869
+ durableOutputCount,
870
+ postPersistMetadataFailed,
871
+ };
872
+ }
873
+ }
874
+