@remnic/core 9.3.749 → 9.3.750

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 (268) 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-PNUOQYRJ.js → chunk-CKLCFBA3.js} +6317 -6096
  49. package/dist/chunk-CKLCFBA3.js.map +1 -0
  50. package/dist/{chunk-CKFLLAIS.js → chunk-DANV2HTH.js} +3 -3
  51. package/dist/{chunk-NVYF5OB6.js → chunk-DBRVU5PR.js} +3 -3
  52. package/dist/{chunk-LMFS64GY.js → chunk-E4PFDD3N.js} +4 -4
  53. package/dist/{chunk-DIHCFJVV.js → chunk-HYI2GP3F.js} +4 -4
  54. package/dist/{chunk-UIKZZ242.js → chunk-IKHKHADN.js} +2 -2
  55. package/dist/{chunk-PUR2CKX2.js → chunk-JS2HKKOK.js} +1 -1
  56. package/dist/{chunk-PUR2CKX2.js.map → chunk-JS2HKKOK.js.map} +1 -1
  57. package/dist/{chunk-R2OAN6IP.js → chunk-K2JM4DZZ.js} +2 -2
  58. package/dist/{chunk-3IJGF6K2.js → chunk-K2R3DEEH.js} +2 -2
  59. package/dist/{chunk-HIHCB2S6.js → chunk-QRRBXD24.js} +2 -2
  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/search/embed-helper.d.ts +1 -1
  184. package/dist/search/factory.d.ts +1 -1
  185. package/dist/search/factory.js +1 -1
  186. package/dist/search/index.d.ts +1 -1
  187. package/dist/search/index.js +1 -1
  188. package/dist/search/lancedb-backend.d.ts +1 -1
  189. package/dist/search/lancedb-backend.js +1 -1
  190. package/dist/search/meilisearch-backend.d.ts +1 -1
  191. package/dist/search/meilisearch-backend.js +1 -1
  192. package/dist/search/noop-backend.d.ts +1 -1
  193. package/dist/search/orama-backend.d.ts +1 -1
  194. package/dist/search/orama-backend.js +1 -1
  195. package/dist/search/port.d.ts +1 -1
  196. package/dist/search/remote-backend.d.ts +1 -1
  197. package/dist/{semantic-consolidation-D3aSiu9t.d.ts → semantic-consolidation-CTfbVwCA.d.ts} +1 -1
  198. package/dist/semantic-consolidation.d.ts +2 -2
  199. package/dist/semantic-consolidation.js +8 -8
  200. package/dist/semantic-rule-promotion.js +7 -7
  201. package/dist/semantic-rule-verifier.d.ts +1 -1
  202. package/dist/semantic-rule-verifier.js +7 -7
  203. package/dist/session-observer-bands.d.ts +1 -1
  204. package/dist/session-observer-state.d.ts +1 -1
  205. package/dist/shared-context/manager.d.ts +1 -1
  206. package/dist/signal.d.ts +1 -1
  207. package/dist/storage.d.ts +1 -1
  208. package/dist/storage.js +6 -6
  209. package/dist/summarizer.d.ts +1 -1
  210. package/dist/summary-snapshot.d.ts +1 -1
  211. package/dist/temporal-supersession.d.ts +1 -1
  212. package/dist/temporal-validity.d.ts +1 -1
  213. package/dist/threading.d.ts +1 -1
  214. package/dist/tier-migration.d.ts +1 -1
  215. package/dist/tier-routing.d.ts +1 -1
  216. package/dist/topics.d.ts +1 -1
  217. package/dist/transcript.d.ts +1 -1
  218. package/dist/trust-score-stage.d.ts +1 -1
  219. package/dist/trust-score.d.ts +1 -1
  220. package/dist/{types-CZm6rx9-.d.ts → types-D4NYDtXI.d.ts} +7 -0
  221. package/dist/types.d.ts +1 -1
  222. package/dist/types.js +1 -1
  223. package/dist/utility-runtime.d.ts +1 -1
  224. package/dist/verified-recall.js +7 -7
  225. package/package.json +2 -2
  226. package/src/access-service.ts +22 -0
  227. package/src/config.ts +5 -0
  228. package/src/index.ts +3 -1
  229. package/src/offline-sync.test.ts +354 -52
  230. package/src/offline-sync.ts +332 -24
  231. package/src/orchestration/extraction-run.ts +4 -34
  232. package/src/orchestration/namespace-read-fanout.ts +484 -0
  233. package/src/orchestration/orchestrator-helpers.ts +1130 -0
  234. package/src/orchestration/workspace-ops.ts +789 -0
  235. package/src/orchestrator.ts +200 -2081
  236. package/src/types.ts +9 -0
  237. package/dist/chunk-24YDB5QP.js.map +0 -1
  238. package/dist/chunk-C5WN74HH.js.map +0 -1
  239. package/dist/chunk-PNUOQYRJ.js.map +0 -1
  240. package/dist/chunk-PWWWLD7D.js.map +0 -1
  241. /package/dist/{chunk-GKRLPHPU.js.map → chunk-2KK5WNQB.js.map} +0 -0
  242. /package/dist/{chunk-JRDPT6DA.js.map → chunk-4NWIGAIC.js.map} +0 -0
  243. /package/dist/{chunk-OBJUKYDO.js.map → chunk-4XL3CVII.js.map} +0 -0
  244. /package/dist/{chunk-WWVXXSLF.js.map → chunk-5PZCDUJ6.js.map} +0 -0
  245. /package/dist/{chunk-URQLO7BX.js.map → chunk-6FNZ3NMS.js.map} +0 -0
  246. /package/dist/{chunk-DRUDC2UX.js.map → chunk-6MMONPHM.js.map} +0 -0
  247. /package/dist/{chunk-RB6A7OUN.js.map → chunk-6SXNQFHB.js.map} +0 -0
  248. /package/dist/{chunk-ZGZNLRKF.js.map → chunk-7TWA7DKP.js.map} +0 -0
  249. /package/dist/{chunk-KMNYXLPL.js.map → chunk-B35L45HZ.js.map} +0 -0
  250. /package/dist/{chunk-SIV3JZPP.js.map → chunk-CGZWGVQF.js.map} +0 -0
  251. /package/dist/{chunk-CKFLLAIS.js.map → chunk-DANV2HTH.js.map} +0 -0
  252. /package/dist/{chunk-NVYF5OB6.js.map → chunk-DBRVU5PR.js.map} +0 -0
  253. /package/dist/{chunk-LMFS64GY.js.map → chunk-E4PFDD3N.js.map} +0 -0
  254. /package/dist/{chunk-DIHCFJVV.js.map → chunk-HYI2GP3F.js.map} +0 -0
  255. /package/dist/{chunk-UIKZZ242.js.map → chunk-IKHKHADN.js.map} +0 -0
  256. /package/dist/{chunk-R2OAN6IP.js.map → chunk-K2JM4DZZ.js.map} +0 -0
  257. /package/dist/{chunk-3IJGF6K2.js.map → chunk-K2R3DEEH.js.map} +0 -0
  258. /package/dist/{chunk-HIHCB2S6.js.map → chunk-QRRBXD24.js.map} +0 -0
  259. /package/dist/{chunk-ZPZ4VVLT.js.map → chunk-SD34EK4Z.js.map} +0 -0
  260. /package/dist/{chunk-WK3NK6SP.js.map → chunk-SNEXVX3U.js.map} +0 -0
  261. /package/dist/{chunk-FVZ2ISIX.js.map → chunk-TPLE6SNY.js.map} +0 -0
  262. /package/dist/{chunk-5IN26V6W.js.map → chunk-UU32EAIY.js.map} +0 -0
  263. /package/dist/{chunk-ZGAJB74V.js.map → chunk-W3BFT5OJ.js.map} +0 -0
  264. /package/dist/{chunk-L4N7SVNN.js.map → chunk-WA7FECSG.js.map} +0 -0
  265. /package/dist/{chunk-Q3X7GUAQ.js.map → chunk-WOX2UW4K.js.map} +0 -0
  266. /package/dist/{chunk-XTEWVF3C.js.map → chunk-WWSXVOGY.js.map} +0 -0
  267. /package/dist/{chunk-P4FW3ZU5.js.map → chunk-YGGXUNS4.js.map} +0 -0
  268. /package/dist/{chunk-JHQUIH3W.js.map → chunk-ZBVQ4ZM4.js.map} +0 -0
