@remnic/core 9.3.749 → 9.3.751

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 (271) hide show
  1. package/dist/access-boundary.d.ts +8 -8
  2. package/dist/access-boundary.js +15 -15
  3. package/dist/access-cli.js +39 -39
  4. package/dist/access-http.d.ts +8 -8
  5. package/dist/access-http.js +20 -20
  6. package/dist/access-mcp.d.ts +8 -8
  7. package/dist/access-mcp.js +19 -19
  8. package/dist/access-operations-batch.js +16 -16
  9. package/dist/access-operations.d.ts +8 -8
  10. package/dist/access-operations.js +18 -18
  11. package/dist/access-schema.d.ts +2 -2
  12. package/dist/access-schema.js +5 -5
  13. package/dist/{access-service-5-EVpt3v.d.ts → access-service-DmAEOJdJ.d.ts} +11 -3
  14. package/dist/access-service.d.ts +5 -5
  15. package/dist/access-service.js +14 -14
  16. package/dist/access-surface-catalog.d.ts +8 -8
  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/active-recall.js +8 -1
  21. package/dist/active-recall.js.map +1 -1
  22. package/dist/behavior-learner.d.ts +1 -1
  23. package/dist/behavior-signals.d.ts +1 -1
  24. package/dist/bootstrap.d.ts +7 -7
  25. package/dist/briefing.d.ts +1 -1
  26. package/dist/briefing.js +7 -7
  27. package/dist/buffer-surprise-report.d.ts +1 -1
  28. package/dist/buffer.d.ts +1 -1
  29. package/dist/calibration.d.ts +1 -1
  30. package/dist/capabilities.d.ts +1 -1
  31. package/dist/{catalog-D_ZDxu8Y.d.ts → catalog-BpOcwHdE.d.ts} +1 -1
  32. package/dist/causal-behavior.d.ts +1 -1
  33. package/dist/causal-consolidation.d.ts +1 -1
  34. package/dist/causal-consolidation.js +8 -8
  35. package/dist/causal-trajectory-graph.d.ts +1 -1
  36. package/dist/{chunk-C5WN74HH.js → chunk-2HAG7JOI.js} +8 -2
  37. package/dist/chunk-2HAG7JOI.js.map +1 -0
  38. package/dist/{chunk-GKRLPHPU.js → chunk-2KK5WNQB.js} +2 -2
  39. package/dist/{chunk-JRDPT6DA.js → chunk-4NWIGAIC.js} +2 -2
  40. package/dist/{chunk-OBJUKYDO.js → chunk-4XL3CVII.js} +2 -2
  41. package/dist/{chunk-WWVXXSLF.js → chunk-5PZCDUJ6.js} +4 -4
  42. package/dist/{chunk-URQLO7BX.js → chunk-6FNZ3NMS.js} +2 -2
  43. package/dist/{chunk-DRUDC2UX.js → chunk-6MMONPHM.js} +2 -2
  44. package/dist/{chunk-RB6A7OUN.js → chunk-6SXNQFHB.js} +2 -2
  45. package/dist/{chunk-ZGZNLRKF.js → chunk-7TWA7DKP.js} +5 -5
  46. package/dist/{chunk-KMNYXLPL.js → chunk-B35L45HZ.js} +2 -2
  47. package/dist/{chunk-SIV3JZPP.js → chunk-CGZWGVQF.js} +5 -5
  48. package/dist/{chunk-CKFLLAIS.js → chunk-DANV2HTH.js} +3 -3
  49. package/dist/{chunk-NVYF5OB6.js → chunk-DBRVU5PR.js} +3 -3
  50. package/dist/{chunk-LMFS64GY.js → chunk-E4PFDD3N.js} +4 -4
  51. package/dist/{chunk-DIHCFJVV.js → chunk-HYI2GP3F.js} +4 -4
  52. package/dist/{chunk-UIKZZ242.js → chunk-IKHKHADN.js} +2 -2
  53. package/dist/{chunk-PUR2CKX2.js → chunk-JS2HKKOK.js} +1 -1
  54. package/dist/{chunk-PUR2CKX2.js.map → chunk-JS2HKKOK.js.map} +1 -1
  55. package/dist/{chunk-R2OAN6IP.js → chunk-K2JM4DZZ.js} +2 -2
  56. package/dist/{chunk-3IJGF6K2.js → chunk-K2R3DEEH.js} +2 -2
  57. package/dist/{chunk-HIHCB2S6.js → chunk-QRRBXD24.js} +2 -2
  58. package/dist/{chunk-PNUOQYRJ.js → chunk-RWZB6MAY.js} +1953 -2172
  59. package/dist/chunk-RWZB6MAY.js.map +1 -0
  60. package/dist/{chunk-ZPZ4VVLT.js → chunk-SD34EK4Z.js} +2 -2
  61. package/dist/{chunk-WK3NK6SP.js → chunk-SNEXVX3U.js} +6 -6
  62. package/dist/{chunk-FVZ2ISIX.js → chunk-TPLE6SNY.js} +7 -7
  63. package/dist/{chunk-24YDB5QP.js → chunk-UETBEHBA.js} +44 -24
  64. package/dist/chunk-UETBEHBA.js.map +1 -0
  65. package/dist/{chunk-5IN26V6W.js → chunk-UU32EAIY.js} +5 -5
  66. package/dist/{chunk-ZGAJB74V.js → chunk-W3BFT5OJ.js} +2 -2
  67. package/dist/{chunk-L4N7SVNN.js → chunk-WA7FECSG.js} +11 -11
  68. package/dist/{chunk-Q3X7GUAQ.js → chunk-WOX2UW4K.js} +2 -2
  69. package/dist/{chunk-XTEWVF3C.js → chunk-WWSXVOGY.js} +2 -2
  70. package/dist/{chunk-PWWWLD7D.js → chunk-X64YLC24.js} +153 -27
  71. package/dist/chunk-X64YLC24.js.map +1 -0
  72. package/dist/{chunk-P4FW3ZU5.js → chunk-YGGXUNS4.js} +20 -20
  73. package/dist/{chunk-JHQUIH3W.js → chunk-ZBVQ4ZM4.js} +19 -19
  74. package/dist/{cli-DuWWjaKT.d.ts → cli-lmf5RGyQ.d.ts} +3 -3
  75. package/dist/cli.d.ts +9 -9
  76. package/dist/cli.js +33 -33
  77. package/dist/compounding/engine.d.ts +1 -1
  78. package/dist/compounding/engine.js +7 -7
  79. package/dist/compounding/preference-consolidator.d.ts +1 -1
  80. package/dist/compression-optimizer.d.ts +1 -1
  81. package/dist/config.d.ts +1 -1
  82. package/dist/config.js +8 -1
  83. package/dist/connectors/codex-materialize-runner.d.ts +1 -1
  84. package/dist/connectors/codex-materialize-runner.js +7 -7
  85. package/dist/connectors/codex-materialize.d.ts +1 -1
  86. package/dist/connectors/index.d.ts +1 -1
  87. package/dist/connectors/index.js +7 -7
  88. package/dist/consolidation-provenance-check.d.ts +1 -1
  89. package/dist/consolidation-undo.d.ts +1 -1
  90. package/dist/contradiction/index.d.ts +1 -1
  91. package/dist/conversation-index/backend.d.ts +1 -1
  92. package/dist/conversation-index/chunker.d.ts +1 -1
  93. package/dist/conversation-index/faiss-adapter.d.ts +1 -1
  94. package/dist/conversation-index/indexer.d.ts +1 -1
  95. package/dist/conversation-index/search.d.ts +1 -1
  96. package/dist/day-summary.d.ts +1 -1
  97. package/dist/delinearize.d.ts +1 -1
  98. package/dist/direct-answer-wiring.d.ts +1 -1
  99. package/dist/direct-answer.d.ts +1 -1
  100. package/dist/embedding-fallback.d.ts +1 -1
  101. package/dist/enrichment/index.d.ts +1 -1
  102. package/dist/entity-retrieval.d.ts +1 -1
  103. package/dist/entity-retrieval.js +7 -7
  104. package/dist/entity-schema.d.ts +1 -1
  105. package/dist/explicit-capture.d.ts +7 -7
  106. package/dist/extraction-faithfulness.d.ts +1 -1
  107. package/dist/extraction-judge-telemetry.d.ts +1 -1
  108. package/dist/extraction-judge-training.d.ts +1 -1
  109. package/dist/extraction-judge.d.ts +1 -1
  110. package/dist/extraction.d.ts +1 -1
  111. package/dist/fallback-llm.d.ts +1 -1
  112. package/dist/graph-dashboard-diff.d.ts +1 -1
  113. package/dist/graph-dashboard-key.d.ts +1 -1
  114. package/dist/graph-dashboard-parser.d.ts +1 -1
  115. package/dist/graph-edge-reinforcement.d.ts +1 -1
  116. package/dist/graph-snapshot.d.ts +1 -1
  117. package/dist/graph.d.ts +1 -1
  118. package/dist/identity-continuity.d.ts +1 -1
  119. package/dist/importance.d.ts +1 -1
  120. package/dist/index.d.ts +10 -10
  121. package/dist/index.js +76 -72
  122. package/dist/intent.d.ts +1 -1
  123. package/dist/lcm/engine.d.ts +1 -1
  124. package/dist/lcm/index.d.ts +1 -1
  125. package/dist/lcm/tools.d.ts +1 -1
  126. package/dist/lifecycle.d.ts +1 -1
  127. package/dist/live-connectors-runner.d.ts +1 -1
  128. package/dist/local-llm.d.ts +1 -1
  129. package/dist/local-model-endpoint.d.ts +1 -1
  130. package/dist/maintenance/memory-governance.d.ts +1 -1
  131. package/dist/maintenance/memory-governance.js +7 -7
  132. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +7 -7
  133. package/dist/maintenance/rebuild-memory-projection.js +8 -8
  134. package/dist/mcp-memory-inspector-app.d.ts +5 -5
  135. package/dist/memory-action-policy.d.ts +1 -1
  136. package/dist/memory-cache.d.ts +1 -1
  137. package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
  138. package/dist/memory-projection-store.d.ts +1 -1
  139. package/dist/memory-provenance.d.ts +1 -1
  140. package/dist/memory-worth-outcomes.d.ts +1 -1
  141. package/dist/models-json.d.ts +1 -1
  142. package/dist/namespaces/migrate.d.ts +2 -2
  143. package/dist/namespaces/migrate.js +8 -8
  144. package/dist/namespaces/principal.d.ts +1 -1
  145. package/dist/namespaces/search.d.ts +1 -1
  146. package/dist/namespaces/search.js +1 -1
  147. package/dist/namespaces/storage.d.ts +2 -2
  148. package/dist/namespaces/storage.js +7 -7
  149. package/dist/native-knowledge.d.ts +1 -1
  150. package/dist/offline-sync.d.ts +108 -1
  151. package/dist/offline-sync.js +7 -1
  152. package/dist/operator-toolkit.d.ts +1 -1
  153. package/dist/operator-toolkit.js +15 -12
  154. package/dist/orchestration/compression-guideline-coordinator.d.ts +1 -1
  155. package/dist/orchestration/maintenance.d.ts +2 -2
  156. package/dist/orchestration/maintenance.js +9 -9
  157. package/dist/{orchestrator-CZI6hO_R.d.ts → orchestrator-D7bb8ZGp.d.ts} +193 -201
  158. package/dist/orchestrator.d.ts +5 -5
  159. package/dist/orchestrator.js +51 -41
  160. package/dist/patterns-cli.d.ts +1 -1
  161. package/dist/policy-runtime.d.ts +1 -1
  162. package/dist/provenance.d.ts +1 -1
  163. package/dist/qmd-recall-cache.d.ts +1 -1
  164. package/dist/qmd.d.ts +1 -1
  165. package/dist/recall-disclosure-escalation.d.ts +1 -1
  166. package/dist/recall-explain-renderer.d.ts +1 -1
  167. package/dist/recall-explain-renderer.js +3 -3
  168. package/dist/recall-planner-llm.d.ts +1 -1
  169. package/dist/recall-state.d.ts +1 -1
  170. package/dist/recall-tag-filter.d.ts +1 -1
  171. package/dist/recall-xray-cli.d.ts +1 -1
  172. package/dist/recall-xray-cli.js +4 -4
  173. package/dist/recall-xray-renderer.d.ts +1 -1
  174. package/dist/recall-xray-renderer.js +3 -3
  175. package/dist/recall-xray.d.ts +1 -1
  176. package/dist/recall-xray.js +2 -2
  177. package/dist/resolve-auth-token.d.ts +1 -1
  178. package/dist/resume-bundles.js +9 -2
  179. package/dist/retrieval-agents.d.ts +1 -1
  180. package/dist/retrieval-tiers.d.ts +1 -1
  181. package/dist/routing/engine.d.ts +1 -1
  182. package/dist/routing/store.d.ts +1 -1
  183. package/dist/schemas.d.ts +22 -22
  184. package/dist/search/embed-helper.d.ts +1 -1
  185. package/dist/search/factory.d.ts +1 -1
  186. package/dist/search/factory.js +1 -1
  187. package/dist/search/index.d.ts +1 -1
  188. package/dist/search/index.js +1 -1
  189. package/dist/search/lancedb-backend.d.ts +1 -1
  190. package/dist/search/lancedb-backend.js +1 -1
  191. package/dist/search/meilisearch-backend.d.ts +1 -1
  192. package/dist/search/meilisearch-backend.js +1 -1
  193. package/dist/search/noop-backend.d.ts +1 -1
  194. package/dist/search/orama-backend.d.ts +1 -1
  195. package/dist/search/orama-backend.js +1 -1
  196. package/dist/search/port.d.ts +1 -1
  197. package/dist/search/remote-backend.d.ts +1 -1
  198. package/dist/{semantic-consolidation-D3aSiu9t.d.ts → semantic-consolidation-CTfbVwCA.d.ts} +1 -1
  199. package/dist/semantic-consolidation.d.ts +2 -2
  200. package/dist/semantic-consolidation.js +8 -8
  201. package/dist/semantic-rule-promotion.js +7 -7
  202. package/dist/semantic-rule-verifier.d.ts +1 -1
  203. package/dist/semantic-rule-verifier.js +7 -7
  204. package/dist/session-observer-bands.d.ts +1 -1
  205. package/dist/session-observer-state.d.ts +1 -1
  206. package/dist/shared-context/manager.d.ts +1 -1
  207. package/dist/signal.d.ts +1 -1
  208. package/dist/storage.d.ts +1 -1
  209. package/dist/storage.js +6 -6
  210. package/dist/summarizer.d.ts +1 -1
  211. package/dist/summary-snapshot.d.ts +1 -1
  212. package/dist/temporal-supersession.d.ts +1 -1
  213. package/dist/temporal-validity.d.ts +1 -1
  214. package/dist/threading.d.ts +1 -1
  215. package/dist/tier-migration.d.ts +1 -1
  216. package/dist/tier-routing.d.ts +1 -1
  217. package/dist/topics.d.ts +1 -1
  218. package/dist/transcript.d.ts +1 -1
  219. package/dist/transfer/types.d.ts +12 -12
  220. package/dist/trust-score-stage.d.ts +1 -1
  221. package/dist/trust-score.d.ts +1 -1
  222. package/dist/{types-CZm6rx9-.d.ts → types-D4NYDtXI.d.ts} +7 -0
  223. package/dist/types.d.ts +1 -1
  224. package/dist/types.js +1 -1
  225. package/dist/utility-runtime.d.ts +1 -1
  226. package/dist/verified-recall.js +7 -7
  227. package/package.json +2 -2
  228. package/src/access-service.ts +22 -0
  229. package/src/config.ts +5 -0
  230. package/src/index.ts +3 -1
  231. package/src/offline-sync.test.ts +354 -52
  232. package/src/offline-sync.ts +332 -24
  233. package/src/orchestration/extraction-run.ts +4 -34
  234. package/src/orchestration/namespace-read-fanout.ts +484 -0
  235. package/src/orchestration/orchestrator-helpers.ts +1130 -0
  236. package/src/orchestration/self-deps.ts +50 -0
  237. package/src/orchestration/workspace-ops.ts +789 -0
  238. package/src/orchestrator.ts +177 -2296
  239. package/src/types.ts +9 -0
  240. package/dist/chunk-24YDB5QP.js.map +0 -1
  241. package/dist/chunk-C5WN74HH.js.map +0 -1
  242. package/dist/chunk-PNUOQYRJ.js.map +0 -1
  243. package/dist/chunk-PWWWLD7D.js.map +0 -1
  244. /package/dist/{chunk-GKRLPHPU.js.map → chunk-2KK5WNQB.js.map} +0 -0
  245. /package/dist/{chunk-JRDPT6DA.js.map → chunk-4NWIGAIC.js.map} +0 -0
  246. /package/dist/{chunk-OBJUKYDO.js.map → chunk-4XL3CVII.js.map} +0 -0
  247. /package/dist/{chunk-WWVXXSLF.js.map → chunk-5PZCDUJ6.js.map} +0 -0
  248. /package/dist/{chunk-URQLO7BX.js.map → chunk-6FNZ3NMS.js.map} +0 -0
  249. /package/dist/{chunk-DRUDC2UX.js.map → chunk-6MMONPHM.js.map} +0 -0
  250. /package/dist/{chunk-RB6A7OUN.js.map → chunk-6SXNQFHB.js.map} +0 -0
  251. /package/dist/{chunk-ZGZNLRKF.js.map → chunk-7TWA7DKP.js.map} +0 -0
  252. /package/dist/{chunk-KMNYXLPL.js.map → chunk-B35L45HZ.js.map} +0 -0
  253. /package/dist/{chunk-SIV3JZPP.js.map → chunk-CGZWGVQF.js.map} +0 -0
  254. /package/dist/{chunk-CKFLLAIS.js.map → chunk-DANV2HTH.js.map} +0 -0
  255. /package/dist/{chunk-NVYF5OB6.js.map → chunk-DBRVU5PR.js.map} +0 -0
  256. /package/dist/{chunk-LMFS64GY.js.map → chunk-E4PFDD3N.js.map} +0 -0
  257. /package/dist/{chunk-DIHCFJVV.js.map → chunk-HYI2GP3F.js.map} +0 -0
  258. /package/dist/{chunk-UIKZZ242.js.map → chunk-IKHKHADN.js.map} +0 -0
  259. /package/dist/{chunk-R2OAN6IP.js.map → chunk-K2JM4DZZ.js.map} +0 -0
  260. /package/dist/{chunk-3IJGF6K2.js.map → chunk-K2R3DEEH.js.map} +0 -0
  261. /package/dist/{chunk-HIHCB2S6.js.map → chunk-QRRBXD24.js.map} +0 -0
  262. /package/dist/{chunk-ZPZ4VVLT.js.map → chunk-SD34EK4Z.js.map} +0 -0
  263. /package/dist/{chunk-WK3NK6SP.js.map → chunk-SNEXVX3U.js.map} +0 -0
  264. /package/dist/{chunk-FVZ2ISIX.js.map → chunk-TPLE6SNY.js.map} +0 -0
  265. /package/dist/{chunk-5IN26V6W.js.map → chunk-UU32EAIY.js.map} +0 -0
  266. /package/dist/{chunk-ZGAJB74V.js.map → chunk-W3BFT5OJ.js.map} +0 -0
  267. /package/dist/{chunk-L4N7SVNN.js.map → chunk-WA7FECSG.js.map} +0 -0
  268. /package/dist/{chunk-Q3X7GUAQ.js.map → chunk-WOX2UW4K.js.map} +0 -0
  269. /package/dist/{chunk-XTEWVF3C.js.map → chunk-WWSXVOGY.js.map} +0 -0
  270. /package/dist/{chunk-P4FW3ZU5.js.map → chunk-YGGXUNS4.js.map} +0 -0
  271. /package/dist/{chunk-JHQUIH3W.js.map → chunk-ZBVQ4ZM4.js.map} +0 -0