@@ -115,6 +115,74 @@ 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 {
121
+ abortRecallError,
122
+ buildCompressionGuidelinesMarkdown,
123
+ buildQmdIntentHint,
124
+ isArtifactMemoryPath,
125
+ mergeArtifactRecallCandidates,
126
+ tokenizeRecallQuery,
127
+ type BulkImportBatchIngestResult,
128
+ type DaySummaryGatherOptions,
129
+ type GraphRecallSnapshot,
130
+ type IntentDebugSnapshot,
131
+ type QmdRecallSnapshot,
132
+ type QueryAwarePrefilter,
133
+ type RecallInvocationOptions,
134
+ } from "./orchestration/orchestrator-helpers.js";
135
+ // Issue #1526 seam 25: module-level helpers moved to
136
+ // orchestration/orchestrator-helpers.ts; re-exported here so existing
137
+ // importers (coordinators, tests, root shims) keep working.
138
+ export {
139
+ BulkImportBatchPartialFailureError,
140
+ COMPACTION_SIGNAL_MAX_AGE_MS,
141
+ appendMemoryToGraphContext,
142
+ applyQueryAwareCandidateFilter,
143
+ buildCompressionGuidelinesMarkdown,
144
+ buildMemoryPathById,
145
+ computeArtifactCandidateFetchLimit,
146
+ computeArtifactRecallLimit,
147
+ computeQmdHybridFetchLimit,
148
+ defaultWorkspaceDir,
149
+ filterHourlySummaryMarkdownForLocalDay,
150
+ filterRecallCandidates,
151
+ formatDateInTimeZone,
152
+ isArtifactMemoryPath,
153
+ lifecycleRecallScoreAdjustment,
154
+ mapRecallSourceToXrayServedBy,
155
+ mergeArtifactRecallCandidates,
156
+ normalizeIanaTimeZone,
157
+ parseFiniteDate,
158
+ parseGraphRecallRankedResults,
159
+ parseMemoryIntentSnapshot,
160
+ parseQmdRecallResults,
161
+ qmdStartupCollectionCheckWithTimeout,
162
+ raceRecallAbort,
163
+ resolveEffectiveRecallMode,
164
+ resolvePersistedMemoryRelativePath,
165
+ resolveRecallModeDecision,
166
+ resolveRecallModeDecisionAsync,
167
+ resolveRecentThreadMemoryPaths,
168
+ sanitizeSessionKeyForFilename,
169
+ shouldFilterLifecycleRecallCandidate,
170
+ splitTurnsBySourceValidAt,
171
+ summarizeGraphShadowComparison,
172
+ targetSourceValidAtSortMs,
173
+ throwIfRecallAborted,
174
+ tokenizeRecallQuery,
175
+ utcDateKeysForLocalDay,
176
+ type BulkImportBatchIngestResult,
177
+ type DaySummaryGatherOptions,
178
+ type GraphRecallSnapshot,
179
+ type IntentDebugSnapshot,
180
+ type QmdRecallSnapshot,
181
+ type QueryAwarePrefilter,
182
+ type RecallInvocationOptions,
183
+ type RecallModeDecision,
184
+ } from "./orchestration/orchestrator-helpers.js";
185
+
118
186
  export { hasIdentityRecoveryIntent, resolveEffectiveIdentityInjectionMode } from "./orchestration/recall-result-formatter.js";