@@ -115,6 +115,75 @@ import { TurnIngestionCoordinator } from "./orchestration/turn-ingestion.js";
115
115
  import { RecallIntrospectionCoordinator } from "./orchestration/recall-introspection.js";
116
116
  import { OrchestratorInitCoordinator } from "./orchestration/orchestrator-init.js";
117
117
  import { PersistenceIndexCoordinator } from "./orchestration/persistence-index.js";
118
+ import { WorkspaceOpsCoordinator } from "./orchestration/workspace-ops.js";
119
+ import { NamespaceReadFanoutCoordinator } from "./orchestration/namespace-read-fanout.js";
120
+ import { selfDeps } from "./orchestration/self-deps.js";
121
+ import {
122
+ abortRecallError,
123
+ buildCompressionGuidelinesMarkdown,
124
+ buildQmdIntentHint,
125
+ isArtifactMemoryPath,
126
+ mergeArtifactRecallCandidates,
127
+ tokenizeRecallQuery,
128
+ type BulkImportBatchIngestResult,
129
+ type DaySummaryGatherOptions,
130
+ type GraphRecallSnapshot,
131
+ type IntentDebugSnapshot,
132
+ type QmdRecallSnapshot,
133
+ type QueryAwarePrefilter,
134
+ type RecallInvocationOptions,
135
+ } from "./orchestration/orchestrator-helpers.js";
136
+ // Issue #1526 seam 25: module-level helpers moved to
137
+ // orchestration/orchestrator-helpers.ts; re-exported here so existing
138
+ // importers (coordinators, tests, root shims) keep working.
139
+ export {
140
+ BulkImportBatchPartialFailureError,
141
+ COMPACTION_SIGNAL_MAX_AGE_MS,
142
+ appendMemoryToGraphContext,
143
+ applyQueryAwareCandidateFilter,
144
+ buildCompressionGuidelinesMarkdown,
145
+ buildMemoryPathById,
146
+ computeArtifactCandidateFetchLimit,
147
+ computeArtifactRecallLimit,
148
+ computeQmdHybridFetchLimit,
149
+ defaultWorkspaceDir,
150
+ filterHourlySummaryMarkdownForLocalDay,
151
+ filterRecallCandidates,
152
+ formatDateInTimeZone,
153
+ isArtifactMemoryPath,
154
+ lifecycleRecallScoreAdjustment,
155
+ mapRecallSourceToXrayServedBy,
156
+ mergeArtifactRecallCandidates,
157
+ normalizeIanaTimeZone,
158
+ parseFiniteDate,
159
+ parseGraphRecallRankedResults,
160
+ parseMemoryIntentSnapshot,
161
+ parseQmdRecallResults,
162
+ qmdStartupCollectionCheckWithTimeout,
163
+ raceRecallAbort,
164
+ resolveEffectiveRecallMode,
165
+ resolvePersistedMemoryRelativePath,
166
+ resolveRecallModeDecision,
167
+ resolveRecallModeDecisionAsync,
168
+ resolveRecentThreadMemoryPaths,
169
+ sanitizeSessionKeyForFilename,
170
+ shouldFilterLifecycleRecallCandidate,
171
+ splitTurnsBySourceValidAt,
172
+ summarizeGraphShadowComparison,
173
+ targetSourceValidAtSortMs,
174
+ throwIfRecallAborted,
175
+ tokenizeRecallQuery,
176
+ utcDateKeysForLocalDay,
177
+ type BulkImportBatchIngestResult,
178
+ type DaySummaryGatherOptions,
179
+ type GraphRecallSnapshot,
180
+ type IntentDebugSnapshot,
181
+ type QmdRecallSnapshot,
182
+ type QueryAwarePrefilter,
183
+ type RecallInvocationOptions,
184
+ type RecallModeDecision,
185
+ } from "./orchestration/orchestrator-helpers.js";
186
+
118
187
  export { hasIdentityRecoveryIntent, resolveEffectiveIdentityInjectionMode } from "./orchestration/recall-result-formatter.js";