119
187
  import {
120
188
  GraphRecallCoordinator,
@@ -469,1105 +537,14 @@ import type {
469
537
  } from "./types.js";
470
538
  import { disposeDefaultArchiveScoring, getDefaultArchiveScoring, memoryFileToScoreItem } from "./recall/archive-scoring.js";
471
539
 
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
540
 
500
541
  // Issue #1526 seam 15: ExtractionRunResult moved to orchestration/extraction-run.ts.
501
542
  export type { ExtractionRunResult } from "./orchestration/extraction-run.js";
502
543
 
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
-
920
- function sourceValidAtMs(turn: BufferTurn): number | null {
921
- if (typeof turn.sourceValidAt !== "string") return null;
922
- return parseFlexibleIsoTimestamp(turn.sourceValidAt.trim());
923
- }
924
544
 
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
545
  // Issue #1526 seam 15: deriveTopicsFromExtraction moved to orchestration/extraction-run.ts.
1037
546
  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
-
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
547
 
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
548
 
1572
549
  export class Orchestrator {
1573
550
 
@@ -1706,7 +683,8 @@ export class Orchestrator {
1706
683
  statuses: Map<string, "active" | "superseded" | "archived" | "missing">;
1707
684
  }
1708
685
  >();
1709
- private static readonly ARTIFACT_STATUS_CACHE_TTL_MS = 60_000;
686
+ /** Read by NamespaceReadFanoutCoordinator (seam 26), hence not `private`. */
687
+ static readonly ARTIFACT_STATUS_CACHE_TTL_MS = 60_000;
1710
688
 
1711
689
  // Access tracking buffer (Phase 1A)
1712
690
  // Maps memoryId -> {count, lastAccessed} for batched updates
@@ -2273,98 +1251,17 @@ export class Orchestrator {
2273
1251
  resolvedStorageDir: string,
2274
1252
  configured: Set<string>,
2275
1253
  ): { namespace: string; identityToken: string; storageDir: string } {
2276
- const currentRank = this.namespaceStorageDirHintOwnershipRank(
1254
+ return this.namespaceReadFanoutCoordinator.preferNamespaceStorageDirHintOwner(
2277
1255
  current,
2278
- resolvedStorageDir,
2279
- configured,
2280
- );
2281
- const candidateRank = this.namespaceStorageDirHintOwnershipRank(
2282
1256
  candidate,
2283
1257
  resolvedStorageDir,
2284
1258
  configured,
2285
1259
  );
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
1260
  }
2296
1261
 
2297
1262
  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)),
1263
+ return this.namespaceReadFanoutCoordinator.loadNamespaceStorageDirHintsFromCatalog(
2342
1264
  );
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
1265
  }
2369
1266
 
2370
1267
  private async maintenanceNamespaces(
@@ -2440,68 +1337,9 @@ export class Orchestrator {
2440
1337
  searchOptions?: SearchQueryOptions;
2441
1338
  execution?: SearchExecutionOptions;
2442
1339
  }): 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
- });
1340
+ return this.namespaceReadFanoutCoordinator.searchAcrossNamespaces(
1341
+ options,
1342
+ );
2505
1343
  }
2506
1344
 
2507
1345
  async searchHealthForNamespace(
@@ -3048,92 +1886,10 @@ export class Orchestrator {
3048
1886
  storage: StorageManager,
3049
1887
  sourceIds: string[],
3050
1888
  ): 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;
1889
+ return this.namespaceReadFanoutCoordinator.resolveArtifactSourceStatuses(
1890
+ storage,
1891
+ sourceIds,
1892
+ );
3137
1893
  }
3138
1894
 
3139
1895
  /**
@@ -3226,27 +1982,6 @@ export class Orchestrator {
3226
1982
  );
3227
1983
  }
3228
1984
 
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
1985
  async runPatternReinforcement(options: {
3251
1986
  force?: boolean;
3252
1987
  namespace?: string;
@@ -3256,69 +1991,16 @@ export class Orchestrator {
3256
1991
  namespace: string;
3257
1992
  result?: PatternReinforcementResult;
3258
1993
  }> {
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}`,
1994
+ return this.workspaceOpsCoordinator.runPatternReinforcement(
1995
+ options,
3285
1996
  );
3286
- return { ran: true, result, namespace: cadenceKey };
3287
1997
  }
3288
1998
 
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
1999
  async runPatternReinforcementFanout(options: {
3296
2000
  force?: boolean;
3297
2001
  } = {}): 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 },
2002
+ return this.workspaceOpsCoordinator.runPatternReinforcementFanout(
2003
+ options,
3322
2004
  );
3323
2005
  }
3324
2006
 
@@ -3432,75 +2114,8 @@ export class Orchestrator {
3432
2114
  }
3433
2115
 
3434
2116
  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
- }
2117
+ return this.workspaceOpsCoordinator.maybeRunFileHygiene(
2118
+ );
3504
2119
  }
3505
2120
 
3506
2121
  async runBootstrap(options: BootstrapOptions): Promise<BootstrapResult> {
@@ -3651,282 +2266,22 @@ export class Orchestrator {
3651
2266
  return this.generateDaySummary(gathered);
3652
2267
  }
3653
2268
 
3654
- /**
3655
- * Read today's facts and hourly summaries from storage, returning them
3656
- * as a formatted string suitable for generateDaySummary().
3657
- */
3658
2269
  async gatherTodayFacts(
3659
2270
  namespace?: string,
3660
2271
  options: DaySummaryGatherOptions = {},
3661
2272
  ): 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)`,
2273
+ return this.workspaceOpsCoordinator.gatherTodayFacts(
2274
+ namespace,
2275
+ options,
3824
2276
  );
3825
-
3826
- return formatted;
3827
2277
  }
3828
2278
 
3829
2279
  previewMemoryActionEvent(
3830
2280
  event: Omit<MemoryActionEvent, "timestamp"> & { timestamp?: string },
3831
2281
  ): 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,
2282
+ return this.workspaceOpsCoordinator.previewMemoryActionEvent(
2283
+ event,
3838
2284
  );
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
2285
  }
3931
2286
 
3932
2287
  async appendMemoryActionEvent(
@@ -4087,63 +2442,9 @@ export class Orchestrator {
4087
2442
  return this.conversationIndexCoordinator.rebuild(sessionKey, hours, opts);
4088
2443
  }
4089
2444
 
4090
- /**
4091
- * Validate local LLM model availability and context window compatibility.
4092
- * Warns the user if there's a mismatch.
4093
- */
4094
2445
  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
- }
2446
+ return this.workspaceOpsCoordinator.validateLocalLlmModel(
2447
+ );
4147
2448
  }
4148
2449
 
4149
2450
  async recall(
@@ -4410,15 +2711,11 @@ export class Orchestrator {
4410
2711
  recallNamespaces: string[],
4411
2712
  targetCount: number,
4412
2713
  ): 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
- ),
2714
+ return this.namespaceReadFanoutCoordinator.recallArtifactsAcrossNamespaces(
2715
+ prompt,
2716
+ recallNamespaces,
2717
+ targetCount,
4419
2718
  );
4420
-
4421
- return mergeArtifactRecallCandidates(filteredByNamespace, targetCount);
4422
2719
  }
4423
2720
 
4424
2721
  private scopeQueryAwarePaths(
@@ -4458,51 +2755,12 @@ export class Orchestrator {
4458
2755
  allowArchived?: boolean;
4459
2756
  },
4460
2757
  ): 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);
2758
+ return this.namespaceReadFanoutCoordinator.searchScopedMemoryCandidates(
2759
+ candidatePaths,
2760
+ query,
2761
+ limit,
2762
+ options,
2763
+ );
4506
2764
  }
4507
2765
 
4508
2766
  private async fetchQmdMemoryResultsWithArtifactTopUp(
@@ -4596,26 +2854,9 @@ export class Orchestrator {
4596
2854
  private async resolveStateDirForNamespace(
4597
2855
  namespace: string,
4598
2856
  ): 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,
2857
+ return this.namespaceReadFanoutCoordinator.resolveStateDirForNamespace(
2858
+ namespace,
4609
2859
  );
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
2860
  }
4620
2861
 
4621
2862
  private buildGraphRecallRankedResults(
@@ -4716,7 +2957,6 @@ export class Orchestrator {
4716
2957
  }
4717
2958
 
4718
2959
 
4719
-
4720
2960
  /**
4721
2961
  * Clock source for the shared post-retrieval assembly/enrichment budget. The
4722
2962
  * deadline is set from this value and every expiry check reads it back, so a
@@ -5025,6 +3265,80 @@ export class Orchestrator {
5025
3265
  return this._persistenceIndexCoordinator;
5026
3266
  }
5027
3267
 
3268
+ /**
3269
+ * Workspace-ops coordinator (issue #1526 seam 24). Owns periodic
3270
+ * workspace/operations surfaces. Lazy + accessor-wired (late-binding
3271
+ * rule, seams 18–23).
3272
+ */
3273
+ private _workspaceOpsCoordinator: WorkspaceOpsCoordinator | undefined;
3274
+
3275
+ private get workspaceOpsCoordinator(): WorkspaceOpsCoordinator {
3276
+ if (!this._workspaceOpsCoordinator) {
3277
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
3278
+ const self = this;
3279
+ this._workspaceOpsCoordinator = new WorkspaceOpsCoordinator({
3280
+ get accessTrackingBuffer() { return self.accessTrackingBuffer; },
3281
+ bulkImportWriteNamespace: () => self.bulkImportWriteNamespace(),
3282
+ get config() { return self.config; },
3283
+ get extraction() { return self.extraction; },
3284
+ getStorage: (namespace) => self.getStorage(namespace),
3285
+ getStorageForNamespace: (namespace) => self.getStorageForNamespace(namespace),
3286
+ get judgeDeferCounts() { return self.judgeDeferCounts; },
3287
+ get judgeVerdictCache() { return self.judgeVerdictCache; },
3288
+ get lastFileHygieneRunAtMs() { return self.lastFileHygieneRunAtMs; },
3289
+ set lastFileHygieneRunAtMs(value) { self.lastFileHygieneRunAtMs = value; },
3290
+ get lastPatternReinforcementAtByNs() { return self.lastPatternReinforcementAtByNs; },
3291
+ get localLlm() { return self.localLlm; },
3292
+ maintenanceNamespaces: (jobName, budgetMode) => self.maintenanceNamespaces(jobName, budgetMode),
3293
+ namespaceFromPath: (p) => self.namespaceFromPath(p),
3294
+ get qmd() { return self.qmd; },
3295
+ readAllMemoriesForNamespaces: (namespaces) => self.readAllMemoriesForNamespaces(namespaces),
3296
+ runNamespaceMaintenanceFanoutForJob: (jobName, runner, options) => self.runNamespaceMaintenanceFanoutForJob(jobName, runner, options),
3297
+ runPatternReinforcement: (options) => self.runPatternReinforcement(options),
3298
+ get storage() { return self.storage; },
3299
+ get storageRouter() { return self.storageRouter; },
3300
+ get wearablesServiceInstance() { return self.wearablesServiceInstance; },
3301
+ set wearablesServiceInstance(value) { self.wearablesServiceInstance = value; },
3302
+ });
3303
+ }
3304
+ return this._workspaceOpsCoordinator;
3305
+ }
3306
+
3307
+ /**
3308
+ * Namespace read-fanout coordinator (issue #1526 seam 26). Owns
3309
+ * namespace-scoped read fanout (hints, cross-namespace search/reads,
3310
+ * artifact statuses). Lazy + accessor-wired (late-binding rule).
3311
+ */
3312
+ private _namespaceReadFanoutCoordinator: NamespaceReadFanoutCoordinator | undefined;
3313
+
3314
+ private get namespaceReadFanoutCoordinator(): NamespaceReadFanoutCoordinator {
3315
+ if (!this._namespaceReadFanoutCoordinator) {
3316
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
3317
+ const self = this;
3318
+ this._namespaceReadFanoutCoordinator = new NamespaceReadFanoutCoordinator({
3319
+ get artifactSourceStatusCache() { return self.artifactSourceStatusCache; },
3320
+ get config() { return self.config; },
3321
+ configuredNamespaceList: () => self.configuredNamespaceList(),
3322
+ fetchActiveArtifactsForNamespace: (namespace, prompt, targetCount) => self.fetchActiveArtifactsForNamespace(namespace, prompt, targetCount),
3323
+ loadNamespaceStorageDirHintsFromCatalog: () => self.loadNamespaceStorageDirHintsFromCatalog(),
3324
+ get namespaceCatalog() { return self.namespaceCatalog; },
3325
+ namespaceFromPath: (p) => self.namespaceFromPath(p),
3326
+ get namespaceSearchRouter() { return self.namespaceSearchRouter; },
3327
+ namespaceStorageDirHintOwnershipRank: (record, resolvedStorageDir, configured) => self.namespaceStorageDirHintOwnershipRank(record, resolvedStorageDir, configured),
3328
+ get namespaceStorageDirHints() { return self.namespaceStorageDirHints; },
3329
+ get namespaceStorageDirHintsLoaded() { return self.namespaceStorageDirHintsLoaded; },
3330
+ set namespaceStorageDirHintsLoaded(value) { self.namespaceStorageDirHintsLoaded = value; },
3331
+ preferNamespaceStorageDirHintOwner: (current, candidate, resolvedStorageDir, configured) => self.preferNamespaceStorageDirHintOwner(current, candidate, resolvedStorageDir, configured),
3332
+ get qmd() { return self.qmd; },
3333
+ qmdCollectionNamespaceFromPrefix: (collectionPrefix) => self.qmdCollectionNamespaceFromPrefix(collectionPrefix),
3334
+ rememberNamespaceStorageDirHint: (namespace, storageDir) => self.rememberNamespaceStorageDirHint(namespace, storageDir),
3335
+ storageDirMatchesNamespaceHint: (namespace, storageDir) => self.storageDirMatchesNamespaceHint(namespace, storageDir),
3336
+ get storageRouter() { return self.storageRouter; },
3337
+ });
3338
+ }
3339
+ return this._namespaceReadFanoutCoordinator;
3340
+ }
3341
+
5028
3342
  private async recallInternal(
5029
3343
  prompt: string,
5030
3344
  sessionKey?: string,
@@ -5159,60 +3473,9 @@ export class Orchestrator {
5159
3473
  return this.config.defaultNamespace;
5160
3474
  }
5161
3475
 
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
3476
  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;
3477
+ return this.workspaceOpsCoordinator.getWearablesService(
3478
+ );
5216
3479
  }
5217
3480
 
5218
3481
  async ingestBulkImportBatch(
@@ -5571,93 +3834,16 @@ export class Orchestrator {
5571
3834
  ): Promise<number> {
5572
3835
  return this.lifecyclePolicyCoordinator.runLifecyclePolicyPass(allMemories, storage);
5573
3836
  }
5574
- /** Threshold (bytes) at which IDENTITY.md reflections get auto-consolidated */
5575
- private static readonly IDENTITY_CONSOLIDATE_THRESHOLD = 8_000;
3837
+ /**
3838
+ * Threshold (bytes) at which IDENTITY.md reflections get auto-consolidated.
3839
+ * Read by WorkspaceOpsCoordinator (seam 24) via `Orchestrator.…`, hence
3840
+ * not `private`.
3841
+ */
3842
+ static readonly IDENTITY_CONSOLIDATE_THRESHOLD = 8_000;
5576
3843
 
5577
3844
  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
- }
3845
+ return this.workspaceOpsCoordinator.autoConsolidateIdentity(
3846
+ );
5661
3847
  }
5662
3848
 
5663
3849
  // Issue #1526: recall result formatting moved to RecallResultFormatter. Thin
@@ -6108,52 +4294,9 @@ export class Orchestrator {
6108
4294
  }
6109
4295
  }
6110
4296
 
6111
- /**
6112
- * Flush access tracking buffer to disk.
6113
- * Called during consolidation or when buffer is full.
6114
- */
6115
4297
  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`);