119
188
  import {
120
189
  GraphRecallCoordinator,
@@ -469,1105 +538,14 @@ import type {
469
538
  } from "./types.js";
470
539
  import { disposeDefaultArchiveScoring, getDefaultArchiveScoring, memoryFileToScoreItem } from "./recall/archive-scoring.js";
471
540
 
472
- export interface BulkImportBatchIngestResult {
473
- attemptedTurnCount: number;
474
- extractionCount: number;
475
- persistedCount: number;
476
- durableOutputCount: number;
477
- skippedCount: number;
478
- failedCount: number;
479
- postPersistMetadataFailureCount: number;
480
- processedTurnCount: number;
481
- }
482
-
483
- export class BulkImportBatchPartialFailureError extends Error {
484
- readonly partialResult: BulkImportBatchIngestResult;
485
-
486
- readonly originalError: unknown;
487
-
488
- constructor(
489
- message: string,
490
- partialResult: BulkImportBatchIngestResult,
491
- originalError: unknown,
492
- ) {
493
- super(message);
494
- this.name = "BulkImportBatchPartialFailureError";
495
- this.partialResult = partialResult;
496
- this.originalError = originalError;
497
- }
498
- }
499
541
 
500
542
  // Issue #1526 seam 15: ExtractionRunResult moved to orchestration/extraction-run.ts.
501
543
  export type { ExtractionRunResult } from "./orchestration/extraction-run.js";
502
544
 
503
- export interface GraphRecallSnapshot {
504
- recordedAt: string;
505
- mode: RecallPlanMode | string;
506
- queryHash: string;
507
- queryLength: number;
508
- namespaces: string[];
509
- seedCount: number;
510
- expandedCount: number;
511
- seeds: string[];
512
- expanded: GraphRecallExpandedEntry[];
513
- status?: "completed" | "skipped" | "aborted";
514
- reason?: string;
515
- shadowMode?: boolean;
516
- queryIntent?: MemoryIntent;
517
- seedResults?: GraphRecallRankedResult[];
518
- finalResults?: GraphRecallRankedResult[];
519
- shadowComparison?: GraphRecallShadowComparison;
520
- }
521
-
522
- export interface IntentDebugSnapshot {
523
- recordedAt: string;
524
- promptHash: string;
525
- promptLength: number;
526
- retrievalQueryHash: string;
527
- retrievalQueryLength: number;
528
- plannerEnabled: boolean;
529
- plannedMode: RecallPlanMode;
530
- effectiveMode: RecallPlanMode;
531
- recallResultLimit: number;
532
- queryIntent: MemoryIntent;
533
- graphExpandedIntentDetected: boolean;
534
- graphDecision: {
535
- status: "not_requested" | "skipped" | "completed" | "aborted";
536
- reason?: string;
537
- shadowMode: boolean;
538
- qmdAvailable: boolean;
539
- graphRecallEnabled: boolean;
540
- multiGraphMemoryEnabled: boolean;
541
- };
542
- }
543
-
544
- export interface QmdRecallSnapshot {
545
- recordedAt: string;
546
- queryHash: string;
547
- queryLength: number;
548
- collection?: string;
549
- namespaces: string[];
550
- fetchLimit: number;
551
- primaryResultCount: number;
552
- hybridResultCount: number;
553
- queryAwareSeedCount: number;
554
- resultCount: number;
555
- intentHint?: string;
556
- explainEnabled: boolean;
557
- hybridTopUpUsed: boolean;
558
- hybridTopUpSkippedReason?: string;
559
- results: QmdSearchResult[];
560
- }
561
-
562
- export interface RecallModeDecision {
563
- plannedMode: RecallPlanMode;
564
- effectiveMode: RecallPlanMode;
565
- graphExpandedIntentDetected: boolean;
566
- graphReason?: string;
567
- /**
568
- * Where `plannedMode` came from (issue #1367 / Option C). `"heuristic"` for
569
- * the regex planner; `"llm"` when the LLM planner classified it; and
570
- * `"heuristic-fallback"` when the LLM was enabled but errored/timed out and we
571
- * fell back. Absent on the synchronous heuristic-only path.
572
- */
573
- plannerSource?: "heuristic" | "llm" | "heuristic-fallback";
574
- /** Short rationale from the planner (for telemetry / x-ray). */
575
- plannerReason?: string;
576
- /** Wall-clock spent in the LLM planner call, when one was made. */
577
- plannerLatencyMs?: number;
578
- /** True when the LLM planner was enabled but fell back to the heuristic. */
579
- plannerFallbackUsed?: boolean;
580
- /** Model that served the LLM classification, when one was used. */
581
- plannerModelUsed?: string;
582
- /**
583
- * The regex-heuristic baseline mode, captured whenever the LLM planner ran
584
- * (any source). Lets operators compare planned-vs-heuristic during rollout —
585
- * distinct from `plannedMode`, which on the LLM path is the LLM's choice.
586
- */
587
- plannerHeuristicMode?: RecallPlanMode;
588
- /**
589
- * In shadow mode, the mode the LLM *would* have chosen (recorded for
590
- * comparison) while `effectiveMode` stays on the heuristic decision.
591
- */
592
- shadowLlmMode?: RecallPlanMode;
593
- }
594
-
595
- /**
596
- * Map the orchestrator's internal `recallSource` strings to the
597
- * X-ray `servedBy` vocabulary (issue #570 PR 1). The X-ray tier
598
- * ladder intentionally flattens QMD / embedding / cold-fallback to
599
- * the `hybrid` tier because they all materialize through the same
600
- * hybrid BM25+vector pipeline from the caller's perspective. The
601
- * `recent_scan` path gets its own dedicated tier because it bypasses
602
- * the hybrid pipeline entirely. `none` is treated as `hybrid` on the
603
- * theory that a query that returned nothing still routed through the
604
- * hybrid pipeline — but callers should normally gate capture on
605
- * `recalledMemoryIds.length > 0`.
606
- */
607
- export function mapRecallSourceToXrayServedBy(
608
- source:
609
- | "none"
610
- | "hot_qmd"
611
- | "hot_embedding"
612
- | "cold_fallback"
613
- | "recent_scan",
614
- ): RecallXrayServedBy {
615
- // Exhaustive switch: every current union member is explicitly
616
- // listed so TypeScript surfaces a compile error if a new source is
617
- // added without a deliberate mapping. The `never`-typed fallthrough
618
- // keeps the function total at runtime — if the caller passes an
619
- // unexpected value that slipped past the type system (e.g. a JSON
620
- // deserialization), we still fall back to `hybrid`.
621
- switch (source) {
622
- case "recent_scan":
623
- return "recent-scan";
624
- case "hot_qmd":
625
- case "hot_embedding":
626
- case "cold_fallback":
627
- case "none":
628
- return "hybrid";
629
- }
630
- const _exhaustive: never = source;
631
- void _exhaustive;
632
- return "hybrid";
633
- }
634
-
635
- export interface RecallInvocationOptions {
636
- namespace?: string;
637
- topK?: number;
638
- mode?: RecallPlanMode;
639
- abortSignal?: AbortSignal;
640
- /**
641
- * Capture a `RecallXraySnapshot` for this recall (issue #570). When
642
- * `true`, the orchestrator builds a snapshot from the data it has
643
- * already gathered and stashes it in memory, accessible via
644
- * `getLastXraySnapshot()`. When `false` or absent, nothing is
645
- * captured and recall behavior is unchanged (schema-only slice).
646
- */
647
- xrayCapture?: boolean;
648
- /**
649
- * Per-invocation override for `recallBudgetChars` (issue #570 PR 3/4).
650
- * Flows through `getRecallBudgetChars()` for this recall only — no
651
- * shared config mutation, so concurrent recalls on the same
652
- * orchestrator are not affected (CLAUDE.md rule 47: no shared
653
- * mutable state across async boundaries). Must be a non-negative
654
- * finite integer; non-conforming values are ignored and the
655
- * configured budget is used.
656
- */
657
- budgetCharsOverride?: number;
658
- /**
659
- * Per-invocation principal override (issue #570 PR 4). When set,
660
- * the orchestrator uses this principal for ACL / namespace checks
661
- * instead of `resolvePrincipal(sessionKey, config)`. This is the
662
- * escape hatch for access surfaces (HTTP / MCP) that have already
663
- * authenticated the caller upstream — threading an unmapped
664
- * principal through the session-key-based resolver would otherwise
665
- * collapse it to `"default"` and produce false denials in
666
- * namespace-enabled deployments (CLAUDE.md rule 42).
667
- */
668
- principalOverride?: string;
669
- /**
670
- * Historical recall point (issue #680). When set, the orchestrator
671
- * filters out memories whose `valid_at` is after this timestamp OR
672
- * whose `invalid_at` is at-or-before this timestamp, so callers see
673
- * the corpus as it existed at `asOf`. ISO 8601 string; comparisons
674
- * use `Date.parse()` so timezone-aware values round-trip correctly
675
- * (CLAUDE.md gotcha — never compare ISO strings lexicographically).
676
- * Invalid values must be rejected at input boundaries (CLAUDE.md
677
- * rule 51); the orchestrator does NOT silently fall back here.
678
- */
679
- asOf?: string;
680
- /**
681
- * Issue #681 — when `true`, bypasses `graphTraversalConfidenceFloor`
682
- * and includes edges below the floor in graph traversal. Useful for
683
- * diagnostic recall queries that need to surface results that would
684
- * normally be pruned by confidence decay. Default `false`.
685
- */
686
- includeLowConfidence?: boolean;
687
- /**
688
- * User-aware context scopes active for this recall. Used by X-ray
689
- * provenance safety checks so boundary-scoped memories are evaluated
690
- * against the caller's real context.
691
- */
692
- currentContextScopes?: readonly unknown[];
693
- }
694
-
695
- export type QueryAwarePrefilter = {
696
- candidatePaths: Set<string> | null;
697
- temporalFromDate: string | null;
698
- matchedTags: string[];
699
- expandedTags: string[];
700
- combination: "none" | "temporal" | "tag" | "intersection" | "union";
701
- filteredToFullSearch: boolean;
702
- };
703
-
704
- // Recall-specific abort helpers. Thin wrappers over the shared
705
- // `abort-error.ts` module so every abort in the codebase shares the
706
- // same `name === "AbortError"` classification contract (`isAbortError`
707
- // works uniformly). We keep the "recall aborted" default message for
708
- // back-compat with call-site logs; callers that pass an explicit
709
- // message (e.g. "extraction aborted (before_extract)") are unaffected.
710
- const abortRecallError = sharedAbortError;
711
-
712
- export function throwIfRecallAborted(
713
- signal?: AbortSignal,
714
- message = "recall aborted",
715
- ): void {
716
- sharedThrowIfAborted(signal, message);
717
- }
718
-
719
- export async function raceRecallAbort<T>(
720
- promise: Promise<T>,
721
- signal?: AbortSignal,
722
- message = "recall aborted",
723
- ): Promise<T> {
724
- throwIfRecallAborted(signal, message);
725
- if (!signal) return promise;
726
-
727
- let onAbort: (() => void) | null = null;
728
- const abortPromise = new Promise<T>((_resolve, reject) => {
729
- onAbort = () => reject(abortRecallError(message));
730
- signal.addEventListener("abort", onAbort, { once: true });
731
- });
732
-
733
- try {
734
- return await Promise.race([promise, abortPromise]);
735
- } finally {
736
- if (onAbort) {
737
- signal.removeEventListener("abort", onAbort);
738
- }
739
- }
740
- }
741
-
742
- /** Maximum age (ms) before a compaction-reset signal file is considered stale and removed. */
743
- export const COMPACTION_SIGNAL_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
744
- const DEFAULT_QMD_STARTUP_COLLECTION_CHECK_TIMEOUT_MS = 10_000;
745
-
746
- type DaySummaryGatherOptions = {
747
- timeZone?: string;
748
- now?: Date;
749
- };
750
-
751
- function normalizeIanaTimeZone(value: unknown): string | undefined {
752
- if (typeof value !== "string") return undefined;
753
- const trimmed = value.trim();
754
- if (!trimmed) return undefined;
755
- try {
756
- Intl.DateTimeFormat(undefined, { timeZone: trimmed });
757
- return trimmed;
758
- } catch {
759
- return undefined;
760
- }
761
- }
762
-
763
- function formatDateInTimeZone(date: Date, timeZone: string): string {
764
- const parts = new Intl.DateTimeFormat("en-US", {
765
- timeZone,
766
- year: "numeric",
767
- month: "2-digit",
768
- day: "2-digit",
769
- }).formatToParts(date);
770
- const get = (type: string): string => parts.find((part) => part.type === type)?.value ?? "";
771
- return `${get("year")}-${get("month")}-${get("day")}`;
772
- }
773
-
774
- function utcDateKey(date: Date): string {
775
- return date.toISOString().slice(0, 10);
776
- }
777
-
778
- function utcDateKeysAround(date: Date): string[] {
779
- const dayMs = 86_400_000;
780
- const keys = [
781
- utcDateKey(new Date(date.getTime() - dayMs)),
782
- utcDateKey(date),
783
- utcDateKey(new Date(date.getTime() + dayMs)),
784
- ];
785
- return keys.filter((value, index, array) => array.indexOf(value) === index);
786
- }
787
-
788
- function utcDateKeysForLocalDay(date: Date, timeZone: string): string[] {
789
- const targetLocalDate = formatDateInTimeZone(date, timeZone);
790
- const keys = new Set<string>();
791
- const hourMs = 3_600_000;
792
- const scanStart = date.getTime() - 48 * hourMs;
793
- const scanEnd = date.getTime() + 48 * hourMs;
794
- for (let ms = scanStart; ms <= scanEnd; ms += hourMs) {
795
- const candidate = new Date(ms);
796
- if (formatDateInTimeZone(candidate, timeZone) === targetLocalDate) {
797
- keys.add(utcDateKey(candidate));
798
- }
799
- }
800
- return keys.size > 0 ? [...keys].sort() : utcDateKeysAround(date);
801
- }
802
-
803
- function parseFiniteDate(value: unknown): Date | null {
804
- if (typeof value !== "string" || value.trim().length === 0) return null;
805
- const parsed = new Date(value);
806
- return Number.isFinite(parsed.getTime()) ? parsed : null;
807
- }
808
-
809
- function filterHourlySummaryMarkdownForLocalDay(
810
- raw: string,
811
- utcDate: string,
812
- timeZone: string,
813
- targetLocalDate: string,
814
- ): string | null {
815
- const hourHeaderPattern = /^## ([01]\d|2[0-3]):00[ \t]*$/gm;
816
- const matches = Array.from(raw.matchAll(hourHeaderPattern));
817
- if (matches.length === 0) return null;
818
-
819
- const firstSectionStart = matches[0]?.index ?? 0;
820
- const preamble = raw.slice(0, firstSectionStart).trim();
821
- const sections: string[] = [];
822
- for (let index = 0; index < matches.length; index += 1) {
823
- const match = matches[index];
824
- const hour = match[1];
825
- if (!hour) continue;
826
- const sectionTimestamp = parseFiniteDate(`${utcDate}T${hour}:00:00.000Z`);
827
- if (
828
- !sectionTimestamp ||
829
- formatDateInTimeZone(sectionTimestamp, timeZone) !== targetLocalDate
830
- ) {
831
- continue;
832
- }
833
- const sectionStart = match.index ?? 0;
834
- const sectionEnd = matches[index + 1]?.index ?? raw.length;
835
- const section = raw.slice(sectionStart, sectionEnd).trim();
836
- if (section.length > 0) sections.push(section);
837
- }
838
-
839
- if (sections.length === 0) return null;
840
- return [preamble, ...sections]
841
- .filter((section) => section.length > 0)
842
- .join("\n\n");
843
- }
844
-
845
- type SearchCollectionState = "present" | "missing" | "unknown" | "skipped";
846
-
847
- function qmdStartupCollectionCheckTimeoutMs(): number {
848
- const raw =
849
- process.env.REMNIC_QMD_STARTUP_COLLECTION_CHECK_TIMEOUT_MS ??
850
- process.env.ENGRAM_QMD_STARTUP_COLLECTION_CHECK_TIMEOUT_MS;
851
- if (raw === undefined) return DEFAULT_QMD_STARTUP_COLLECTION_CHECK_TIMEOUT_MS;
852
- const parsed = Number(raw);
853
- return Number.isFinite(parsed) && parsed >= 1_000
854
- ? Math.floor(parsed)
855
- : DEFAULT_QMD_STARTUP_COLLECTION_CHECK_TIMEOUT_MS;
856
- }
857
-
858
- export async function qmdStartupCollectionCheckWithTimeout(
859
- promise: Promise<SearchCollectionState>,
860
- controller: AbortController,
861
- label: string,
862
- ): Promise<SearchCollectionState> {
863
- const timeoutMs = qmdStartupCollectionCheckTimeoutMs();
864
- let timer: NodeJS.Timeout | undefined;
865
- let settled = false;
866
-
867
- const timeoutPromise = new Promise<SearchCollectionState>((resolve) => {
868
- timer = setTimeout(() => {
869
- if (settled) return;
870
- controller.abort();
871
- log.warn(
872
- `QMD startup collection check for ${label} timed out after ${timeoutMs}ms; keeping search enabled fail-open`,
873
- );
874
- resolve("unknown");
875
- }, timeoutMs);
876
- timer.unref?.();
877
- });
878
-
879
- const checkedPromise = promise
880
- .catch((err): SearchCollectionState => {
881
- log.warn(
882
- `QMD startup collection check for ${label} failed; keeping search enabled fail-open: ${err}`,
883
- );
884
- return "unknown";
885
- })
886
- .finally(() => {
887
- settled = true;
888
- if (timer) clearTimeout(timer);
889
- });
890
-
891
- return await Promise.race([checkedPromise, timeoutPromise]);
892
- }
893
-
894
- /** Default workspace directory when no per-agent or config workspace is available. */
895
- export function defaultWorkspaceDir(): string {
896
- return path.join(os.homedir(), ".openclaw", "workspace");
897
- }
898
-
899
- /**
900
- * Produce a collision-resistant, filesystem-safe identifier from a session key.
901
- *
902
- * Session keys follow colon-delimited forms (e.g., `agent:gpucodebot:main`).
903
- * A naive replace (`:` → `_`) is lossy: different keys like `agent:alpha` and
904
- * `agent/alpha` would collide. Instead we append a short SHA-256 hash of the
905
- * original key to the human-readable sanitized prefix, guaranteeing uniqueness
906
- * while keeping filenames debuggable.
907
- *
908
- * Format: `<sanitized>-<12-char-hex-hash>`
909
- * Example: `agent:gpucodebot:main` → `agent_gpucodebot_main-a1b2c3d4e5f6`
910
- */
911
- export function sanitizeSessionKeyForFilename(sessionKey: string): string {
912
- const readable = sessionKey.replace(/[^a-zA-Z0-9._-]/g, "_");
913
- const hash = createHash("sha256")
914
- .update(sessionKey)
915
- .digest("hex")
916
- .slice(0, 12);
917
- return `${readable}-${hash}`;
918
- }
919
545
 
920
- function sourceValidAtMs(turn: BufferTurn): number | null {
921
- if (typeof turn.sourceValidAt !== "string") return null;
922
- return parseFlexibleIsoTimestamp(turn.sourceValidAt.trim());
923
- }
924
-
925
- const SOURCE_VALID_AT_CONTEXT_TURNS = 2;
926
-
927
- function sourceValidAtSliceKey(turn: BufferTurn, index: number): string {
928
- const validAtMs = sourceValidAtMs(turn);
929
- return validAtMs === null ? `unknown:${index}` : String(validAtMs);
930
- }
931
-
932
- function asExtractionContextTurn(turn: BufferTurn): BufferTurn {
933
- return { ...turn, extractionContextOnly: true };
934
- }
935
-
936
- function asExtractionTargetTurn(turn: BufferTurn): BufferTurn {
937
- const { extractionContextOnly: _contextOnly, ...targetTurn } = turn;
938
- return targetTurn;
939
- }
940
-
941
- function sourceValidAtContextTurns(
942
- turns: readonly BufferTurn[],
943
- targetStart: number,
944
- targetEnd: number,
945
- targetValidAtMs: number | null,
946
- ): BufferTurn[] {
947
- if (targetValidAtMs === null) return [];
948
- return turns
949
- .flatMap((turn, index) => {
950
- if (index >= targetStart && index < targetEnd) return [];
951
- const contextValidAtMs = sourceValidAtMs(turn);
952
- if (contextValidAtMs === null || contextValidAtMs > targetValidAtMs) {
953
- return [];
954
- }
955
- return [{ turn, index, validAtMs: contextValidAtMs }];
956
- })
957
- .sort((a, b) => {
958
- if (a.validAtMs < b.validAtMs) return -1;
959
- if (a.validAtMs > b.validAtMs) return 1;
960
- if (a.index === b.index) return 0;
961
- return a.index < b.index ? -1 : 1;
962
- })
963
- .slice(-SOURCE_VALID_AT_CONTEXT_TURNS)
964
- .map(({ turn }) => asExtractionContextTurn(turn));
965
- }
966
-
967
- export function targetSourceValidAtSortMs(turns: readonly BufferTurn[]): number {
968
- let latestMs: number | null = null;
969
- for (const turn of turns) {
970
- if (turn.extractionContextOnly === true) continue;
971
- const validAtMs = sourceValidAtMs(turn);
972
- if (validAtMs === null) continue;
973
- if (latestMs === null || validAtMs > latestMs) {
974
- latestMs = validAtMs;
975
- }
976
- }
977
- return latestMs ?? Number.POSITIVE_INFINITY;
978
- }
979
-
980
- function sortSourceValidAtSlicesChronologically(
981
- slices: BufferTurn[][],
982
- ): BufferTurn[][] {
983
- return slices
984
- .map((turns, order) => ({
985
- turns,
986
- order,
987
- targetValidAtMs: targetSourceValidAtSortMs(turns),
988
- }))
989
- .sort((a, b) => {
990
- if (a.targetValidAtMs < b.targetValidAtMs) return -1;
991
- if (a.targetValidAtMs > b.targetValidAtMs) return 1;
992
- if (a.order === b.order) return 0;
993
- return a.order < b.order ? -1 : 1;
994
- })
995
- .map((slice) => slice.turns);
996
- }
997
-
998
- export function splitTurnsBySourceValidAt(
999
- turns: readonly BufferTurn[],
1000
- options: { includeContext?: boolean } = {},
1001
- ): BufferTurn[][] {
1002
- if (turns.length === 0) return [];
1003
- if (!turns.some((turn) => sourceValidAtMs(turn) !== null)) {
1004
- return [[...turns]];
1005
- }
1006
-
1007
- const slices: BufferTurn[][] = [];
1008
- let start = 0;
1009
- while (start < turns.length) {
1010
- const targetValidAtMs = sourceValidAtMs(turns[start]);
1011
- const activeKey = sourceValidAtSliceKey(turns[start], start);
1012
- let end = start + 1;
1013
- while (
1014
- end < turns.length &&
1015
- sourceValidAtSliceKey(turns[end], end) === activeKey
1016
- ) {
1017
- end += 1;
1018
- }
1019
-
1020
- const contextTurns =
1021
- options.includeContext === false
1022
- ? []
1023
- : sourceValidAtContextTurns(turns, start, end, targetValidAtMs);
1024
- slices.push([
1025
- ...contextTurns,
1026
- ...turns.slice(start, end).map(asExtractionTargetTurn),
1027
- ]);
1028
- start = end;
1029
- }
1030
- return sortSourceValidAtSlicesChronologically(slices);
1031
- }
1032
-
1033
- export function isArtifactMemoryPath(filePath: string): boolean {
1034
- return /(?:^|[\\/])artifacts(?:[\\/]|$)/i.test(filePath);
1035
- }
1036
546
  // Issue #1526 seam 15: deriveTopicsFromExtraction moved to orchestration/extraction-run.ts.
1037
547
  export { deriveTopicsFromExtraction } from "./orchestration/extraction-run.js";
1038
- export function buildCompressionGuidelinesMarkdown(
1039
- events: MemoryActionEvent[],
1040
- generatedAtIso: string = new Date().toISOString(),
1041
- ): string {
1042
- return buildCompressionGuidelinesMarkdownV2(events, generatedAtIso);
1043
- }
1044
548
 
1045
- export function filterRecallCandidates(
1046
- candidates: QmdSearchResult[],
1047
- options: {
1048
- namespacesEnabled: boolean;
1049
- recallNamespaces: string[];
1050
- resolveNamespace: (path: string) => string;
1051
- limit: number;
1052
- },
1053
- ): QmdSearchResult[] {
1054
- const scopedByNamespace = options.namespacesEnabled
1055
- ? candidates.filter((r) =>
1056
- options.recallNamespaces.includes(options.resolveNamespace(r.path)),
1057
- )
1058
- : candidates;
1059
- return scopedByNamespace
1060
- .filter((r) => !isArtifactMemoryPath(r.path))
1061
- .slice(0, Math.max(0, options.limit));
1062
- }
1063
-
1064
- export function applyQueryAwareCandidateFilter(
1065
- candidates: QmdSearchResult[],
1066
- candidatePaths: Set<string> | null,
1067
- ): QmdSearchResult[] {
1068
- if (!candidatePaths) return candidates;
1069
- if (candidatePaths.size === 0) return [];
1070
- const filtered = candidates.filter((candidate) =>
1071
- candidatePaths.has(candidate.path),
1072
- );
1073
- return filtered.length > 0 ? filtered : candidates;
1074
- }
1075
-
1076
- export function tokenizeRecallQuery(prompt: string): string[] {
1077
- return prompt
1078
- .toLowerCase()
1079
- .split(/[^a-z0-9]+/i)
1080
- .map((t) => t.trim())
1081
- .filter((t) => t.length >= 3);
1082
- }
1083
-
1084
- function hasLifecycleMetadata(frontmatter: MemoryFrontmatter): boolean {
1085
- return (
1086
- frontmatter.lifecycleState !== undefined ||
1087
- frontmatter.verificationState !== undefined ||
1088
- frontmatter.policyClass !== undefined ||
1089
- frontmatter.lastValidatedAt !== undefined ||
1090
- frontmatter.decayScore !== undefined ||
1091
- frontmatter.heatScore !== undefined
1092
- );
1093
- }
1094
-
1095
- export function shouldFilterLifecycleRecallCandidate(
1096
- frontmatter: MemoryFrontmatter,
1097
- options: {
1098
- lifecyclePolicyEnabled: boolean;
1099
- lifecycleFilterStaleEnabled: boolean;
1100
- },
1101
- ): boolean {
1102
- if (!options.lifecyclePolicyEnabled || !options.lifecycleFilterStaleEnabled)
1103
- return false;
1104
- if (!hasLifecycleMetadata(frontmatter)) return false;
1105
- const lifecycleState = resolveLifecycleState(frontmatter);
1106
- return lifecycleState === "stale" || lifecycleState === "archived";
1107
- }
1108
-
1109
- export function lifecycleRecallScoreAdjustment(
1110
- frontmatter: MemoryFrontmatter,
1111
- options: {
1112
- lifecyclePolicyEnabled: boolean;
1113
- },
1114
- ): number {
1115
- if (!options.lifecyclePolicyEnabled) return 0;
1116
- if (!hasLifecycleMetadata(frontmatter)) return 0;
1117
-
1118
- let delta = 0;
1119
- const lifecycleState = resolveLifecycleState(frontmatter);
1120
- switch (lifecycleState) {
1121
- case "active":
1122
- delta += 0.05;
1123
- break;
1124
- case "validated":
1125
- delta += 0.03;
1126
- break;
1127
- case "candidate":
1128
- delta -= 0.01;
1129
- break;
1130
- case "stale":
1131
- delta -= 0.06;
1132
- break;
1133
- case "archived":
1134
- delta -= 0.08;
1135
- break;
1136
- }
1137
- if (frontmatter.verificationState === "disputed") {
1138
- delta -= 0.12;
1139
- }
1140
- return delta;
1141
- }
1142
-
1143
- export function computeArtifactRecallLimit(
1144
- recallMode: RecallPlanMode,
1145
- recallResultLimit: number,
1146
- verbatimArtifactsMaxRecall: number,
1147
- ): number {
1148
- if (recallMode === "no_recall") return 0;
1149
- if (Math.max(0, recallResultLimit) === 0) return 0;
1150
- const base = Math.max(0, verbatimArtifactsMaxRecall);
1151
- if (recallMode === "minimal") {
1152
- return Math.min(base, Math.max(0, recallResultLimit));
1153
- }
1154
- return base;
1155
- }
1156
-
1157
- export function resolveEffectiveRecallMode(options: {
1158
- plannerEnabled: boolean;
1159
- graphRecallEnabled: boolean;
1160
- multiGraphMemoryEnabled: boolean;
1161
- graphExpandedIntentEnabled?: boolean;
1162
- prompt: string;
1163
- }): RecallPlanMode {
1164
- return resolveRecallModeDecision(options).effectiveMode;
1165
- }
1166
-
1167
- interface RecallModeGraphOptions {
1168
- plannerEnabled: boolean;
1169
- graphRecallEnabled: boolean;
1170
- multiGraphMemoryEnabled: boolean;
1171
- graphExpandedIntentEnabled?: boolean;
1172
- prompt: string;
1173
- }
1174
-
1175
- /**
1176
- * Apply the graph-mode overlay + gating to a planner-produced mode.
1177
- *
1178
- * Shared by the heuristic ({@link resolveRecallModeDecision}) and LLM
1179
- * ({@link resolveRecallModeDecisionAsync}) paths so graph promotion and the
1180
- * "graph disabled → fall back to full" gating behave identically regardless of
1181
- * which planner produced `plannedModeRaw` (gotcha #39).
1182
- */
1183
- function finalizeRecallModeDecision(
1184
- plannedModeRaw: RecallPlanMode,
1185
- options: RecallModeGraphOptions,
1186
- ): RecallModeDecision {
1187
- let plannedMode: RecallPlanMode = plannedModeRaw;
1188
- const graphExpandedIntentDetected =
1189
- options.plannerEnabled &&
1190
- options.graphExpandedIntentEnabled === true &&
1191
- hasBroadGraphIntent(options.prompt);
1192
- if (plannedMode !== "graph_mode" && graphExpandedIntentDetected) {
1193
- plannedMode = "graph_mode";
1194
- }
1195
- if (
1196
- plannedMode === "graph_mode" &&
1197
- (!options.graphRecallEnabled || !options.multiGraphMemoryEnabled)
1198
- ) {
1199
- return {
1200
- plannedMode,
1201
- effectiveMode: "full",
1202
- graphExpandedIntentDetected,
1203
- graphReason: !options.graphRecallEnabled
1204
- ? "graph recall disabled by config"
1205
- : "multi-graph memory disabled by config",
1206
- };
1207
- }
1208
- return {
1209
- plannedMode,
1210
- effectiveMode: plannedMode,
1211
- graphExpandedIntentDetected,
1212
- };
1213
- }
1214
-
1215
- export function resolveRecallModeDecision(options: RecallModeGraphOptions): RecallModeDecision {
1216
- const plannedMode: RecallPlanMode = options.plannerEnabled
1217
- ? planRecallMode(options.prompt)
1218
- : "full";
1219
- return finalizeRecallModeDecision(plannedMode, options);
1220
- }
1221
-
1222
- /**
1223
- * Async recall-mode decision with optional LLM-based planning (issue #1367 /
1224
- * Option C). Falls back to the heuristic decision when the LLM planner is
1225
- * disabled, in shadow mode, or unavailable/failed — so this is always safe to
1226
- * await on the recall hot path. Provider-agnostic: the LLM call routes through
1227
- * the gateway/fallback chain.
1228
- *
1229
- * `recallPlannerEnabled === false` keeps the legacy "always full" behavior and
1230
- * skips the LLM entirely (the planner as a whole is off).
1231
- */
1232
- export async function resolveRecallModeDecisionAsync(
1233
- options: RecallModeGraphOptions & {
1234
- config: PluginConfig;
1235
- /**
1236
- * Recall-operation capability gates (issue #1523). REQUIRED: the recall
1237
- * orchestrator always passes a resolved set — the LLM planner gate reads
1238
- * `caps.recallPlannerLlm`, never re-derives from config.
1239
- */
1240
- caps: CapabilitySet;
1241
- hints?: string[];
1242
- llm?: FallbackLlmClient;
1243
- signal?: AbortSignal;
1244
- },
1245
- ): Promise<RecallModeDecision> {
1246
- const heuristicDecision = resolveRecallModeDecision(options);
1247
-
1248
- // Planner globally off, or LLM planning not opted into → heuristic only.
1249
- // Read the resolved capability (issue #1523) — never re-derive from config.
1250
- const plannerLlmEnabled = options.caps.recallPlannerLlm;
1251
- if (!options.plannerEnabled || !plannerLlmEnabled) {
1252
- return heuristicDecision;
1253
- }
1254
-
1255
- const { planRecallModeLLM } = await import("./recall-planner-llm.js");
1256
- const planned = await planRecallModeLLM(
1257
- options.prompt,
1258
- options.hints,
1259
- options.config,
1260
- options.caps,
1261
- options.llm,
1262
- options.signal,
1263
- );
1264
-
1265
- // Shadow mode: record what the LLM would have chosen but keep the heuristic
1266
- // effective decision (safe rollout / comparison — gotcha #30).
1267
- if (options.config.recallPlannerShadowMode) {
1268
- return {
1269
- ...heuristicDecision,
1270
- plannerSource: planned.source,
1271
- plannerReason: `shadow:${planned.reason}`,
1272
- plannerLatencyMs: planned.latencyMs,
1273
- plannerFallbackUsed: planned.fallbackUsed,
1274
- plannerModelUsed: planned.modelUsed,
1275
- plannerHeuristicMode: planned.heuristicMode,
1276
- shadowLlmMode: planned.mode,
1277
- };
1278
- }
1279
-
1280
- const llmDecision = finalizeRecallModeDecision(planned.mode, options);
1281
- return {
1282
- ...llmDecision,
1283
- plannerSource: planned.source,
1284
- plannerReason: planned.reason,
1285
- plannerLatencyMs: planned.latencyMs,
1286
- plannerFallbackUsed: planned.fallbackUsed,
1287
- plannerModelUsed: planned.modelUsed,
1288
- plannerHeuristicMode: planned.heuristicMode,
1289
- };
1290
- }
1291
-
1292
- export function computeArtifactCandidateFetchLimit(
1293
- targetCount: number,
1294
- ): number {
1295
- const cappedTarget = Math.max(0, targetCount);
1296
- if (cappedTarget === 0) return 0;
1297
- const headroom = Math.max(8, cappedTarget * 4);
1298
- return Math.min(200, cappedTarget + headroom);
1299
- }
1300
-
1301
- export function computeQmdHybridFetchLimit(
1302
- recallFetchLimit: number,
1303
- artifactsEnabled: boolean,
1304
- maxArtifactRecall: number,
1305
- ): number {
1306
- const cappedRecallLimit = Math.max(0, recallFetchLimit);
1307
- if (cappedRecallLimit === 0) return 0;
1308
- if (!artifactsEnabled) return cappedRecallLimit;
1309
- // Overscan when artifacts are enabled, then filter artifact paths before
1310
- // re-applying the recall cap to avoid artifact-dominated top-N starvation.
1311
- const artifactHeadroom = Math.max(20, Math.max(0, maxArtifactRecall) * 8);
1312
- return Math.min(400, cappedRecallLimit + artifactHeadroom);
1313
- }
1314
- export function summarizeGraphShadowComparison(
1315
- baseline: QmdSearchResult[],
1316
- merged: QmdSearchResult[],
1317
- topN: number,
1318
- ): {
1319
- baselineCount: number;
1320
- graphCount: number;
1321
- overlapCount: number;
1322
- overlapRatio: number;
1323
- averageOverlapDelta: number;
1324
- } {
1325
- const limit = Math.max(0, Math.floor(topN));
1326
- const baselineTop = limit > 0 ? baseline.slice(0, limit) : [];
1327
- const graphTop = limit > 0 ? merged.slice(0, limit) : [];
1328
- const baselineByPath = new Map(
1329
- baselineTop.map((item) => [item.path, item.score]),
1330
- );
1331
- const graphByPath = new Map(graphTop.map((item) => [item.path, item.score]));
1332
-
1333
- let overlapCount = 0;
1334
- let overlapDeltaSum = 0;
1335
- for (const [p, baselineScore] of baselineByPath.entries()) {
1336
- const graphScore = graphByPath.get(p);
1337
- if (typeof graphScore !== "number") continue;
1338
- overlapCount += 1;
1339
- overlapDeltaSum += graphScore - baselineScore;
1340
- }
1341
-
1342
- const baselineCount = baselineTop.length;
1343
- return {
1344
- baselineCount,
1345
- graphCount: graphTop.length,
1346
- overlapCount,
1347
- overlapRatio: baselineCount > 0 ? overlapCount / baselineCount : 0,
1348
- averageOverlapDelta: overlapCount > 0 ? overlapDeltaSum / overlapCount : 0,
1349
- };
1350
- }
1351
-
1352
- export function parseGraphRecallRankedResults(
1353
- value: unknown,
1354
- ): GraphRecallRankedResult[] {
1355
- if (!Array.isArray(value)) return [];
1356
- const parsed: GraphRecallRankedResult[] = [];
1357
- for (const entry of value) {
1358
- if (!entry || typeof entry !== "object") continue;
1359
- const candidate = entry as Partial<GraphRecallRankedResult>;
1360
- if (
1361
- typeof candidate.path !== "string" ||
1362
- typeof candidate.score !== "number"
1363
- )
1364
- continue;
1365
- parsed.push({
1366
- path: candidate.path,
1367
- score: candidate.score,
1368
- docid: typeof candidate.docid === "string" ? candidate.docid : undefined,
1369
- sourceLabels: Array.isArray(candidate.sourceLabels)
1370
- ? candidate.sourceLabels.filter(
1371
- (item): item is string => typeof item === "string",
1372
- )
1373
- : [],
1374
- });
1375
- }
1376
- return parsed.slice(0, 64);
1377
- }
1378
-
1379
- export function parseMemoryIntentSnapshot(value: unknown): MemoryIntent {
1380
- const candidate =
1381
- value && typeof value === "object" ? (value as Partial<MemoryIntent>) : {};
1382
- return {
1383
- goal: typeof candidate.goal === "string" ? candidate.goal : "unknown",
1384
- actionType:
1385
- typeof candidate.actionType === "string"
1386
- ? candidate.actionType
1387
- : "unknown",
1388
- entityTypes: Array.isArray(candidate.entityTypes)
1389
- ? candidate.entityTypes.filter(
1390
- (item): item is string => typeof item === "string",
1391
- )
1392
- : [],
1393
- taskInitiation: candidate.taskInitiation === true,
1394
- };
1395
- }
1396
-
1397
- function buildQmdIntentHint(intent: MemoryIntent): string | undefined {
1398
- const parts: string[] = [];
1399
- if (intent.goal !== "unknown") {
1400
- parts.push(`goal:${intent.goal.replace(/_/g, " ")}`);
1401
- }
1402
- if (intent.actionType !== "unknown") {
1403
- parts.push(`action:${intent.actionType.replace(/_/g, " ")}`);
1404
- }
1405
- if (intent.entityTypes.length > 0) {
1406
- parts.push(`entities:${intent.entityTypes.join(",")}`);
1407
- }
1408
- if (intent.taskInitiation === true) {
1409
- parts.push("task_initiation");
1410
- }
1411
- return parts.length > 0 ? parts.join(" ") : undefined;
1412
- }
1413
-
1414
- export function parseQmdRecallResults(value: unknown): QmdSearchResult[] {
1415
- if (!Array.isArray(value)) return [];
1416
- const parsed: QmdSearchResult[] = [];
1417
- for (const entry of value) {
1418
- if (!entry || typeof entry !== "object") continue;
1419
- const candidate = entry as Partial<QmdSearchResult>;
1420
- if (
1421
- typeof candidate.path !== "string" ||
1422
- typeof candidate.score !== "number"
1423
- )
1424
- continue;
1425
- parsed.push({
1426
- docid: typeof candidate.docid === "string" ? candidate.docid : "",
1427
- path: candidate.path,
1428
- snippet: typeof candidate.snippet === "string" ? candidate.snippet : "",
1429
- score: candidate.score,
1430
- explain: parseQmdExplain(candidate.explain),
1431
- transport:
1432
- candidate.transport === "daemon" ||
1433
- candidate.transport === "subprocess" ||
1434
- candidate.transport === "hybrid" ||
1435
- candidate.transport === "scoped_prefilter"
1436
- ? candidate.transport
1437
- : undefined,
1438
- });
1439
- }
1440
- return parsed.slice(0, 32);
1441
- }
1442
-
1443
- export function mergeArtifactRecallCandidates(
1444
- candidatesByNamespace: MemoryFile[][],
1445
- limit: number,
1446
- ): MemoryFile[] {
1447
- const cappedLimit = Math.max(0, limit);
1448
- if (cappedLimit === 0) return [];
1449
-
1450
- const out: MemoryFile[] = [];
1451
- const seen = new Set<string>();
1452
- let offset = 0;
1453
- while (out.length < cappedLimit) {
1454
- let hasAnyCandidateAtOffset = false;
1455
- for (const list of candidatesByNamespace) {
1456
- if (offset >= list.length) continue;
1457
- hasAnyCandidateAtOffset = true;
1458
- const item = list[offset];
1459
- const dedupeKey = `${item.frontmatter.id}:${item.frontmatter.sourceMemoryId ?? ""}:${item.content}`;
1460
- if (seen.has(dedupeKey)) continue;
1461
- seen.add(dedupeKey);
1462
- out.push(item);
1463
- if (out.length >= cappedLimit) break;
1464
- }
1465
- if (!hasAnyCandidateAtOffset) break;
1466
- offset += 1;
1467
- }
1468
- return out;
1469
- }
1470
-
1471
- export function resolveRecentThreadMemoryPaths(options: {
1472
- threadEpisodeIds: string[];
1473
- currentMemoryId: string;
1474
- allMemsForGraph: MemoryFile[] | null | undefined;
1475
- pathById?: Map<string, string>;
1476
- storageDir: string;
1477
- maxRecent: number;
1478
- }): string[] {
1479
- const maxRecent = Math.max(0, options.maxRecent);
1480
- if (options.threadEpisodeIds.length === 0 || maxRecent === 0) return [];
1481
- const pathById =
1482
- options.pathById ??
1483
- buildMemoryPathById(options.allMemsForGraph, options.storageDir);
1484
- if (pathById.size === 0) return [];
1485
-
1486
- // #1635 (defensive): skip pending_review ids from legacy episode sets.
1487
- const pendingReviewIds = new Set<string>(
1488
- (options.allMemsForGraph ?? [])
1489
- .filter((m) => m.frontmatter.status === "pending_review" && m.frontmatter.id)
1490
- .map((m) => m.frontmatter.id as string),
1491
- );
1492
-
1493
- return options.threadEpisodeIds
1494
- .filter((id) => id !== options.currentMemoryId)
1495
- .filter((id) => !pendingReviewIds.has(id))
1496
- .slice(-maxRecent)
1497
- .map((id) => pathById.get(id))
1498
- .filter((p): p is string => typeof p === "string" && p.length > 0);
1499
- }
1500
-
1501
- export function buildMemoryPathById(
1502
- allMemsForGraph: MemoryFile[] | null | undefined,
1503
- storageDir: string,
1504
- ): Map<string, string> {
1505
- const pathById = new Map<string, string>();
1506
- for (const mem of allMemsForGraph ?? []) {
1507
- const id = mem.frontmatter.id;
1508
- if (!id) continue;
1509
- pathById.set(id, path.relative(storageDir, mem.path));
1510
- }
1511
- return pathById;
1512
- }
1513
-
1514
- export function appendMemoryToGraphContext(options: {
1515
- allMemsForGraph: MemoryFile[] | null | undefined;
1516
- storageDir: string;
1517
- memoryRelPath: string;
1518
- memoryId: string;
1519
- category: MemoryFile["frontmatter"]["category"];
1520
- content: string;
1521
- entityRef: string | undefined;
1522
- }): void {
1523
- if (!Array.isArray(options.allMemsForGraph)) return;
1524
-
1525
- const nowIso = new Date().toISOString();
1526
- options.allMemsForGraph.push({
1527
- path: path.join(options.storageDir, options.memoryRelPath),
1528
- content: options.content,
1529
- frontmatter: {
1530
- id: options.memoryId,
1531
- category: options.category,
1532
- created: nowIso,
1533
- updated: nowIso,
1534
- source: "extraction",
1535
- confidence: 0.8,
1536
- confidenceTier: "implied",
1537
- tags: [],
1538
- entityRef: options.entityRef,
1539
- status: "active",
1540
- },
1541
- });
1542
- }
1543
-
1544
- export function resolvePersistedMemoryRelativePath(options: {
1545
- memoryId: string;
1546
- pathById: Map<string, string>;
1547
- category: string;
1548
- }): string {
1549
- const persisted = options.pathById.get(options.memoryId);
1550
- if (persisted) return persisted;
1551
- if (options.category === "correction") {
1552
- return path.join("corrections", `${options.memoryId}.md`);
1553
- }
1554
- // Pick the subtree that matches the StorageManager.writeMemory routing
1555
- // so fallback paths (used before memoryPathById has seen the fresh
1556
- // write) agree with where the file actually lives. Routing goes through
1557
- // the shared categoryDirName() chokepoint (utils/category-dir.ts) so
1558
- // every category — decisions/, preferences/, reasoning-traces/, ... —
1559
- // resolves to the same dir the writer used; otherwise graph edges point
1560
- // at the wrong subtree and graph expansion silently drops those nodes
1561
- // when readMemoryByPath cannot resolve them (issue #564 PR 3 / #1546).
1562
- const subtree = categoryDirName(options.category);
1563
- const idParts = options.memoryId.split("-");
1564
- const maybeTimestamp = Number(idParts[1]);
1565
- if (Number.isFinite(maybeTimestamp) && maybeTimestamp > 0) {
1566
- const day = new Date(maybeTimestamp).toISOString().slice(0, 10);
1567
- return path.join(subtree, day, `${options.memoryId}.md`);
1568
- }
1569
- return path.join(subtree, `${options.memoryId}.md`);
1570
- }
1571
549
 
1572
550
  export class Orchestrator {
1573
551
 
@@ -1706,7 +684,8 @@ export class Orchestrator {
1706
684
  statuses: Map<string, "active" | "superseded" | "archived" | "missing">;
1707
685
  }
1708
686
  >();
1709
- private static readonly ARTIFACT_STATUS_CACHE_TTL_MS = 60_000;
687
+ /** Read by NamespaceReadFanoutCoordinator (seam 26), hence not `private`. */
688
+ static readonly ARTIFACT_STATUS_CACHE_TTL_MS = 60_000;
1710
689
 
1711
690
  // Access tracking buffer (Phase 1A)
1712
691
  // Maps memoryId -> {count, lastAccessed} for batched updates
@@ -2273,98 +1252,17 @@ export class Orchestrator {
2273
1252
  resolvedStorageDir: string,
2274
1253
  configured: Set<string>,
2275
1254
  ): { namespace: string; identityToken: string; storageDir: string } {
2276
- const currentRank = this.namespaceStorageDirHintOwnershipRank(
1255
+ return this.namespaceReadFanoutCoordinator.preferNamespaceStorageDirHintOwner(
2277
1256
  current,
2278
- resolvedStorageDir,
2279
- configured,
2280
- );
2281
- const candidateRank = this.namespaceStorageDirHintOwnershipRank(
2282
1257
  candidate,
2283
1258
  resolvedStorageDir,
2284
1259
  configured,
2285
1260
  );
2286
- if (candidateRank < currentRank) return candidate;
2287
- if (candidateRank > currentRank) return current;
2288
-
2289
- const byName = candidate.namespace.localeCompare(current.namespace);
2290
- if (byName < 0) return candidate;
2291
- if (byName > 0) return current;
2292
- return candidate.identityToken.localeCompare(current.identityToken) < 0
2293
- ? candidate
2294
- : current;
2295
1261
  }
2296
1262
 
2297
1263
  private loadNamespaceStorageDirHintsFromCatalog(): void {
2298
- if (this.namespaceStorageDirHintsLoaded || !this.namespaceCatalog.enabled) return;
2299
- this.namespaceStorageDirHintsLoaded = true;
2300
- const catalogPath = path.join(this.config.memoryDir, "state", "namespaces.jsonl");
2301
- if (!existsSync(catalogPath)) return;
2302
-
2303
- let body: string;
2304
- try {
2305
- body = readFileSync(catalogPath, "utf8");
2306
- } catch {
2307
- return;
2308
- }
2309
-
2310
- const compactedByNamespace = new Map<
2311
- string,
2312
- { namespace: string; identityToken: string; storageDir: string }
2313
- >();
2314
- for (const line of body.split(/\r?\n/)) {
2315
- const trimmed = line.trim();
2316
- if (!trimmed) continue;
2317
- try {
2318
- const parsed = JSON.parse(trimmed) as unknown;
2319
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
2320
- const record = parsed as Record<string, unknown>;
2321
- if (
2322
- typeof record.namespace !== "string" ||
2323
- typeof record.storageDir !== "string" ||
2324
- typeof record.identityToken !== "string"
2325
- ) {
2326
- continue;
2327
- }
2328
- const namespace = normalizeNamespaceIdentity(record.namespace);
2329
- if (!namespace || record.identityToken !== namespaceIdentityToken(namespace)) continue;
2330
- compactedByNamespace.set(namespace, {
2331
- namespace,
2332
- identityToken: record.identityToken,
2333
- storageDir: record.storageDir,
2334
- });
2335
- } catch {
2336
- // Catalog hints are best-effort. The catalog reader still owns full recovery.
2337
- }
2338
- }
2339
-
2340
- const configured = new Set(
2341
- this.configuredNamespaceList().map((namespace) => normalizeNamespaceIdentity(namespace)),
1264
+ return this.namespaceReadFanoutCoordinator.loadNamespaceStorageDirHintsFromCatalog(
2342
1265
  );
2343
- const preferredByStorageDir = new Map<
2344
- string,
2345
- { namespace: string; identityToken: string; storageDir: string }
2346
- >();
2347
- for (const record of compactedByNamespace.values()) {
2348
- if (!this.storageDirMatchesNamespaceHint(record.namespace, record.storageDir)) {
2349
- continue;
2350
- }
2351
- const resolvedStorageDir = path.resolve(record.storageDir);
2352
- const current = preferredByStorageDir.get(resolvedStorageDir);
2353
- preferredByStorageDir.set(
2354
- resolvedStorageDir,
2355
- current
2356
- ? this.preferNamespaceStorageDirHintOwner(
2357
- current,
2358
- record,
2359
- resolvedStorageDir,
2360
- configured,
2361
- )
2362
- : record,
2363
- );
2364
- }
2365
- for (const record of preferredByStorageDir.values()) {
2366
- this.rememberNamespaceStorageDirHint(record.namespace, record.storageDir);
2367
- }
2368
1266
  }
2369
1267
 
2370
1268
  private async maintenanceNamespaces(
@@ -2440,68 +1338,9 @@ export class Orchestrator {
2440
1338
  searchOptions?: SearchQueryOptions;
2441
1339
  execution?: SearchExecutionOptions;
2442
1340
  }): Promise<QmdSearchResult[]> {
2443
- if (
2444
- resolveNamespaceCapabilities(this.config).namespaces &&
2445
- options.namespaces !== undefined &&
2446
- options.namespaces.length === 0
2447
- ) {
2448
- return [];
2449
- }
2450
- const namespaces = resolveNamespaceCapabilities(this.config).namespaces
2451
- ? Array.from(
2452
- new Set(
2453
- (options.namespaces?.length
2454
- ? options.namespaces
2455
- : this.configuredNamespaceList()
2456
- )
2457
- .map((value) => value.trim())
2458
- .filter(Boolean),
2459
- ),
2460
- )
2461
- : [this.config.defaultNamespace];
2462
-
2463
- if (!resolveNamespaceCapabilities(this.config).namespaces) {
2464
- switch (options.mode) {
2465
- case "hybrid":
2466
- return await this.qmd.hybridSearch(
2467
- options.query,
2468
- undefined,
2469
- options.maxResults,
2470
- options.execution,
2471
- );
2472
- case "bm25":
2473
- return await this.qmd.bm25Search(
2474
- options.query,
2475
- undefined,
2476
- options.maxResults,
2477
- options.execution,
2478
- );
2479
- case "vector":
2480
- return await this.qmd.vectorSearch(
2481
- options.query,
2482
- undefined,
2483
- options.maxResults,
2484
- options.execution,
2485
- );
2486
- default:
2487
- return await this.qmd.search(
2488
- options.query,
2489
- undefined,
2490
- options.maxResults,
2491
- options.searchOptions,
2492
- options.execution,
2493
- );
2494
- }
2495
- }
2496
-
2497
- return await this.namespaceSearchRouter.searchAcrossNamespaces({
2498
- query: options.query,
2499
- namespaces,
2500
- maxResults: options.maxResults,
2501
- mode: options.mode,
2502
- searchOptions: options.searchOptions,
2503
- execution: options.execution,
2504
- });
1341
+ return this.namespaceReadFanoutCoordinator.searchAcrossNamespaces(
1342
+ options,
1343
+ );
2505
1344
  }
2506
1345
 
2507
1346
  async searchHealthForNamespace(
@@ -3048,92 +1887,10 @@ export class Orchestrator {
3048
1887
  storage: StorageManager,
3049
1888
  sourceIds: string[],
3050
1889
  ): Promise<Map<string, "active" | "superseded" | "archived" | "missing">> {
3051
- const currentStatusVersion = storage.getMemoryStatusVersion();
3052
- const cached = this.artifactSourceStatusCache.get(storage);
3053
- let snapshot = cached;
3054
- const isFresh =
3055
- snapshot !== undefined &&
3056
- Date.now() - snapshot.loadedAtMs <=
3057
- Orchestrator.ARTIFACT_STATUS_CACHE_TTL_MS &&
3058
- snapshot.statusVersion === currentStatusVersion;
3059
-
3060
- const rebuildSnapshot = async () => {
3061
- const MAX_STABLE_READ_ATTEMPTS = 3;
3062
- let latestStatuses = new Map<
3063
- string,
3064
- "active" | "superseded" | "archived" | "missing"
3065
- >();
3066
- let latestVersionAfter = storage.getMemoryStatusVersion();
3067
-
3068
- for (let attempt = 0; attempt < MAX_STABLE_READ_ATTEMPTS; attempt += 1) {
3069
- const versionBefore = storage.getMemoryStatusVersion();
3070
- const allMemories = await storage.readAllMemories();
3071
- const versionAfter = storage.getMemoryStatusVersion();
3072
- latestVersionAfter = versionAfter;
3073
- latestStatuses = new Map(
3074
- allMemories.map((m) => [
3075
- m.frontmatter.id,
3076
- (m.frontmatter.status ?? "active") as
3077
- | "active"
3078
- | "superseded"
3079
- | "archived"
3080
- | "missing",
3081
- ]),
3082
- );
3083
-
3084
- if (versionAfter === versionBefore) {
3085
- const rebuilt = {
3086
- loadedAtMs: Date.now(),
3087
- statusVersion: versionAfter,
3088
- statuses: latestStatuses,
3089
- };
3090
- this.artifactSourceStatusCache.set(storage, rebuilt);
3091
- return rebuilt;
3092
- }
3093
- }
3094
-
3095
- // Sustained write churn: return latest read without caching a potentially torn snapshot.
3096
- return {
3097
- loadedAtMs: Date.now(),
3098
- statusVersion: latestVersionAfter,
3099
- statuses: latestStatuses,
3100
- };
3101
- };
3102
-
3103
- if (!isFresh) {
3104
- snapshot = await rebuildSnapshot();
3105
- } else {
3106
- // Warm cache may miss brand-new sourceMemoryId values created after snapshot build.
3107
- // Refresh once on-demand when unseen IDs are requested.
3108
- const hasUnknownSourceIds = sourceIds.some(
3109
- (id) => !snapshot?.statuses.has(id),
3110
- );
3111
- if (hasUnknownSourceIds) {
3112
- snapshot = await rebuildSnapshot();
3113
- }
3114
- }
3115
-
3116
- // Persist negative lookups in the cached snapshot so stale source IDs do not
3117
- // trigger repeated full snapshot rebuilds on every matching recall.
3118
- for (const id of sourceIds) {
3119
- if (!snapshot?.statuses.has(id)) {
3120
- snapshot?.statuses.set(id, "missing");
3121
- }
3122
- }
3123
-
3124
- const statuses = new Map<
3125
- string,
3126
- "active" | "superseded" | "archived" | "missing"
3127
- >();
3128
- for (const id of sourceIds) {
3129
- const status = snapshot?.statuses.get(id);
3130
- if (status) {
3131
- statuses.set(id, status);
3132
- } else {
3133
- statuses.set(id, "missing");
3134
- }
3135
- }
3136
- return statuses;
1890
+ return this.namespaceReadFanoutCoordinator.resolveArtifactSourceStatuses(
1891
+ storage,
1892
+ sourceIds,
1893
+ );
3137
1894
  }
3138
1895
 
3139
1896
  /**
@@ -3226,27 +1983,6 @@ export class Orchestrator {
3226
1983
  );
3227
1984
  }
3228
1985
 
3229
- /**
3230
- * Run the pattern-reinforcement maintenance job (issue #687 PR 2/4).
3231
- *
3232
- * Cadence-gated on `patternReinforcementCadenceMs` so every caller
3233
- * (orchestrator cron path, MCP tool, CLI) shares a single floor —
3234
- * none can call this on a hot loop and burn the corpus. When the
3235
- * feature is disabled or the cadence has not elapsed, returns a
3236
- * synthetic "skipped" result rather than throwing.
3237
- *
3238
- * Cadence tracking is per-namespace so a tenant-scoped MCP run in
3239
- * one namespace does not silence a cron run in another (PR #730
3240
- * review feedback, Codex P2). Pass `force: true` for ad-hoc
3241
- * operator runs that must bypass the cadence floor — mirrors the
3242
- * pattern used by other maintenance MCP tools.
3243
- *
3244
- * `force` deliberately does NOT bypass the master
3245
- * `patternReinforcementEnabled` flag (PR #730 review feedback,
3246
- * Cursor Medium). Operators who have explicitly disabled the
3247
- * feature must not have their corpus mutated by an MCP tool call —
3248
- * the only way to run the job is to enable the feature in config.
3249
- */
3250
1986
  async runPatternReinforcement(options: {
3251
1987
  force?: boolean;
3252
1988
  namespace?: string;
@@ -3256,69 +1992,16 @@ export class Orchestrator {
3256
1992
  namespace: string;
3257
1993
  result?: PatternReinforcementResult;
3258
1994
  }> {
3259
- const cadenceKey = options.namespace ?? "";
3260
- // Master switch: a disabled feature is never bypassed, even with
3261
- // force=true. `force` only relaxes the cadence floor below.
3262
- if (!resolveConsolidationCapabilities(this.config).patternReinforcement) {
3263
- return { ran: false, skippedReason: "disabled", namespace: cadenceKey };
3264
- }
3265
- const cadence = this.config.patternReinforcementCadenceMs;
3266
- const lastAt = this.lastPatternReinforcementAtByNs.get(cadenceKey);
3267
- if (
3268
- !options.force &&
3269
- cadence > 0 &&
3270
- lastAt !== undefined &&
3271
- Date.now() - lastAt < cadence
3272
- ) {
3273
- return { ran: false, skippedReason: "cadence", namespace: cadenceKey };
3274
- }
3275
- const storage = options.namespace
3276
- ? await this.getStorage(options.namespace)
3277
- : this.storage;
3278
- const result = await runPatternReinforcement(storage, {
3279
- categories: this.config.patternReinforcementCategories,
3280
- minCount: this.config.patternReinforcementMinCount,
3281
- });
3282
- this.lastPatternReinforcementAtByNs.set(cadenceKey, Date.now());
3283
- log.debug(
3284
- `pattern reinforcement [ns=${cadenceKey || "(default)"}]: clusters=${result.clustersFound} canonicalsUpdated=${result.canonicalsUpdated} duplicatesSuperseded=${result.duplicatesSuperseded}`,
1995
+ return this.workspaceOpsCoordinator.runPatternReinforcement(
1996
+ options,
3285
1997
  );
3286
- return { ran: true, result, namespace: cadenceKey };
3287
1998
  }
3288
1999
 
3289
- /**
3290
- * Fan out pattern reinforcement across all maintained namespaces (issue #1500).
3291
- * Delegates per-namespace execution to {@link runPatternReinforcement} while
3292
- * the planner handles discovery, budgeting, locking, and status recording.
3293
- * When namespaces are disabled, runs once against default storage.
3294
- */
3295
2000
  async runPatternReinforcementFanout(options: {
3296
2001
  force?: boolean;
3297
2002
  } = {}): Promise<NamespaceMaintenanceSummary> {
3298
- return this.runNamespaceMaintenanceFanoutForJob(
3299
- "pattern-reinforcement",
3300
- async (ctx) => {
3301
- const result = await this.runPatternReinforcement({
3302
- namespace: ctx.candidate.namespace,
3303
- force: options.force,
3304
- });
3305
- // runPatternReinforcement has its own per-namespace cadence gate
3306
- // (lastPatternReinforcementAtByNs). When it throttles (ran:false),
3307
- // signal skip so the planner records state:"skipped" and does NOT
3308
- // touch lastMaintenanceAt — otherwise a throttled namespace would
3309
- // look maintained while pattern reinforcement never ran (#1500
3310
- // review: cadence-skip accuracy).
3311
- if (!result.ran) {
3312
- return {
3313
- skipped: true,
3314
- skipReason: result.skippedReason ?? "throttled",
3315
- };
3316
- }
3317
- return result.result
3318
- ? { itemCount: result.result.clustersFound }
3319
- : { itemCount: 0 };
3320
- },
3321
- { enabled: resolveConsolidationCapabilities(this.config).patternReinforcement },
2003
+ return this.workspaceOpsCoordinator.runPatternReinforcementFanout(
2004
+ options,
3322
2005
  );
3323
2006
  }
3324
2007
 
@@ -3432,75 +2115,8 @@ export class Orchestrator {
3432
2115
  }
3433
2116
 
3434
2117
  async maybeRunFileHygiene(): Promise<void> {
3435
- const hygiene = this.config.fileHygiene;
3436
- if (!hygiene?.enabled) return;
3437
-
3438
- const now = Date.now();
3439
- if (now - this.lastFileHygieneRunAtMs < hygiene.runMinIntervalMs) return;
3440
- this.lastFileHygieneRunAtMs = now;
3441
-
3442
- // Rotation first (keeps bootstrap files small).
3443
- if (hygiene.rotateEnabled) {
3444
- for (const rel of hygiene.rotatePaths) {
3445
- const abs = path.isAbsolute(rel)
3446
- ? rel
3447
- : path.join(this.config.workspaceDir, rel);
3448
- try {
3449
- const raw = await readFile(abs, "utf-8");
3450
- if (raw.length > hygiene.rotateMaxBytes) {
3451
- const archiveDir = path.join(
3452
- this.config.workspaceDir,
3453
- hygiene.archiveDir,
3454
- );
3455
- const base = path.basename(abs);
3456
- const prefix =
3457
- base
3458
- .toUpperCase()
3459
- .replace(/\.MD$/i, "")
3460
- .replace(/[^A-Z0-9]+/g, "-") || "FILE";
3461
- const { newContent } = await rotateMarkdownFileToArchive({
3462
- filePath: abs,
3463
- archiveDir,
3464
- archivePrefix: prefix,
3465
- keepTailChars: hygiene.rotateKeepTailChars,
3466
- });
3467
- await writeFile(abs, newContent, "utf-8");
3468
- }
3469
- } catch {
3470
- // ignore missing/unreadable targets
3471
- }
3472
- }
3473
- }
3474
-
3475
- // Lint (warn before truncation risk).
3476
- if (hygiene.lintEnabled) {
3477
- const warnings = await lintWorkspaceFiles({
3478
- workspaceDir: this.config.workspaceDir,
3479
- paths: hygiene.lintPaths,
3480
- budgetBytes: hygiene.lintBudgetBytes,
3481
- warnRatio: hygiene.lintWarnRatio,
3482
- });
3483
- for (const w of warnings) {
3484
- log.warn(w.message);
3485
- }
3486
-
3487
- if (hygiene.warningsLogEnabled && warnings.length > 0) {
3488
- const fp = path.join(this.config.memoryDir, hygiene.warningsLogPath);
3489
- await mkdir(path.dirname(fp), { recursive: true });
3490
- const stamp = new Date().toISOString();
3491
- const block =
3492
- `\n\n## ${stamp}\n\n` +
3493
- warnings.map((w) => `- ${w.message}`).join("\n") +
3494
- "\n";
3495
- let existing = "";
3496
- try {
3497
- existing = await readFile(fp, "utf-8");
3498
- } catch {
3499
- existing = "# Engram File Hygiene Warnings\n";
3500
- }
3501
- await writeFile(fp, existing + block, "utf-8");
3502
- }
3503
- }
2118
+ return this.workspaceOpsCoordinator.maybeRunFileHygiene(
2119
+ );
3504
2120
  }
3505
2121
 
3506
2122
  async runBootstrap(options: BootstrapOptions): Promise<BootstrapResult> {
@@ -3651,282 +2267,22 @@ export class Orchestrator {
3651
2267
  return this.generateDaySummary(gathered);
3652
2268
  }
3653
2269
 
3654
- /**
3655
- * Read today's facts and hourly summaries from storage, returning them
3656
- * as a formatted string suitable for generateDaySummary().
3657
- */
3658
2270
  async gatherTodayFacts(
3659
2271
  namespace?: string,
3660
2272
  options: DaySummaryGatherOptions = {},
3661
2273
  ): Promise<string> {
3662
- const ns =
3663
- namespace && namespace.length > 0
3664
- ? namespace
3665
- : this.config.defaultNamespace;
3666
- const storage = await this.storageRouter.storageFor(ns);
3667
- const configuredTimeZone = normalizeIanaTimeZone(options.timeZone)
3668
- ?? normalizeIanaTimeZone(this.config.daySummaryTimezone);
3669
- const timeZone =
3670
- configuredTimeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
3671
- const now = options.now instanceof Date && Number.isFinite(options.now.getTime())
3672
- ? options.now
3673
- : new Date();
3674
- const targetLocalDate = formatDateInTimeZone(now, timeZone);
3675
- // Facts are stored under UTC date directories, while the summary target is
3676
- // a local calendar day. Scan the UTC-date envelope that overlaps the local
3677
- // day, then filter parseable fact timestamps to that configured local day.
3678
- const datesToScan = utcDateKeysForLocalDay(now, timeZone);
3679
- const MAX_CHARS = 100_000;
3680
-
3681
- // --- Read memory files from each category dir × date directory ---
3682
- // Iterate every recall category dir (RECALL_FALLBACK_DIRS — single source
3683
- // of truth) so the day summary includes decisions/, moments/, ... not just
3684
- // facts/ (#1546). corrections/ is flat, so corrections/<date>/ never exists
3685
- // and is skipped by the ENOENT guard — preserving the prior exclusion. The
3686
- // per-file created→local-day filter below is unchanged.
3687
- //
3688
- // Symlink/containment hardening (mirrors scanDir / the CLI walker): the
3689
- // gathered contents feed the day-summary LLM input, so a symlinked category
3690
- // dir (decisions/ → outside memoryDir) must not be followed and leak files.
3691
- // Resolve the store root once; skip symlinked / out-of-root dirs and
3692
- // entries; skip the scan gracefully if the root can't be resolved.
3693
- const facts: MemoryFile[] = [];
3694
- let memoryRootReal: string | null = null;
3695
- try {
3696
- memoryRootReal = await realpath(storage.dir);
3697
- } catch {
3698
- memoryRootReal = null;
3699
- }
3700
- for (const categoryDir of RECALL_FALLBACK_DIRS) {
3701
- if (memoryRootReal === null) break;
3702
- for (const date of datesToScan) {
3703
- const dateDir = path.join(storage.dir, categoryDir, date);
3704
- try {
3705
- const dirStat = await lstat(dateDir);
3706
- if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) continue;
3707
- assertPathInsideRoot(memoryRootReal, await realpath(dateDir), dateDir);
3708
- const entries = await readdir(dateDir, { withFileTypes: true });
3709
- for (const entry of entries) {
3710
- if (entry.isSymbolicLink()) continue;
3711
- if (!entry.name.endsWith(".md")) continue;
3712
- const fullPath = path.join(dateDir, entry.name);
3713
- try {
3714
- assertPathInsideRoot(memoryRootReal, await realpath(fullPath), fullPath);
3715
- const raw = await readFile(fullPath, "utf-8");
3716
- const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
3717
- if (!fmMatch) continue;
3718
- const fmBlock = fmMatch[1];
3719
- const content = fmMatch[2].trim();
3720
- const fm: Record<string, string> = {};
3721
- for (const line of fmBlock.split("\n")) {
3722
- const colonIdx = line.indexOf(":");
3723
- if (colonIdx === -1) continue;
3724
- fm[line.slice(0, colonIdx).trim()] = line
3725
- .slice(colonIdx + 1)
3726
- .trim();
3727
- }
3728
- const created = fm.created || "unknown";
3729
- const createdAt = parseFiniteDate(created);
3730
- if (
3731
- createdAt &&
3732
- formatDateInTimeZone(createdAt, timeZone) !== targetLocalDate
3733
- ) {
3734
- continue;
3735
- }
3736
- facts.push({
3737
- path: fullPath,
3738
- frontmatter: {
3739
- id: fm.id || path.basename(entry.name, ".md"),
3740
- category: (fm.category as any) || "fact",
3741
- created,
3742
- updated: fm.updated || created,
3743
- source: fm.source || "unknown",
3744
- confidence: parseFloat(fm.confidence || "0.8"),
3745
- confidenceTier: (fm.confidenceTier as any) || "implied",
3746
- tags: [],
3747
- },
3748
- content,
3749
- });
3750
- } catch {
3751
- // Skip unreadable files
3752
- }
3753
- }
3754
- } catch {
3755
- // Absent dir (ENOENT), symlinked/out-of-root dir, or containment
3756
- // violation — skip this category/date without aborting the summary.
3757
- }
3758
- }
3759
- }
3760
-
3761
- // Sort facts by created timestamp (most recent last) so truncation keeps newest
3762
- facts.sort((a, b) => {
3763
- if (a.frontmatter.created === b.frontmatter.created) return 0;
3764
- return a.frontmatter.created < b.frontmatter.created ? -1 : 1;
3765
- });
3766
-
3767
- // --- Read hourly summaries for the scanned dates ---
3768
- const hourlySummaries: string[] = [];
3769
- const hourlyBaseDir = path.join(storage.dir, "summaries", "hourly");
3770
- try {
3771
- const sessionKeys = await readdir(hourlyBaseDir, { withFileTypes: true });
3772
- for (const sk of sessionKeys) {
3773
- if (!sk.isDirectory()) continue;
3774
- for (const date of datesToScan) {
3775
- const summaryFile = path.join(hourlyBaseDir, sk.name, `${date}.md`);
3776
- try {
3777
- const raw = await readFile(summaryFile, "utf-8");
3778
- const filtered = filterHourlySummaryMarkdownForLocalDay(
3779
- raw,
3780
- date,
3781
- timeZone,
3782
- targetLocalDate,
3783
- );
3784
- if (filtered) {
3785
- hourlySummaries.push(filtered);
3786
- }
3787
- } catch {
3788
- // No summary file for this session/date
3789
- }
3790
- }
3791
- }
3792
- } catch {
3793
- // No hourly summaries directory
3794
- }
3795
-
3796
- // --- Format and truncate ---
3797
- let formatted = formatDaySummaryMemories(facts);
3798
- if (hourlySummaries.length > 0) {
3799
- formatted +=
3800
- "\n\n---\n## Hourly Summaries\n\n" +
3801
- hourlySummaries.join("\n\n---\n\n");
3802
- }
3803
-
3804
- // Truncate intelligently if over budget: drop oldest facts first
3805
- if (formatted.length > MAX_CHARS) {
3806
- // Re-build with fewer facts, keeping most recent
3807
- while (facts.length > 1 && formatted.length > MAX_CHARS) {
3808
- facts.shift(); // drop oldest
3809
- formatted = formatDaySummaryMemories(facts);
3810
- if (hourlySummaries.length > 0) {
3811
- formatted +=
3812
- "\n\n---\n## Hourly Summaries\n\n" +
3813
- hourlySummaries.join("\n\n---\n\n");
3814
- }
3815
- }
3816
- // If still over, hard truncate
3817
- if (formatted.length > MAX_CHARS) {
3818
- formatted = formatted.slice(0, MAX_CHARS);
3819
- }
3820
- }
3821
-
3822
- log.info(
3823
- `gatherTodayFacts: collected ${facts.length} facts, ${hourlySummaries.length} hourly summaries for ${targetLocalDate} (${timeZone}, ${formatted.length} chars)`,
2274
+ return this.workspaceOpsCoordinator.gatherTodayFacts(
2275
+ namespace,
2276
+ options,
3824
2277
  );
3825
-
3826
- return formatted;
3827
2278
  }
3828
2279
 
3829
2280
  previewMemoryActionEvent(
3830
2281
  event: Omit<MemoryActionEvent, "timestamp"> & { timestamp?: string },
3831
2282
  ): MemoryActionEvent {
3832
- const namespace =
3833
- typeof event.namespace === "string" && event.namespace.length > 0
3834
- ? event.namespace
3835
- : this.config.defaultNamespace;
3836
- const eligibility = parseMemoryActionEligibilityContext(
3837
- event.policyEligibility,
2283
+ return this.workspaceOpsCoordinator.previewMemoryActionEvent(
2284
+ event,
3838
2285
  );
3839
- const policy = evaluateMemoryActionPolicy({
3840
- action: event.action,
3841
- eligibility,
3842
- options: {
3843
- actionsEnabled: resolveCompressionCapabilities(this.config).contextCompressionActions,
3844
- maxCompressionTokensPerHour: this.config.maxCompressionTokensPerHour,
3845
- },
3846
- });
3847
- const dryRun = event.dryRun === true;
3848
-
3849
- const normalizedOutcome = dryRun
3850
- ? event.outcome === "failed"
3851
- ? "failed"
3852
- : "skipped"
3853
- : policy.decision === "allow"
3854
- ? event.outcome
3855
- : event.outcome === "failed"
3856
- ? "failed"
3857
- : "skipped";
3858
- const sourceSessionKey =
3859
- typeof event.sourceSessionKey === "string" &&
3860
- event.sourceSessionKey.length > 0
3861
- ? event.sourceSessionKey
3862
- : typeof event.sessionKey === "string" && event.sessionKey.length > 0
3863
- ? event.sessionKey
3864
- : undefined;
3865
- const outputMemoryIds = Array.isArray(event.outputMemoryIds)
3866
- ? Array.from(
3867
- new Set(
3868
- event.outputMemoryIds.filter(
3869
- (value): value is string =>
3870
- typeof value === "string" && value.length > 0,
3871
- ),
3872
- ),
3873
- )
3874
- : [];
3875
-
3876
- const reasonParts = [
3877
- event.reason,
3878
- `policy:${policy.decision}`,
3879
- policy.rationale,
3880
- ].filter(
3881
- (part): part is string => typeof part === "string" && part.length > 0,
3882
- );
3883
-
3884
- return {
3885
- ...event,
3886
- schemaVersion: event.schemaVersion ?? 1,
3887
- actionId:
3888
- typeof event.actionId === "string" && event.actionId.length > 0
3889
- ? event.actionId
3890
- : `memact-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
3891
- outcome: normalizedOutcome,
3892
- status:
3893
- event.status ??
3894
- (dryRun && policy.decision === "allow" && event.outcome !== "failed"
3895
- ? "validated"
3896
- : normalizedOutcome === "applied"
3897
- ? "applied"
3898
- : "rejected"),
3899
- actor:
3900
- typeof event.actor === "string" && event.actor.length > 0
3901
- ? event.actor
3902
- : "engram",
3903
- subsystem:
3904
- typeof event.subsystem === "string" && event.subsystem.length > 0
3905
- ? event.subsystem
3906
- : "memory_action",
3907
- reason: reasonParts.join(" | "),
3908
- namespace,
3909
- sessionKey: sourceSessionKey ?? event.sessionKey,
3910
- sourceSessionKey,
3911
- inputSummary:
3912
- typeof event.inputSummary === "string" && event.inputSummary.length > 0
3913
- ? event.inputSummary
3914
- : undefined,
3915
- outputMemoryIds,
3916
- dryRun,
3917
- policyVersion:
3918
- typeof event.policyVersion === "string" &&
3919
- event.policyVersion.length > 0
3920
- ? event.policyVersion
3921
- : "memory-action-policy.v1",
3922
- timestamp:
3923
- typeof event.timestamp === "string" && event.timestamp.length > 0
3924
- ? event.timestamp
3925
- : new Date().toISOString(),
3926
- policyDecision: policy.decision,
3927
- policyRationale: policy.rationale,
3928
- policyEligibility: eligibility,
3929
- };
3930
2286
  }
3931
2287
 
3932
2288
  async appendMemoryActionEvent(
@@ -4087,63 +2443,9 @@ export class Orchestrator {
4087
2443
  return this.conversationIndexCoordinator.rebuild(sessionKey, hours, opts);
4088
2444
  }
4089
2445
 
4090
- /**
4091
- * Validate local LLM model availability and context window compatibility.
4092
- * Warns the user if there's a mismatch.
4093
- */
4094
2446
  private async validateLocalLlmModel(): Promise<void> {
4095
- log.debug("Local LLM: validating model configuration");
4096
- try {
4097
- const modelInfo = await this.localLlm.getLoadedModelInfo();
4098
- if (!modelInfo) {
4099
- log.warn(
4100
- "Local LLM validation: Could not query model info from server",
4101
- );
4102
- log.warn(
4103
- "Local LLM validation: Could not query model info. " +
4104
- "Ensure LM Studio/Ollama is running with the model loaded.",
4105
- );
4106
- return;
4107
- }
4108
-
4109
- // Check for context window mismatch
4110
- const configuredMaxContext = this.config.localLlmMaxContext;
4111
-
4112
- if (modelInfo.contextWindow) {
4113
- log.debug(
4114
- `Local LLM: ${modelInfo.id} loaded with ${modelInfo.contextWindow.toLocaleString()} token context window`,
4115
- );
4116
-
4117
- if (
4118
- configuredMaxContext &&
4119
- configuredMaxContext > modelInfo.contextWindow
4120
- ) {
4121
- log.warn(
4122
- `Local LLM context mismatch: engram configured for ${configuredMaxContext.toLocaleString()} tokens, ` +
4123
- `but ${modelInfo.id} only supports ${modelInfo.contextWindow.toLocaleString()}. ` +
4124
- `Reducing to ${modelInfo.contextWindow.toLocaleString()} to avoid errors.`,
4125
- );
4126
- // Update the config in-memory to match actual capability
4127
- // (This is a temporary fix - user should update their config)
4128
- (this.config as { localLlmMaxContext?: number }).localLlmMaxContext =
4129
- modelInfo.contextWindow;
4130
- }
4131
- } else {
4132
- log.debug(
4133
- `Local LLM: ${modelInfo.id} loaded (context window not reported by server)`,
4134
- );
4135
-
4136
- if (!configuredMaxContext) {
4137
- log.warn(
4138
- "Local LLM: Server did not report context window. " +
4139
- "If you get 'context length exceeded' errors, set localLlmMaxContext in your config. " +
4140
- "Common defaults: LM Studio (32K), Ollama (2K-128K depending on model).",
4141
- );
4142
- }
4143
- }
4144
- } catch (err) {
4145
- log.warn(`Local LLM validation failed: ${err}`);
4146
- }
2447
+ return this.workspaceOpsCoordinator.validateLocalLlmModel(
2448
+ );
4147
2449
  }
4148
2450
 
4149
2451
  async recall(
@@ -4410,15 +2712,11 @@ export class Orchestrator {
4410
2712
  recallNamespaces: string[],
4411
2713
  targetCount: number,
4412
2714
  ): Promise<MemoryFile[]> {
4413
- if (targetCount <= 0) return [];
4414
- const namespaces = Array.from(new Set(recallNamespaces));
4415
- const filteredByNamespace = await Promise.all(
4416
- namespaces.map((namespace) =>
4417
- this.fetchActiveArtifactsForNamespace(namespace, prompt, targetCount),
4418
- ),
2715
+ return this.namespaceReadFanoutCoordinator.recallArtifactsAcrossNamespaces(
2716
+ prompt,
2717
+ recallNamespaces,
2718
+ targetCount,
4419
2719
  );
4420
-
4421
- return mergeArtifactRecallCandidates(filteredByNamespace, targetCount);
4422
2720
  }
4423
2721
 
4424
2722
  private scopeQueryAwarePaths(
@@ -4458,51 +2756,12 @@ export class Orchestrator {
4458
2756
  allowArchived?: boolean;
4459
2757
  },
4460
2758
  ): Promise<QmdSearchResult[]> {
4461
- const cappedLimit = Math.max(0, limit);
4462
- if (cappedLimit === 0 || candidatePaths.size === 0) return [];
4463
-
4464
- const tokens = Array.from(new Set(tokenizeRecallQuery(query)));
4465
- const memories = (
4466
- await Promise.all(
4467
- Array.from(candidatePaths).map(async (memoryPath) => {
4468
- const namespace = resolveNamespaceCapabilities(this.config).namespaces
4469
- ? this.namespaceFromPath(memoryPath)
4470
- : this.config.defaultNamespace;
4471
- const storage = await this.storageRouter.storageFor(namespace);
4472
- return await storage.readMemoryByPath(memoryPath);
4473
- }),
4474
- )
4475
- ).filter((memory): memory is MemoryFile => memory !== null);
4476
-
4477
- const results: QmdSearchResult[] = [];
4478
- for (const memory of memories) {
4479
- const status = memory.frontmatter.status ?? "active";
4480
- if (!options?.allowArchived && status !== "active") continue;
4481
-
4482
- const haystack = [
4483
- memory.content,
4484
- memory.frontmatter.category,
4485
- ...(memory.frontmatter.tags ?? []),
4486
- ]
4487
- .join(" ")
4488
- .toLowerCase();
4489
- let hits = 0;
4490
- for (const token of tokens) {
4491
- if (haystack.includes(token)) hits += 1;
4492
- }
4493
- const score = tokens.length > 0 ? hits / tokens.length : 0.01;
4494
- if (tokens.length > 0 && hits === 0) continue;
4495
-
4496
- results.push({
4497
- docid: memory.frontmatter.id,
4498
- path: memory.path,
4499
- score,
4500
- snippet: memory.content.slice(0, 400).replace(/\n/g, " "),
4501
- transport: "scoped_prefilter",
4502
- });
4503
- }
4504
-
4505
- return results.sort((a, b) => b.score - a.score).slice(0, cappedLimit);
2759
+ return this.namespaceReadFanoutCoordinator.searchScopedMemoryCandidates(
2760
+ candidatePaths,
2761
+ query,
2762
+ limit,
2763
+ options,
2764
+ );
4506
2765
  }
4507
2766
 
4508
2767
  private async fetchQmdMemoryResultsWithArtifactTopUp(
@@ -4596,26 +2855,9 @@ export class Orchestrator {
4596
2855
  private async resolveStateDirForNamespace(
4597
2856
  namespace: string,
4598
2857
  ): Promise<string> {
4599
- if (!resolveNamespaceCapabilities(this.config).namespaces) {
4600
- return path.join(this.config.memoryDir, "state");
4601
- }
4602
- if (namespace !== this.config.defaultNamespace) {
4603
- return path.join(this.config.memoryDir, "namespaces", namespace, "state");
4604
- }
4605
- const candidate = path.join(
4606
- this.config.memoryDir,
4607
- "namespaces",
4608
- this.config.defaultNamespace,
2858
+ return this.namespaceReadFanoutCoordinator.resolveStateDirForNamespace(
2859
+ namespace,
4609
2860
  );
4610
- try {
4611
- const candidateStat = await stat(candidate);
4612
- if (candidateStat.isDirectory()) {
4613
- return path.join(candidate, "state");
4614
- }
4615
- } catch {
4616
- // Fall back to the legacy root when the migrated default namespace directory is absent.
4617
- }
4618
- return path.join(this.config.memoryDir, "state");
4619
2861
  }
4620
2862
 
4621
2863
  private buildGraphRecallRankedResults(
@@ -4716,7 +2958,6 @@ export class Orchestrator {
4716
2958
  }
4717
2959
 
4718
2960
 
4719
-
4720
2961
  /**
4721
2962
  * Clock source for the shared post-retrieval assembly/enrichment budget. The
4722
2963
  * deadline is set from this value and every expiry check reads it back, so a
@@ -4739,84 +2980,9 @@ export class Orchestrator {
4739
2980
 
4740
2981
  private get recallInternalCoordinator(): RecallInternalCoordinator {
4741
2982
  if (!this._recallInternalCoordinator) {
4742
- // eslint-disable-next-line @typescript-eslint/no-this-alias
4743
- const self = this;
4744
- this._recallInternalCoordinator = new RecallInternalCoordinator({
4745
- get _recallWorkspaceOverrides() { return self._recallWorkspaceOverrides; },
4746
- appendRecallSection: (sectionBuckets, sectionId, content) => self.appendRecallSection(sectionBuckets, sectionId, content),
4747
- applyColdFallbackPipeline: (options) => self.applyColdFallbackPipeline(options),
4748
- applyTrustScoreToBranch: (results, namespaces, caps, label) => self.applyTrustScoreToBranch(results, namespaces, caps, label),
4749
- assembleRecallSections: (sectionBuckets, budgetOverride) => self.assembleRecallSections(sectionBuckets, budgetOverride),
4750
- boostSearchResults: (results, _recallNamespaces, prompt, preloadedMemoryMap, options) => self.boostSearchResults(results, _recallNamespaces, prompt, preloadedMemoryMap, options),
4751
- boxBuilderFor: (storage) => self.boxBuilderFor(storage),
4752
- buildCompressionGuidelineRecallSection: () => self.buildCompressionGuidelineRecallSection(),
4753
- buildConfiguredQmdSearchOptions: (queryText) => self.buildConfiguredQmdSearchOptions(queryText),
4754
- buildGraphRecallRankedResults: (results, sourceLabelResolver, limit) => self.buildGraphRecallRankedResults(results, sourceLabelResolver, limit),
4755
- buildIdentityContinuitySection: (options) => self.buildIdentityContinuitySection(options),
4756
- buildLastRecallBudgetSummary: (options) => self.buildLastRecallBudgetSummary(options),
4757
- buildQueryAwarePrefilter: (prompt, recallNamespaces) => self.buildQueryAwarePrefilter(prompt, recallNamespaces),
4758
- collectLastRecallSources: (sectionBuckets, recallSource) => self.collectLastRecallSources(sectionBuckets, recallSource),
4759
- get compounding() { return self.compounding; },
4760
- get config() { return self.config; },
4761
- currentPolicyVersion: () => self.currentPolicyVersion(),
4762
- diversifyAndLimitRecallResults: (sectionId, results, limit, retrievalQuery, caps) => self.diversifyAndLimitRecallResults(sectionId, results, limit, retrievalQuery, caps),
4763
- effectiveCronRecallInstructionHeavyTokenCap: () => self.effectiveCronRecallInstructionHeavyTokenCap(),
4764
- emitTrace: (event) => self.emitTrace(event),
4765
- expandResultsViaGraph: (options) => self.expandResultsViaGraph(options),
4766
- extractMemoryIdsFromResults: (results) => self.extractMemoryIdsFromResults(results),
4767
- get fastLlmForRerank() { return self.fastLlmForRerank; },
4768
- fetchQmdMemoryResultsWithArtifactTopUp: (prompt, qmdFetchLimit, qmdHybridFetchLimit, options) => self.fetchQmdMemoryResultsWithArtifactTopUp(prompt, qmdFetchLimit, qmdHybridFetchLimit, options),
4769
- filterSearchResultsForRecall: (results, preloadedMemoryMap, options) => self.filterSearchResultsForRecall(results, preloadedMemoryMap, options),
4770
- formatCausalTrajectoryResults: (results) => self.formatCausalTrajectoryResults(results),
4771
- formatConversationRecallSection: (results, maxChars) => self.formatConversationRecallSection(results, maxChars),
4772
- formatHarmonicRetrievalResults: (results) => self.formatHarmonicRetrievalResults(results),
4773
- formatObjectiveStateResults: (results) => self.formatObjectiveStateResults(results),
4774
- formatQmdResults: (title, results, sessionKey, trustByPath) => self.formatQmdResults(title, results, sessionKey, trustByPath),
4775
- formatTrustZoneResults: (results) => self.formatTrustZoneResults(results),
4776
- formatVerifiedEpisodeResults: (results) => self.formatVerifiedEpisodeResults(results),
4777
- formatVerifiedSemanticRuleResults: (results) => self.formatVerifiedSemanticRuleResults(results),
4778
- formatWorkProductResults: (results) => self.formatWorkProductResults(results),
4779
- getCodingContextForSession: (sessionKey) => self.getCodingContextForSession(sessionKey),
4780
- getPeerIdForSession: (sessionKey) => self.getPeerIdForSession(sessionKey),
4781
- getRecallBudgetChars: (override) => self.getRecallBudgetChars(override),
4782
- getRecallSectionEntry: (sectionId) => self.getRecallSectionEntry(sectionId),
4783
- getRecallSectionMaxChars: (sectionId) => self.getRecallSectionMaxChars(sectionId),
4784
- getRecallSectionNumber: (sectionId, key) => self.getRecallSectionNumber(sectionId, key),
4785
- getStorage: (namespace) => self.getStorage(namespace),
4786
- get handleHistory() { return self.handleHistory; },
4787
- isRecallSectionEnabled: (sectionId, defaultEnabled) => self.isRecallSectionEnabled(sectionId, defaultEnabled),
4788
- isSpecializedRecallSectionEnabled: (sectionId, topLevelEnabled) => self.isSpecializedRecallSectionEnabled(sectionId, topLevelEnabled),
4789
- get lastQmdReprobeAtMs() { return self.lastQmdReprobeAtMs; },
4790
- set lastQmdReprobeAtMs(value) { self.lastQmdReprobeAtMs = value; },
4791
- get lastRecall() { return self.lastRecall; },
4792
- get lastXraySnapshot() { return self.lastXraySnapshot; },
4793
- set lastXraySnapshot(value) { self.lastXraySnapshot = value; },
4794
- get lcmEngine() { return self.lcmEngine; },
4795
- get namespaceCatalog() { return self.namespaceCatalog; },
4796
- namespaceFromPath: (p) => self.namespaceFromPath(p),
4797
- get profiler() { return self.profiler; },
4798
- publishRecallResults: (options) => self.publishRecallResults(options),
4799
- get qmd() { return self.qmd; },
4800
- queueEvalShadowRecall: (record) => self.queueEvalShadowRecall(record),
4801
- readAllMemoriesForNamespaces: (namespaces) => self.readAllMemoriesForNamespaces(namespaces),
4802
- recallArtifactsAcrossNamespaces: (prompt, recallNamespaces, targetCount) => self.recallArtifactsAcrossNamespaces(prompt, recallNamespaces, targetCount),
4803
- recallAssemblyClockMs: () => self.recallAssemblyClockMs(),
4804
- recordLastGraphRecallSnapshot: (options) => self.recordLastGraphRecallSnapshot(options),
4805
- recordLastIntentSnapshot: (options) => self.recordLastIntentSnapshot(options),
4806
- recordLastIntentSnapshotForNamespace: (options) => self.recordLastIntentSnapshotForNamespace(options),
4807
- recordLastQmdRecallSnapshot: (options) => self.recordLastQmdRecallSnapshot(options),
4808
- get rerankCache() { return self.rerankCache; },
4809
- searchConversationRecallResults: (retrievalQuery, topK) => self.searchConversationRecallResults(retrievalQuery, topK),
4810
- searchEmbeddingFallback: (query, limit) => self.searchEmbeddingFallback(query, limit),
4811
- get sharedContext() { return self.sharedContext; },
4812
- get storage() { return self.storage; },
4813
- get storageRouter() { return self.storageRouter; },
4814
- get summarizer() { return self.summarizer; },
4815
- get tmtBuilder() { return self.tmtBuilder; },
4816
- get transcript() { return self.transcript; },
4817
- truncateArtifactForRecall: (text, maxChars) => self.truncateArtifactForRecall(text, maxChars),
4818
- truncateRecallSectionToBudget: (content, maxChars) => self.truncateRecallSectionToBudget(content, maxChars),
4819
- });
2983
+ this._recallInternalCoordinator = new RecallInternalCoordinator(
2984
+ selfDeps<ConstructorParameters<typeof RecallInternalCoordinator>[0]>(this),
2985
+ );
4820
2986
  }
4821
2987
  return this._recallInternalCoordinator;
4822
2988
  }
@@ -4831,41 +2997,9 @@ export class Orchestrator {
4831
2997
 
4832
2998
  private get recallSearchPipelineCoordinator(): RecallSearchPipelineCoordinator {
4833
2999
  if (!this._recallSearchPipelineCoordinator) {
4834
- // eslint-disable-next-line @typescript-eslint/no-this-alias
4835
- const self = this;
4836
- this._recallSearchPipelineCoordinator = new RecallSearchPipelineCoordinator({
4837
- applyMemoryWorthRerank: (results, namespaces) => self.applyMemoryWorthRerank(results, namespaces),
4838
- applyTrustScoreRerank: (results, namespaces) => self.applyTrustScoreRerank(results, namespaces),
4839
- boostSearchResults: (results, _recallNamespaces, prompt, preloadedMemoryMap, options) => self.boostSearchResults(results, _recallNamespaces, prompt, preloadedMemoryMap, options),
4840
- buildConfiguredQmdSearchOptions: (queryText) => self.buildConfiguredQmdSearchOptions(queryText),
4841
- buildQueryAwarePrefilter: (prompt, recallNamespaces) => self.buildQueryAwarePrefilter(prompt, recallNamespaces),
4842
- get config() { return self.config; },
4843
- diversifyAndLimitRecallResults: (sectionId, results, limit, retrievalQuery, caps) => self.diversifyAndLimitRecallResults(sectionId, results, limit, retrievalQuery, caps),
4844
- effectiveRecencyWeight: () => self.effectiveRecencyWeight(),
4845
- get embeddingFallback() { return self.embeddingFallback; },
4846
- expandResultsViaGraph: (options) => self.expandResultsViaGraph(options),
4847
- get fastLlmForRerank() { return self.fastLlmForRerank; },
4848
- fetchQmdMemoryResultsWithArtifactTopUp: (prompt, qmdFetchLimit, qmdHybridFetchLimit, options) => self.fetchQmdMemoryResultsWithArtifactTopUp(prompt, qmdFetchLimit, qmdHybridFetchLimit, options),
4849
- filterSearchResultsByRecallSafety: (results, memoryByPath, options) => self.filterSearchResultsByRecallSafety(results, memoryByPath, options),
4850
- filterSearchResultsForRecall: (results, preloadedMemoryMap, options) => self.filterSearchResultsForRecall(results, preloadedMemoryMap, options),
4851
- loadSearchResultMemoryMap: (results, preloadedMemoryMap, options) => self.loadSearchResultMemoryMap(results, preloadedMemoryMap, options),
4852
- namespaceFromPath: (p) => self.namespaceFromPath(p),
4853
- get negatives() { return self.negatives; },
4854
- get qmd() { return self.qmd; },
4855
- readArchivedMemoriesForNamespaces: (namespaces) => self.readArchivedMemoriesForNamespaces(namespaces),
4856
- readQmdResultMemory: (resultPath, fallbackStorage, recallNamespaces) => self.readQmdResultMemory(resultPath, fallbackStorage, recallNamespaces),
4857
- get relevance() { return self.relevance; },
4858
- get rerankCache() { return self.rerankCache; },
4859
- resolveArtifactSourceStatuses: (storage, sourceIds) => self.resolveArtifactSourceStatuses(storage, sourceIds),
4860
- resolveColdQmdResultForRecall: (result, fallbackStorage, recallNamespaces) => self.resolveColdQmdResultForRecall(result, fallbackStorage, recallNamespaces),
4861
- scopeQueryAwarePaths: (paths, recallNamespaces) => self.scopeQueryAwarePaths(paths, recallNamespaces),
4862
- searchAcrossNamespaces: (options) => self.searchAcrossNamespaces(options),
4863
- searchLongTermArchiveFallback: (prompt, recallNamespaces, limit, queryAwarePrefilter, abortSignal) => self.searchLongTermArchiveFallback(prompt, recallNamespaces, limit, queryAwarePrefilter, abortSignal),
4864
- searchScopedMemoryCandidates: (candidatePaths, query, limit, options) => self.searchScopedMemoryCandidates(candidatePaths, query, limit, options),
4865
- get storage() { return self.storage; },
4866
- get storageRouter() { return self.storageRouter; },
4867
- get utilityRuntimeValues() { return self.utilityRuntimeValues; },
4868
- });
3000
+ this._recallSearchPipelineCoordinator = new RecallSearchPipelineCoordinator(
3001
+ selfDeps<ConstructorParameters<typeof RecallSearchPipelineCoordinator>[0]>(this),
3002
+ );
4869
3003
  }
4870
3004
  return this._recallSearchPipelineCoordinator;
4871
3005
  }
@@ -4879,26 +3013,9 @@ export class Orchestrator {
4879
3013
 
4880
3014
  private get turnIngestionCoordinator(): TurnIngestionCoordinator {
4881
3015
  if (!this._turnIngestionCoordinator) {
4882
- // eslint-disable-next-line @typescript-eslint/no-this-alias
4883
- const self = this;
4884
- this._turnIngestionCoordinator = new TurnIngestionCoordinator({
4885
- get buffer() { return self.buffer; },
4886
- bulkImportWriteNamespace: () => self.bulkImportWriteNamespace(),
4887
- get config() { return self.config; },
4888
- get extractionQueueCoordinator() { return self.extractionQueueCoordinator; },
4889
- getStorage: (namespace) => self.getStorage(namespace),
4890
- get heartbeatObserverChains() { return self.heartbeatObserverChains; },
4891
- get lcmEngine() { return self.lcmEngine; },
4892
- get passiveCorrectionDedup() { return self.passiveCorrectionDedup; },
4893
- passiveCorrectionService: () => self.passiveCorrectionService(),
4894
- get passiveCorrectionTelemetry() { return self.passiveCorrectionTelemetry; },
4895
- queueBufferedExtraction: (turnsToExtract, reason, options) => self.queueBufferedExtraction(turnsToExtract, reason, options),
4896
- resolveMemoryIdOrHandle: (ref, sessionKey) => self.resolveMemoryIdOrHandle(ref, sessionKey),
4897
- runExtraction: (...args) => self.runExtraction(...args),
4898
- get sessionObserver() { return self.sessionObserver; },
4899
- shouldQueueExtraction: (turns, options) => self.shouldQueueExtraction(turns, options),
4900
- get transcript() { return self.transcript; },
4901
- });
3016
+ this._turnIngestionCoordinator = new TurnIngestionCoordinator(
3017
+ selfDeps<ConstructorParameters<typeof TurnIngestionCoordinator>[0]>(this),
3018
+ );
4902
3019
  }
4903
3020
  return this._turnIngestionCoordinator;
4904
3021
  }
@@ -4912,25 +3029,9 @@ export class Orchestrator {
4912
3029
 
4913
3030
  private get recallIntrospectionCoordinator(): RecallIntrospectionCoordinator {
4914
3031
  if (!this._recallIntrospectionCoordinator) {
4915
- // eslint-disable-next-line @typescript-eslint/no-this-alias
4916
- const self = this;
4917
- this._recallIntrospectionCoordinator = new RecallIntrospectionCoordinator({
4918
- annotateDirectAnswerTier: (prompt, sessionKey, namespaces, expectedIdentity, caps, _parentAbortSignal) => self.annotateDirectAnswerTier(prompt, sessionKey, namespaces, expectedIdentity, caps, _parentAbortSignal),
4919
- get config() { return self.config; },
4920
- get directAnswerObservationChain() { return self.directAnswerObservationChain; },
4921
- set directAnswerObservationChain(value) { self.directAnswerObservationChain = value; },
4922
- effectiveCronRecallInstructionHeavyTokenCap: () => self.effectiveCronRecallInstructionHeavyTokenCap(),
4923
- get faithfulnessCounters() { return self.faithfulnessCounters; },
4924
- getCodingContextForSession: (sessionKey) => self.getCodingContextForSession(sessionKey),
4925
- getLastGraphRecallSnapshot: (namespace) => self.getLastGraphRecallSnapshot(namespace),
4926
- getLastIntentSnapshot: (namespace) => self.getLastIntentSnapshot(namespace),
4927
- getLastQmdRecallSnapshot: (namespace) => self.getLastQmdRecallSnapshot(namespace),
4928
- getStorage: (namespace) => self.getStorage(namespace),
4929
- get graphRecallCoordinator() { return self.graphRecallCoordinator; },
4930
- get lastRecall() { return self.lastRecall; },
4931
- resolveStateDirForNamespace: (namespace) => self.resolveStateDirForNamespace(namespace),
4932
- get storageRouter() { return self.storageRouter; },
4933
- });
3032
+ this._recallIntrospectionCoordinator = new RecallIntrospectionCoordinator(
3033
+ selfDeps<ConstructorParameters<typeof RecallIntrospectionCoordinator>[0]>(this),
3034
+ );
4934
3035
  }
4935
3036
  return this._recallIntrospectionCoordinator;
4936
3037
  }
@@ -4945,57 +3046,9 @@ export class Orchestrator {
4945
3046
 
4946
3047
  private get orchestratorInitCoordinator(): OrchestratorInitCoordinator {
4947
3048
  if (!this._orchestratorInitCoordinator) {
4948
- // eslint-disable-next-line @typescript-eslint/no-this-alias
4949
- const self = this;
4950
- this._orchestratorInitCoordinator = new OrchestratorInitCoordinator({
4951
- get buffer() { return self.buffer; },
4952
- get compounding() { return self.compounding; },
4953
- get config() { return self.config; },
4954
- configuredNamespaceList: () => self.configuredNamespaceList(),
4955
- get contentHashIndex() { return self.contentHashIndex; },
4956
- set contentHashIndex(value) { self.contentHashIndex = value; },
4957
- get conversationIndexBackend() { return self.conversationIndexBackend; },
4958
- get deferredInitAbort() { return self.deferredInitAbort; },
4959
- set deferredInitAbort(value) { self.deferredInitAbort = value; },
4960
- deferredInitialize: (signal) => self.deferredInitialize(signal),
4961
- get deferredReady() { return self.deferredReady; },
4962
- set deferredReady(value) { self.deferredReady = value; },
4963
- get deferredSyncSucceeded() { return self.deferredSyncSucceeded; },
4964
- set deferredSyncSucceeded(value) { self.deferredSyncSucceeded = value; },
4965
- disposeSearchBackendIfNeeded: () => self.disposeSearchBackendIfNeeded(),
4966
- get embeddingFallback() { return self.embeddingFallback; },
4967
- getWearablesService: () => self.getWearablesService(),
4968
- get handleHistory() { return self.handleHistory; },
4969
- get lastRecall() { return self.lastRecall; },
4970
- maintenanceNamespaces: (jobName, budgetMode) => self.maintenanceNamespaces(jobName, budgetMode),
4971
- get maintenanceScheduler() { return self.maintenanceScheduler; },
4972
- get namespaceCatalog() { return self.namespaceCatalog; },
4973
- get namespaceSearchRouter() { return self.namespaceSearchRouter; },
4974
- get negatives() { return self.negatives; },
4975
- passiveCorrectionService: () => self.passiveCorrectionService(),
4976
- get policyRuntime() { return self.policyRuntime; },
4977
- get qmd() { return self.qmd; },
4978
- set qmd(value) { self.qmd = value; },
4979
- get relevance() { return self.relevance; },
4980
- get resolveDeferredReady() { return self.resolveDeferredReady; },
4981
- set resolveDeferredReady(value) { self.resolveDeferredReady = value; },
4982
- get resolveInit() { return self.resolveInit; },
4983
- set resolveInit(value) { self.resolveInit = value; },
4984
- get runtimePolicyValues() { return self.runtimePolicyValues; },
4985
- set runtimePolicyValues(value) { self.runtimePolicyValues = value; },
4986
- get sessionObserver() { return self.sessionObserver; },
4987
- get sharedContext() { return self.sharedContext; },
4988
- get storage() { return self.storage; },
4989
- get storageRouter() { return self.storageRouter; },
4990
- get summarizer() { return self.summarizer; },
4991
- get tierMigrationStatus() { return self.tierMigrationStatus; },
4992
- get transcript() { return self.transcript; },
4993
- get utilityRuntimeValues() { return self.utilityRuntimeValues; },
4994
- set utilityRuntimeValues(value) { self.utilityRuntimeValues = value; },
4995
- validateLocalLlmModel: () => self.validateLocalLlmModel(),
4996
- get wearablesAutoSyncHandle() { return self.wearablesAutoSyncHandle; },
4997
- set wearablesAutoSyncHandle(value) { self.wearablesAutoSyncHandle = value; },
4998
- });
3049
+ this._orchestratorInitCoordinator = new OrchestratorInitCoordinator(
3050
+ selfDeps<ConstructorParameters<typeof OrchestratorInitCoordinator>[0]>(this),
3051
+ );
4999
3052
  }
5000
3053
  return this._orchestratorInitCoordinator;
5001
3054
  }
@@ -5009,22 +3062,45 @@ export class Orchestrator {
5009
3062
 
5010
3063
  private get persistenceIndexCoordinator(): PersistenceIndexCoordinator {
5011
3064
  if (!this._persistenceIndexCoordinator) {
5012
- // eslint-disable-next-line @typescript-eslint/no-this-alias
5013
- const self = this;
5014
- this._persistenceIndexCoordinator = new PersistenceIndexCoordinator({
5015
- get config() { return self.config; },
5016
- get contentHashIndex() { return self.contentHashIndex; },
5017
- contentHashIndexForStorage: (targetStorage) => self.contentHashIndexForStorage(targetStorage),
5018
- get contentHashIndexesByStorageDir() { return self.contentHashIndexesByStorageDir; },
5019
- get embeddingFallback() { return self.embeddingFallback; },
5020
- graphIndexFor: (storage) => self.graphIndexFor(storage),
5021
- readAllMemoriesForNamespaces: (namespaces) => self.readAllMemoriesForNamespaces(namespaces),
5022
- semanticDedupScopeFor: (targetStorage) => self.semanticDedupScopeFor(targetStorage),
5023
- });
3065
+ this._persistenceIndexCoordinator = new PersistenceIndexCoordinator(
3066
+ selfDeps<ConstructorParameters<typeof PersistenceIndexCoordinator>[0]>(this),
3067
+ );
5024
3068
  }
5025
3069
  return this._persistenceIndexCoordinator;
5026
3070
  }
5027
3071
 
3072
+ /**
3073
+ * Workspace-ops coordinator (issue #1526 seam 24). Owns periodic
3074
+ * workspace/operations surfaces. Lazy + accessor-wired (late-binding
3075
+ * rule, seams 18–23).
3076
+ */
3077
+ private _workspaceOpsCoordinator: WorkspaceOpsCoordinator | undefined;
3078
+
3079
+ private get workspaceOpsCoordinator(): WorkspaceOpsCoordinator {
3080
+ if (!this._workspaceOpsCoordinator) {
3081
+ this._workspaceOpsCoordinator = new WorkspaceOpsCoordinator(
3082
+ selfDeps<ConstructorParameters<typeof WorkspaceOpsCoordinator>[0]>(this),
3083
+ );
3084
+ }
3085
+ return this._workspaceOpsCoordinator;
3086
+ }
3087
+
3088
+ /**
3089
+ * Namespace read-fanout coordinator (issue #1526 seam 26). Owns
3090
+ * namespace-scoped read fanout (hints, cross-namespace search/reads,
3091
+ * artifact statuses). Lazy + accessor-wired (late-binding rule).
3092
+ */
3093
+ private _namespaceReadFanoutCoordinator: NamespaceReadFanoutCoordinator | undefined;
3094
+
3095
+ private get namespaceReadFanoutCoordinator(): NamespaceReadFanoutCoordinator {
3096
+ if (!this._namespaceReadFanoutCoordinator) {
3097
+ this._namespaceReadFanoutCoordinator = new NamespaceReadFanoutCoordinator(
3098
+ selfDeps<ConstructorParameters<typeof NamespaceReadFanoutCoordinator>[0]>(this),
3099
+ );
3100
+ }
3101
+ return this._namespaceReadFanoutCoordinator;
3102
+ }
3103
+
5028
3104
  private async recallInternal(
5029
3105
  prompt: string,
5030
3106
  sessionKey?: string,
@@ -5159,60 +3235,9 @@ export class Orchestrator {
5159
3235
  return this.config.defaultNamespace;
5160
3236
  }
5161
3237
 
5162
- /**
5163
- * Lazily-constructed wearables service (Limitless / Bee / Omi
5164
- * transcript ingestion). All wearables surfaces — CLI, MCP tools,
5165
- * HTTP routes — share this one instance so sync state, search, and
5166
- * memory writes stay consistent. Writes are pinned to the same
5167
- * deterministic namespace bulk-import uses.
5168
- */
5169
3238
  getWearablesService(): WearablesService {
5170
- if (!this.wearablesServiceInstance) {
5171
- this.wearablesServiceInstance = new WearablesService({
5172
- config: this.config.wearables,
5173
- getStorage: async () =>
5174
- await this.getStorageForNamespace(this.bulkImportWriteNamespace()),
5175
- extract: (turns) => this.extraction.extract(turns),
5176
- // Smart memoryMode runs candidates through the SAME extraction
5177
- // judge (cache + defer counters included) the live extraction
5178
- // pipeline uses, so wearable facts get identical LLM-as-judge
5179
- // durability gating.
5180
- judgeFacts: (candidates) =>
5181
- judgeFactDurability(
5182
- candidates,
5183
- this.config,
5184
- this.localLlm,
5185
- new FallbackLlmClient(
5186
- this.config.gatewayConfig,
5187
- fallbackLlmRuntimeContextFromConfig(this.config),
5188
- ),
5189
- this.judgeVerdictCache,
5190
- this.judgeDeferCounts,
5191
- ),
5192
- searchBackend: {
5193
- search: async (query, maxResults) => {
5194
- if (!this.qmd.isAvailable()) return null;
5195
- try {
5196
- const results = await this.qmd.search(query, undefined, maxResults);
5197
- return results.map((result) => ({
5198
- path: result.path,
5199
- score: result.score,
5200
- preview: result.snippet,
5201
- }));
5202
- } catch {
5203
- // Backend hiccup → tell the service "unavailable" so it
5204
- // runs its bounded scan fallback instead of returning a
5205
- // silent empty result (CLAUDE.md rule 34).
5206
- return null;
5207
- }
5208
- },
5209
- },
5210
- reindexSearch: async () => {
5211
- await this.qmd.update();
5212
- },
5213
- });
5214
- }
5215
- return this.wearablesServiceInstance;
3239
+ return this.workspaceOpsCoordinator.getWearablesService(
3240
+ );
5216
3241
  }
5217
3242
 
5218
3243
  async ingestBulkImportBatch(
@@ -5571,93 +3596,16 @@ export class Orchestrator {
5571
3596
  ): Promise<number> {
5572
3597
  return this.lifecyclePolicyCoordinator.runLifecyclePolicyPass(allMemories, storage);
5573
3598
  }
5574
- /** Threshold (bytes) at which IDENTITY.md reflections get auto-consolidated */
5575
- private static readonly IDENTITY_CONSOLIDATE_THRESHOLD = 8_000;
3599
+ /**
3600
+ * Threshold (bytes) at which IDENTITY.md reflections get auto-consolidated.
3601
+ * Read by WorkspaceOpsCoordinator (seam 24) via `Orchestrator.…`, hence
3602
+ * not `private`.
3603
+ */
3604
+ static readonly IDENTITY_CONSOLIDATE_THRESHOLD = 8_000;
5576
3605
 
5577
3606
  private async autoConsolidateIdentity(): Promise<void> {
5578
- // Fan out over the catalog-union namespace set (issue #1499 sweep): a dynamic
5579
- // namespace that accumulated IDENTITY.md reflections must also be eligible for
5580
- // auto-consolidation, otherwise its identity file grows unbounded and is never
5581
- // consolidated. Falls back to the configured set on any catalog read failure.
5582
- const namespaces = resolveNamespaceCapabilities(this.config).namespaces
5583
- ? await this.maintenanceNamespaces()
5584
- : [this.config.defaultNamespace];
5585
-
5586
- for (const namespace of namespaces) {
5587
- const storage = await this.storageRouter.storageFor(namespace);
5588
- const identityNamespace =
5589
- resolveNamespaceCapabilities(this.config).namespaces &&
5590
- namespace !== this.config.defaultNamespace
5591
- ? namespace
5592
- : undefined;
5593
- const reflectionsContent =
5594
- (await storage.readIdentityReflections()) ?? "";
5595
-
5596
- const existingIdentity = await storage.readIdentity(
5597
- this.config.workspaceDir,
5598
- identityNamespace,
5599
- );
5600
- const headerEnd =
5601
- existingIdentity.indexOf("## Learned Patterns") !== -1
5602
- ? existingIdentity.indexOf("## Learned Patterns")
5603
- : existingIdentity.indexOf("## Reflection");
5604
- const staticHeader =
5605
- (headerEnd !== -1
5606
- ? existingIdentity.slice(0, headerEnd)
5607
- : existingIdentity
5608
- ).trimEnd() || "# IDENTITY";
5609
- const identityContent = `${staticHeader}\n\n${reflectionsContent.trim()}\n`;
5610
- if (identityContent.length < Orchestrator.IDENTITY_CONSOLIDATE_THRESHOLD)
5611
- continue;
5612
-
5613
- log.info(
5614
- `IDENTITY(${namespace}) is ${identityContent.length} chars — auto-consolidating reflections`,
5615
- );
5616
- const result = await this.extraction.consolidateIdentity(
5617
- identityContent,
5618
- "## Reflection",
5619
- );
5620
-
5621
- if (!result || result.learnedPatterns.length === 0) {
5622
- log.warn(
5623
- `identity consolidation produced no patterns for namespace=${namespace}`,
5624
- );
5625
- continue;
5626
- }
5627
-
5628
- const patternsSection = [
5629
- "## Learned Patterns (consolidated from reflections, " +
5630
- new Date().toISOString().slice(0, 10) +
5631
- ")",
5632
- "",
5633
- ...result.learnedPatterns.map((p) => `- ${p}`),
5634
- "",
5635
- ].join("\n");
5636
-
5637
- const newContent = staticHeader + "\n\n" + patternsSection + "\n";
5638
-
5639
- await storage.writeIdentity(
5640
- this.config.workspaceDir,
5641
- newContent,
5642
- identityNamespace,
5643
- );
5644
- await storage.writeIdentityReflections("");
5645
- // NRcCL (codex P2): record a per-namespace catalog write for THIS namespace
5646
- // after the identity files are updated. This fan-out can mutate a dynamic
5647
- // namespace via `writeIdentity`/`writeIdentityReflections`, but the
5648
- // consolidation pass's only consolidated touch covers `this.storage` (the
5649
- // default) and only fires when `memoryItemMutated` was set by OTHER work — so
5650
- // a namespace whose sole mutation in the pass is identity consolidation would
5651
- // otherwise keep a stale `lastWriteAt`, making `listNamespaces({ writtenSince })`
5652
- // and catalog-recency consumers miss the write. Best-effort and
5653
- // failure-tolerant (the storage chokepoint (#1522) swallows errors, never crashing the
5654
- // consolidation; gotcha #13, rule #40). No double-count with the consolidated
5655
- // touch above: that one is gated on `memoryItemMutated` (which identity
5656
- // consolidation does not set), and `markWrite` is idempotent regardless.
5657
- log.info(
5658
- `IDENTITY(${namespace}) consolidated: ${identityContent.length} → ${newContent.length} chars, ${result.learnedPatterns.length} patterns`,
5659
- );
5660
- }
3607
+ return this.workspaceOpsCoordinator.autoConsolidateIdentity(
3608
+ );
5661
3609
  }
5662
3610
 
5663
3611
  // Issue #1526: recall result formatting moved to RecallResultFormatter. Thin
@@ -6108,52 +4056,9 @@ export class Orchestrator {
6108
4056
  }
6109
4057
  }
6110
4058
 
6111
- /**
6112
- * Flush access tracking buffer to disk.
6113
- * Called during consolidation or when buffer is full.
6114
- */
6115
4059
  async flushAccessTracking(): Promise<void> {
6116
- if (this.accessTrackingBuffer.size === 0) return;
6117
-
6118
- // Build entries from buffer, merging with existing counts
6119
- const entries: AccessTrackingEntry[] = [];
6120
- const namespaces = resolveNamespaceCapabilities(this.config).namespaces
6121
- ? Array.from(
6122
- new Set<string>([
6123
- this.config.defaultNamespace,
6124
- this.config.sharedNamespace,
6125
- ...this.config.namespacePolicies.map((p) => p.name),
6126
- ]),
6127
- )
6128
- : [this.config.defaultNamespace];
6129
- const memories = await this.readAllMemoriesForNamespaces(namespaces);
6130
- const memoryMap = new Map(memories.map((m) => [m.frontmatter.id, m]));
6131
-
6132
- for (const [memoryId, update] of this.accessTrackingBuffer) {
6133
- const memory = memoryMap.get(memoryId);
6134
- const existingCount = memory?.frontmatter.accessCount ?? 0;
6135
- entries.push({
6136
- memoryId,
6137
- newCount: existingCount + update.count,
6138
- lastAccessed: update.lastAccessed,
6139
- });
6140
- }
6141
-
6142
- const byNamespace = new Map<string, AccessTrackingEntry[]>();
6143
- for (const e of entries) {
6144
- const m = memoryMap.get(e.memoryId);
6145
- if (!m) continue;
6146
- const ns = this.namespaceFromPath(m.path);
6147
- const list = byNamespace.get(ns) ?? [];
6148
- list.push(e);
6149
- byNamespace.set(ns, list);
6150
- }
6151
- for (const [ns, list] of byNamespace) {
6152
- const sm = await this.storageRouter.storageFor(ns);
6153
- await sm.flushAccessTracking(list);
6154
- }
6155
- this.accessTrackingBuffer.clear();
6156
- log.debug(`flushed ${entries.length} access tracking entries`);
4060
+ return this.workspaceOpsCoordinator.flushAccessTracking(
4061
+ );
6157
4062
  }
6158
4063
 
6159
4064
  private async loadSearchResultMemoryMap(
@@ -6402,29 +4307,15 @@ export class Orchestrator {
6402
4307
  }
6403
4308
 
6404
4309
  private namespaceFromPath(p: string): string {
6405
- if (!resolveNamespaceCapabilities(this.config).namespaces) return this.config.defaultNamespace;
6406
- const parts = qmdCollectionPathParts(p);
6407
- const collectionNamespace = parts
6408
- ? this.qmdCollectionNamespaceFromPrefix(parts.collection)
6409
- : null;
6410
- if (collectionNamespace) return collectionNamespace;
6411
- const m = p.match(/[\\/]+namespaces[\\/]+([^\\/]+)(?:[\\/]|$)/);
6412
- if (!m?.[1]) return this.config.defaultNamespace;
6413
- return namespaceIdentityFromToken(m[1]) ?? m[1];
4310
+ return this.namespaceReadFanoutCoordinator.namespaceFromPath(
4311
+ p,
4312
+ );
6414
4313
  }
6415
4314
 
6416
4315
  private storageDirNamespace(storageDir: string): string {
6417
- // #1521: delegates to the scope-module resolver. The inline dir→namespace
6418
- // derivation (token round-trip guard, catalog hints) is retired so the
6419
- // adHocNamespaceResolutions ratchet no longer counts this site. Hints are
6420
- // loaded lazily via the callback (only after early returns, matching the
6421
- // original behavior — codex P2).
6422
- return resolveNamespaceFromStorageDir(storageDir, {
6423
- config: this.config,
6424
- configuredNamespaces: this.configuredNamespaceList(),
6425
- hints: this.namespaceStorageDirHints,
6426
- loadHints: () => this.loadNamespaceStorageDirHintsFromCatalog(),
6427
- });
4316
+ return this.namespaceReadFanoutCoordinator.storageDirNamespace(
4317
+ storageDir,
4318
+ );
6428
4319
  }
6429
4320
 
6430
4321
  // #1522: catalog touch methods removed — touches now happen at the storage chokepoint.
@@ -6450,26 +4341,16 @@ export class Orchestrator {
6450
4341
  private async readAllMemoriesForNamespaces(
6451
4342
  namespaces: string[],
6452
4343
  ): Promise<MemoryFile[]> {
6453
- const uniq = Array.from(new Set(namespaces.filter(Boolean)));
6454
- const lists = await Promise.all(
6455
- uniq.map(async (ns) => {
6456
- const sm = await this.storageRouter.storageFor(ns);
6457
- return sm.readAllMemories();
6458
- }),
4344
+ return this.namespaceReadFanoutCoordinator.readAllMemoriesForNamespaces(
4345
+ namespaces,
6459
4346
  );
6460
- return lists.flat();
6461
4347
  }
6462
4348
 
6463
4349
  private async readArchivedMemoriesForNamespaces(
6464
4350
  namespaces: string[],
6465
4351
  ): Promise<MemoryFile[]> {
6466
- const uniq = Array.from(new Set(namespaces.filter(Boolean)));
6467
- const lists = await Promise.all(
6468
- uniq.map(async (ns) => {
6469
- const sm = await this.storageRouter.storageFor(ns);
6470
- return sm.readArchivedMemories();
6471
- }),
4352
+ return this.namespaceReadFanoutCoordinator.readArchivedMemoriesForNamespaces(
4353
+ namespaces,
6472
4354
  );
6473
- return lists.flat();
6474
4355
  }
6475
4356
  }