4298
+ return this.workspaceOpsCoordinator.flushAccessTracking(
4299
+ );
6157
4300
  }
6158
4301
 
6159
4302
  private async loadSearchResultMemoryMap(
@@ -6402,29 +4545,15 @@ export class Orchestrator {
6402
4545
  }
6403
4546
 
6404
4547
  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];
4548
+ return this.namespaceReadFanoutCoordinator.namespaceFromPath(
4549
+ p,
4550
+ );
6414
4551
  }
6415
4552
 
6416
4553
  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
- });
4554
+ return this.namespaceReadFanoutCoordinator.storageDirNamespace(
4555
+ storageDir,
4556
+ );
6428
4557
  }
6429
4558
 
6430
4559
  // #1522: catalog touch methods removed — touches now happen at the storage chokepoint.
@@ -6450,26 +4579,16 @@ export class Orchestrator {
6450
4579
  private async readAllMemoriesForNamespaces(
6451
4580
  namespaces: string[],
6452
4581
  ): 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
- }),
4582
+ return this.namespaceReadFanoutCoordinator.readAllMemoriesForNamespaces(
4583
+ namespaces,
6459
4584
  );
6460
- return lists.flat();
6461
4585
  }
6462
4586
 
6463
4587
  private async readArchivedMemoriesForNamespaces(
6464
4588
  namespaces: string[],
6465
4589
  ): 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
- }),
4590
+ return this.namespaceReadFanoutCoordinator.readArchivedMemoriesForNamespaces(
4591
+ namespaces,
6472
4592
  );
6473
- return lists.flat();
6474
4593
  }
6475
4594
  }