@remnic/core 9.3.749 → 9.3.751

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/dist/access-boundary.d.ts +8 -8
  2. package/dist/access-boundary.js +15 -15
  3. package/dist/access-cli.js +39 -39
  4. package/dist/access-http.d.ts +8 -8
  5. package/dist/access-http.js +20 -20
  6. package/dist/access-mcp.d.ts +8 -8
  7. package/dist/access-mcp.js +19 -19
  8. package/dist/access-operations-batch.js +16 -16
  9. package/dist/access-operations.d.ts +8 -8
  10. package/dist/access-operations.js +18 -18
  11. package/dist/access-schema.d.ts +2 -2
  12. package/dist/access-schema.js +5 -5
  13. package/dist/{access-service-5-EVpt3v.d.ts → access-service-DmAEOJdJ.d.ts} +11 -3
  14. package/dist/access-service.d.ts +5 -5
  15. package/dist/access-service.js +14 -14
  16. package/dist/access-surface-catalog.d.ts +8 -8
  17. package/dist/action-confidence.d.ts +1 -1
  18. package/dist/active-memory-bridge.d.ts +1 -1
  19. package/dist/active-recall.d.ts +1 -1
  20. package/dist/active-recall.js +8 -1
  21. package/dist/active-recall.js.map +1 -1
  22. package/dist/behavior-learner.d.ts +1 -1
  23. package/dist/behavior-signals.d.ts +1 -1
  24. package/dist/bootstrap.d.ts +7 -7
  25. package/dist/briefing.d.ts +1 -1
  26. package/dist/briefing.js +7 -7
  27. package/dist/buffer-surprise-report.d.ts +1 -1
  28. package/dist/buffer.d.ts +1 -1
  29. package/dist/calibration.d.ts +1 -1
  30. package/dist/capabilities.d.ts +1 -1
  31. package/dist/{catalog-D_ZDxu8Y.d.ts → catalog-BpOcwHdE.d.ts} +1 -1
  32. package/dist/causal-behavior.d.ts +1 -1
  33. package/dist/causal-consolidation.d.ts +1 -1
  34. package/dist/causal-consolidation.js +8 -8
  35. package/dist/causal-trajectory-graph.d.ts +1 -1
  36. package/dist/{chunk-C5WN74HH.js → chunk-2HAG7JOI.js} +8 -2
  37. package/dist/chunk-2HAG7JOI.js.map +1 -0
  38. package/dist/{chunk-GKRLPHPU.js → chunk-2KK5WNQB.js} +2 -2
  39. package/dist/{chunk-JRDPT6DA.js → chunk-4NWIGAIC.js} +2 -2
  40. package/dist/{chunk-OBJUKYDO.js → chunk-4XL3CVII.js} +2 -2
  41. package/dist/{chunk-WWVXXSLF.js → chunk-5PZCDUJ6.js} +4 -4
  42. package/dist/{chunk-URQLO7BX.js → chunk-6FNZ3NMS.js} +2 -2
  43. package/dist/{chunk-DRUDC2UX.js → chunk-6MMONPHM.js} +2 -2
  44. package/dist/{chunk-RB6A7OUN.js → chunk-6SXNQFHB.js} +2 -2
  45. package/dist/{chunk-ZGZNLRKF.js → chunk-7TWA7DKP.js} +5 -5
  46. package/dist/{chunk-KMNYXLPL.js → chunk-B35L45HZ.js} +2 -2
  47. package/dist/{chunk-SIV3JZPP.js → chunk-CGZWGVQF.js} +5 -5
  48. package/dist/{chunk-CKFLLAIS.js → chunk-DANV2HTH.js} +3 -3
  49. package/dist/{chunk-NVYF5OB6.js → chunk-DBRVU5PR.js} +3 -3
  50. package/dist/{chunk-LMFS64GY.js → chunk-E4PFDD3N.js} +4 -4
  51. package/dist/{chunk-DIHCFJVV.js → chunk-HYI2GP3F.js} +4 -4
  52. package/dist/{chunk-UIKZZ242.js → chunk-IKHKHADN.js} +2 -2
  53. package/dist/{chunk-PUR2CKX2.js → chunk-JS2HKKOK.js} +1 -1
  54. package/dist/{chunk-PUR2CKX2.js.map → chunk-JS2HKKOK.js.map} +1 -1
  55. package/dist/{chunk-R2OAN6IP.js → chunk-K2JM4DZZ.js} +2 -2
  56. package/dist/{chunk-3IJGF6K2.js → chunk-K2R3DEEH.js} +2 -2
  57. package/dist/{chunk-HIHCB2S6.js → chunk-QRRBXD24.js} +2 -2
  58. package/dist/{chunk-PNUOQYRJ.js → chunk-RWZB6MAY.js} +1953 -2172
  59. package/dist/chunk-RWZB6MAY.js.map +1 -0
  60. package/dist/{chunk-ZPZ4VVLT.js → chunk-SD34EK4Z.js} +2 -2
  61. package/dist/{chunk-WK3NK6SP.js → chunk-SNEXVX3U.js} +6 -6
  62. package/dist/{chunk-FVZ2ISIX.js → chunk-TPLE6SNY.js} +7 -7
  63. package/dist/{chunk-24YDB5QP.js → chunk-UETBEHBA.js} +44 -24
  64. package/dist/chunk-UETBEHBA.js.map +1 -0
  65. package/dist/{chunk-5IN26V6W.js → chunk-UU32EAIY.js} +5 -5
  66. package/dist/{chunk-ZGAJB74V.js → chunk-W3BFT5OJ.js} +2 -2
  67. package/dist/{chunk-L4N7SVNN.js → chunk-WA7FECSG.js} +11 -11
  68. package/dist/{chunk-Q3X7GUAQ.js → chunk-WOX2UW4K.js} +2 -2
  69. package/dist/{chunk-XTEWVF3C.js → chunk-WWSXVOGY.js} +2 -2
  70. package/dist/{chunk-PWWWLD7D.js → chunk-X64YLC24.js} +153 -27
  71. package/dist/chunk-X64YLC24.js.map +1 -0
  72. package/dist/{chunk-P4FW3ZU5.js → chunk-YGGXUNS4.js} +20 -20
  73. package/dist/{chunk-JHQUIH3W.js → chunk-ZBVQ4ZM4.js} +19 -19
  74. package/dist/{cli-DuWWjaKT.d.ts → cli-lmf5RGyQ.d.ts} +3 -3
  75. package/dist/cli.d.ts +9 -9
  76. package/dist/cli.js +33 -33
  77. package/dist/compounding/engine.d.ts +1 -1
  78. package/dist/compounding/engine.js +7 -7
  79. package/dist/compounding/preference-consolidator.d.ts +1 -1
  80. package/dist/compression-optimizer.d.ts +1 -1
  81. package/dist/config.d.ts +1 -1
  82. package/dist/config.js +8 -1
  83. package/dist/connectors/codex-materialize-runner.d.ts +1 -1
  84. package/dist/connectors/codex-materialize-runner.js +7 -7
  85. package/dist/connectors/codex-materialize.d.ts +1 -1
  86. package/dist/connectors/index.d.ts +1 -1
  87. package/dist/connectors/index.js +7 -7
  88. package/dist/consolidation-provenance-check.d.ts +1 -1
  89. package/dist/consolidation-undo.d.ts +1 -1
  90. package/dist/contradiction/index.d.ts +1 -1
  91. package/dist/conversation-index/backend.d.ts +1 -1
  92. package/dist/conversation-index/chunker.d.ts +1 -1
  93. package/dist/conversation-index/faiss-adapter.d.ts +1 -1
  94. package/dist/conversation-index/indexer.d.ts +1 -1
  95. package/dist/conversation-index/search.d.ts +1 -1
  96. package/dist/day-summary.d.ts +1 -1
  97. package/dist/delinearize.d.ts +1 -1
  98. package/dist/direct-answer-wiring.d.ts +1 -1
  99. package/dist/direct-answer.d.ts +1 -1
  100. package/dist/embedding-fallback.d.ts +1 -1
  101. package/dist/enrichment/index.d.ts +1 -1
  102. package/dist/entity-retrieval.d.ts +1 -1
  103. package/dist/entity-retrieval.js +7 -7
  104. package/dist/entity-schema.d.ts +1 -1
  105. package/dist/explicit-capture.d.ts +7 -7
  106. package/dist/extraction-faithfulness.d.ts +1 -1
  107. package/dist/extraction-judge-telemetry.d.ts +1 -1
  108. package/dist/extraction-judge-training.d.ts +1 -1
  109. package/dist/extraction-judge.d.ts +1 -1
  110. package/dist/extraction.d.ts +1 -1
  111. package/dist/fallback-llm.d.ts +1 -1
  112. package/dist/graph-dashboard-diff.d.ts +1 -1
  113. package/dist/graph-dashboard-key.d.ts +1 -1
  114. package/dist/graph-dashboard-parser.d.ts +1 -1
  115. package/dist/graph-edge-reinforcement.d.ts +1 -1
  116. package/dist/graph-snapshot.d.ts +1 -1
  117. package/dist/graph.d.ts +1 -1
  118. package/dist/identity-continuity.d.ts +1 -1
  119. package/dist/importance.d.ts +1 -1
  120. package/dist/index.d.ts +10 -10
  121. package/dist/index.js +76 -72
  122. package/dist/intent.d.ts +1 -1
  123. package/dist/lcm/engine.d.ts +1 -1
  124. package/dist/lcm/index.d.ts +1 -1
  125. package/dist/lcm/tools.d.ts +1 -1
  126. package/dist/lifecycle.d.ts +1 -1
  127. package/dist/live-connectors-runner.d.ts +1 -1
  128. package/dist/local-llm.d.ts +1 -1
  129. package/dist/local-model-endpoint.d.ts +1 -1
  130. package/dist/maintenance/memory-governance.d.ts +1 -1
  131. package/dist/maintenance/memory-governance.js +7 -7
  132. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +7 -7
  133. package/dist/maintenance/rebuild-memory-projection.js +8 -8
  134. package/dist/mcp-memory-inspector-app.d.ts +5 -5
  135. package/dist/memory-action-policy.d.ts +1 -1
  136. package/dist/memory-cache.d.ts +1 -1
  137. package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
  138. package/dist/memory-projection-store.d.ts +1 -1
  139. package/dist/memory-provenance.d.ts +1 -1
  140. package/dist/memory-worth-outcomes.d.ts +1 -1
  141. package/dist/models-json.d.ts +1 -1
  142. package/dist/namespaces/migrate.d.ts +2 -2
  143. package/dist/namespaces/migrate.js +8 -8
  144. package/dist/namespaces/principal.d.ts +1 -1
  145. package/dist/namespaces/search.d.ts +1 -1
  146. package/dist/namespaces/search.js +1 -1
  147. package/dist/namespaces/storage.d.ts +2 -2
  148. package/dist/namespaces/storage.js +7 -7
  149. package/dist/native-knowledge.d.ts +1 -1
  150. package/dist/offline-sync.d.ts +108 -1
  151. package/dist/offline-sync.js +7 -1
  152. package/dist/operator-toolkit.d.ts +1 -1
  153. package/dist/operator-toolkit.js +15 -12
  154. package/dist/orchestration/compression-guideline-coordinator.d.ts +1 -1
  155. package/dist/orchestration/maintenance.d.ts +2 -2
  156. package/dist/orchestration/maintenance.js +9 -9
  157. package/dist/{orchestrator-CZI6hO_R.d.ts → orchestrator-D7bb8ZGp.d.ts} +193 -201
  158. package/dist/orchestrator.d.ts +5 -5
  159. package/dist/orchestrator.js +51 -41
  160. package/dist/patterns-cli.d.ts +1 -1
  161. package/dist/policy-runtime.d.ts +1 -1
  162. package/dist/provenance.d.ts +1 -1
  163. package/dist/qmd-recall-cache.d.ts +1 -1
  164. package/dist/qmd.d.ts +1 -1
  165. package/dist/recall-disclosure-escalation.d.ts +1 -1
  166. package/dist/recall-explain-renderer.d.ts +1 -1
  167. package/dist/recall-explain-renderer.js +3 -3
  168. package/dist/recall-planner-llm.d.ts +1 -1
  169. package/dist/recall-state.d.ts +1 -1
  170. package/dist/recall-tag-filter.d.ts +1 -1
  171. package/dist/recall-xray-cli.d.ts +1 -1
  172. package/dist/recall-xray-cli.js +4 -4
  173. package/dist/recall-xray-renderer.d.ts +1 -1
  174. package/dist/recall-xray-renderer.js +3 -3
  175. package/dist/recall-xray.d.ts +1 -1
  176. package/dist/recall-xray.js +2 -2
  177. package/dist/resolve-auth-token.d.ts +1 -1
  178. package/dist/resume-bundles.js +9 -2
  179. package/dist/retrieval-agents.d.ts +1 -1
  180. package/dist/retrieval-tiers.d.ts +1 -1
  181. package/dist/routing/engine.d.ts +1 -1
  182. package/dist/routing/store.d.ts +1 -1
  183. package/dist/schemas.d.ts +22 -22
  184. package/dist/search/embed-helper.d.ts +1 -1
  185. package/dist/search/factory.d.ts +1 -1
  186. package/dist/search/factory.js +1 -1
  187. package/dist/search/index.d.ts +1 -1
  188. package/dist/search/index.js +1 -1
  189. package/dist/search/lancedb-backend.d.ts +1 -1
  190. package/dist/search/lancedb-backend.js +1 -1
  191. package/dist/search/meilisearch-backend.d.ts +1 -1
  192. package/dist/search/meilisearch-backend.js +1 -1
  193. package/dist/search/noop-backend.d.ts +1 -1
  194. package/dist/search/orama-backend.d.ts +1 -1
  195. package/dist/search/orama-backend.js +1 -1
  196. package/dist/search/port.d.ts +1 -1
  197. package/dist/search/remote-backend.d.ts +1 -1
  198. package/dist/{semantic-consolidation-D3aSiu9t.d.ts → semantic-consolidation-CTfbVwCA.d.ts} +1 -1
  199. package/dist/semantic-consolidation.d.ts +2 -2
  200. package/dist/semantic-consolidation.js +8 -8
  201. package/dist/semantic-rule-promotion.js +7 -7
  202. package/dist/semantic-rule-verifier.d.ts +1 -1
  203. package/dist/semantic-rule-verifier.js +7 -7
  204. package/dist/session-observer-bands.d.ts +1 -1
  205. package/dist/session-observer-state.d.ts +1 -1
  206. package/dist/shared-context/manager.d.ts +1 -1
  207. package/dist/signal.d.ts +1 -1
  208. package/dist/storage.d.ts +1 -1
  209. package/dist/storage.js +6 -6
  210. package/dist/summarizer.d.ts +1 -1
  211. package/dist/summary-snapshot.d.ts +1 -1
  212. package/dist/temporal-supersession.d.ts +1 -1
  213. package/dist/temporal-validity.d.ts +1 -1
  214. package/dist/threading.d.ts +1 -1
  215. package/dist/tier-migration.d.ts +1 -1
  216. package/dist/tier-routing.d.ts +1 -1
  217. package/dist/topics.d.ts +1 -1
  218. package/dist/transcript.d.ts +1 -1
  219. package/dist/transfer/types.d.ts +12 -12
  220. package/dist/trust-score-stage.d.ts +1 -1
  221. package/dist/trust-score.d.ts +1 -1
  222. package/dist/{types-CZm6rx9-.d.ts → types-D4NYDtXI.d.ts} +7 -0
  223. package/dist/types.d.ts +1 -1
  224. package/dist/types.js +1 -1
  225. package/dist/utility-runtime.d.ts +1 -1
  226. package/dist/verified-recall.js +7 -7
  227. package/package.json +2 -2
  228. package/src/access-service.ts +22 -0
  229. package/src/config.ts +5 -0
  230. package/src/index.ts +3 -1
  231. package/src/offline-sync.test.ts +354 -52
  232. package/src/offline-sync.ts +332 -24
  233. package/src/orchestration/extraction-run.ts +4 -34
  234. package/src/orchestration/namespace-read-fanout.ts +484 -0
  235. package/src/orchestration/orchestrator-helpers.ts +1130 -0
  236. package/src/orchestration/self-deps.ts +50 -0
  237. package/src/orchestration/workspace-ops.ts +789 -0
  238. package/src/orchestrator.ts +177 -2296
  239. package/src/types.ts +9 -0
  240. package/dist/chunk-24YDB5QP.js.map +0 -1
  241. package/dist/chunk-C5WN74HH.js.map +0 -1
  242. package/dist/chunk-PNUOQYRJ.js.map +0 -1
  243. package/dist/chunk-PWWWLD7D.js.map +0 -1
  244. /package/dist/{chunk-GKRLPHPU.js.map → chunk-2KK5WNQB.js.map} +0 -0
  245. /package/dist/{chunk-JRDPT6DA.js.map → chunk-4NWIGAIC.js.map} +0 -0
  246. /package/dist/{chunk-OBJUKYDO.js.map → chunk-4XL3CVII.js.map} +0 -0
  247. /package/dist/{chunk-WWVXXSLF.js.map → chunk-5PZCDUJ6.js.map} +0 -0
  248. /package/dist/{chunk-URQLO7BX.js.map → chunk-6FNZ3NMS.js.map} +0 -0
  249. /package/dist/{chunk-DRUDC2UX.js.map → chunk-6MMONPHM.js.map} +0 -0
  250. /package/dist/{chunk-RB6A7OUN.js.map → chunk-6SXNQFHB.js.map} +0 -0
  251. /package/dist/{chunk-ZGZNLRKF.js.map → chunk-7TWA7DKP.js.map} +0 -0
  252. /package/dist/{chunk-KMNYXLPL.js.map → chunk-B35L45HZ.js.map} +0 -0
  253. /package/dist/{chunk-SIV3JZPP.js.map → chunk-CGZWGVQF.js.map} +0 -0
  254. /package/dist/{chunk-CKFLLAIS.js.map → chunk-DANV2HTH.js.map} +0 -0
  255. /package/dist/{chunk-NVYF5OB6.js.map → chunk-DBRVU5PR.js.map} +0 -0
  256. /package/dist/{chunk-LMFS64GY.js.map → chunk-E4PFDD3N.js.map} +0 -0
  257. /package/dist/{chunk-DIHCFJVV.js.map → chunk-HYI2GP3F.js.map} +0 -0
  258. /package/dist/{chunk-UIKZZ242.js.map → chunk-IKHKHADN.js.map} +0 -0
  259. /package/dist/{chunk-R2OAN6IP.js.map → chunk-K2JM4DZZ.js.map} +0 -0
  260. /package/dist/{chunk-3IJGF6K2.js.map → chunk-K2R3DEEH.js.map} +0 -0
  261. /package/dist/{chunk-HIHCB2S6.js.map → chunk-QRRBXD24.js.map} +0 -0
  262. /package/dist/{chunk-ZPZ4VVLT.js.map → chunk-SD34EK4Z.js.map} +0 -0
  263. /package/dist/{chunk-WK3NK6SP.js.map → chunk-SNEXVX3U.js.map} +0 -0
  264. /package/dist/{chunk-FVZ2ISIX.js.map → chunk-TPLE6SNY.js.map} +0 -0
  265. /package/dist/{chunk-5IN26V6W.js.map → chunk-UU32EAIY.js.map} +0 -0
  266. /package/dist/{chunk-ZGAJB74V.js.map → chunk-W3BFT5OJ.js.map} +0 -0
  267. /package/dist/{chunk-L4N7SVNN.js.map → chunk-WA7FECSG.js.map} +0 -0
  268. /package/dist/{chunk-Q3X7GUAQ.js.map → chunk-WOX2UW4K.js.map} +0 -0
  269. /package/dist/{chunk-XTEWVF3C.js.map → chunk-WWSXVOGY.js.map} +0 -0
  270. /package/dist/{chunk-P4FW3ZU5.js.map → chunk-YGGXUNS4.js.map} +0 -0
  271. /package/dist/{chunk-JHQUIH3W.js.map → chunk-ZBVQ4ZM4.js.map} +0 -0
@@ -0,0 +1,789 @@
1
+ /**
2
+ * Workspace-ops coordinator — extracted from the orchestrator
3
+ * (issue #1526, seam 24).
4
+ *
5
+ * Owns the periodic workspace/operations surfaces:
6
+ * - day-summary fact gathering (gatherTodayFacts)
7
+ * - memory-action policy preview (previewMemoryActionEvent)
8
+ * - file hygiene runs and identity auto-consolidation
9
+ * - wearables service construction
10
+ * - local-LLM model validation
11
+ * - pattern-reinforcement runs and their namespace fanout
12
+ * - access-tracking flush
13
+ *
14
+ * Behavior-preserving move from orchestrator.ts (late-binding deps rule,
15
+ * seams 18–23).
16
+ */
17
+
18
+ import { lstat, mkdir, readFile, readdir, realpath, writeFile } from "node:fs/promises";
19
+ import path from "node:path";
20
+ import { resolveCompressionCapabilities, resolveConsolidationCapabilities, resolveNamespaceCapabilities } from "../capabilities.js";
21
+ import { formatDaySummaryMemories } from "../day-summary.js";
22
+ import { type JudgeVerdict, judgeFactDurability } from "../extraction-judge.js";
23
+ import { ExtractionEngine } from "../extraction.js";
24
+ import { FallbackLlmClient, fallbackLlmRuntimeContextFromConfig } from "../fallback-llm.js";
25
+ import { lintWorkspaceFiles, rotateMarkdownFileToArchive } from "../hygiene.js";
26
+ import { StorageManager } from "../index.js";
27
+ import { LocalLlmClient } from "../local-llm.js";
28
+ import { log } from "../logger.js";
29
+ import type { NamespaceMaintenanceFanoutRunnerContext } from "../maintenance/namespace-maintenance-fanout.js";
30
+ import type { NamespaceMaintenanceSummary } from "../maintenance/namespace-planner.js";
31
+ import { type PatternReinforcementResult, runPatternReinforcement } from "../maintenance/pattern-reinforcement.js";
32
+ import { evaluateMemoryActionPolicy } from "../memory-action-policy.js";
33
+ import { NamespaceStorageRouter } from "../namespaces/storage.js";
34
+ import { parseMemoryActionEligibilityContext } from "../schemas.js";
35
+ import type { SearchBackend } from "../search/port.js";
36
+ import { type AccessTrackingEntry, type MemoryActionEvent, type MemoryFile, type PluginConfig, confidenceTier } from "../types.js";
37
+ import { RECALL_FALLBACK_DIRS } from "../utils/category-dir.js";
38
+ import { assertPathInsideRoot } from "../utils/path-containment.js";
39
+ import { WearablesService } from "../wearables/service.js";
40
+ import {
41
+ Orchestrator,
42
+ filterHourlySummaryMarkdownForLocalDay,
43
+ formatDateInTimeZone,
44
+ normalizeIanaTimeZone,
45
+ parseFiniteDate,
46
+ utcDateKeysForLocalDay,
47
+ type DaySummaryGatherOptions,
48
+ } from "../orchestrator.js";
49
+
50
+ export interface WorkspaceOpsDeps {
51
+ readonly accessTrackingBuffer: Map<string, { count: number; lastAccessed: string }>;
52
+ bulkImportWriteNamespace(): string;
53
+ readonly config: PluginConfig;
54
+ readonly extraction: ExtractionEngine;
55
+ getStorage(namespace?: string): Promise<StorageManager>;
56
+ getStorageForNamespace(namespace?: string): Promise<StorageManager>;
57
+ readonly judgeDeferCounts: Map<string, number>;
58
+ readonly judgeVerdictCache: Map<string, JudgeVerdict>;
59
+ lastFileHygieneRunAtMs: number;
60
+ readonly lastPatternReinforcementAtByNs: Map<string, number>;
61
+ readonly localLlm: LocalLlmClient;
62
+ maintenanceNamespaces(
63
+ jobName?: string,
64
+ budgetMode?: "cycle" | "unbounded",
65
+ ): Promise<string[]>;
66
+ namespaceFromPath(p: string): string;
67
+ readonly qmd: SearchBackend;
68
+ readAllMemoriesForNamespaces(
69
+ namespaces: string[],
70
+ ): Promise<MemoryFile[]>;
71
+ runNamespaceMaintenanceFanoutForJob(
72
+ jobName: string,
73
+ runner: (ctx: NamespaceMaintenanceFanoutRunnerContext) => Promise<{ itemCount?: number } | undefined>,
74
+ options?: { enabled?: boolean },
75
+ ): Promise<NamespaceMaintenanceSummary>;
76
+ runPatternReinforcement(options?: {
77
+ force?: boolean;
78
+ namespace?: string;
79
+ }): Promise<{
80
+ ran: boolean;
81
+ skippedReason?: "disabled" | "cadence";
82
+ namespace: string;
83
+ result?: PatternReinforcementResult;
84
+ }>;
85
+ readonly storage: StorageManager;
86
+ readonly storageRouter: NamespaceStorageRouter;
87
+ wearablesServiceInstance: WearablesService | null;
88
+ }
89
+
90
+ export class WorkspaceOpsCoordinator {
91
+ constructor(
92
+ private readonly deps: WorkspaceOpsDeps,
93
+ ) {}
94
+
95
+ /**
96
+ * Run the pattern-reinforcement maintenance job (issue #687 PR 2/4).
97
+ *
98
+ * Cadence-gated on `patternReinforcementCadenceMs` so every caller
99
+ * (orchestrator cron path, MCP tool, CLI) shares a single floor —
100
+ * none can call this on a hot loop and burn the corpus. When the
101
+ * feature is disabled or the cadence has not elapsed, returns a
102
+ * synthetic "skipped" result rather than throwing.
103
+ *
104
+ * Cadence tracking is per-namespace so a tenant-scoped MCP run in
105
+ * one namespace does not silence a cron run in another (PR #730
106
+ * review feedback, Codex P2). Pass `force: true` for ad-hoc
107
+ * operator runs that must bypass the cadence floor — mirrors the
108
+ * pattern used by other maintenance MCP tools.
109
+ *
110
+ * `force` deliberately does NOT bypass the master
111
+ * `patternReinforcementEnabled` flag (PR #730 review feedback,
112
+ * Cursor Medium). Operators who have explicitly disabled the
113
+ * feature must not have their corpus mutated by an MCP tool call —
114
+ * the only way to run the job is to enable the feature in config.
115
+ */
116
+ async runPatternReinforcement(options: {
117
+ force?: boolean;
118
+ namespace?: string;
119
+ } = {}): Promise<{
120
+ ran: boolean;
121
+ skippedReason?: "disabled" | "cadence";
122
+ namespace: string;
123
+ result?: PatternReinforcementResult;
124
+ }> {
125
+ const cadenceKey = options.namespace ?? "";
126
+ // Master switch: a disabled feature is never bypassed, even with
127
+ // force=true. `force` only relaxes the cadence floor below.
128
+ if (!resolveConsolidationCapabilities(this.deps.config).patternReinforcement) {
129
+ return { ran: false, skippedReason: "disabled", namespace: cadenceKey };
130
+ }
131
+ const cadence = this.deps.config.patternReinforcementCadenceMs;
132
+ const lastAt = this.deps.lastPatternReinforcementAtByNs.get(cadenceKey);
133
+ if (
134
+ !options.force &&
135
+ cadence > 0 &&
136
+ lastAt !== undefined &&
137
+ Date.now() - lastAt < cadence
138
+ ) {
139
+ return { ran: false, skippedReason: "cadence", namespace: cadenceKey };
140
+ }
141
+ const storage = options.namespace
142
+ ? await this.deps.getStorage(options.namespace)
143
+ : this.deps.storage;
144
+ const result = await runPatternReinforcement(storage, {
145
+ categories: this.deps.config.patternReinforcementCategories,
146
+ minCount: this.deps.config.patternReinforcementMinCount,
147
+ });
148
+ this.deps.lastPatternReinforcementAtByNs.set(cadenceKey, Date.now());
149
+ log.debug(
150
+ `pattern reinforcement [ns=${cadenceKey || "(default)"}]: clusters=${result.clustersFound} canonicalsUpdated=${result.canonicalsUpdated} duplicatesSuperseded=${result.duplicatesSuperseded}`,
151
+ );
152
+ return { ran: true, result, namespace: cadenceKey };
153
+ }
154
+
155
+ /**
156
+ * Fan out pattern reinforcement across all maintained namespaces (issue #1500).
157
+ * Delegates per-namespace execution to {@link runPatternReinforcement} while
158
+ * the planner handles discovery, budgeting, locking, and status recording.
159
+ * When namespaces are disabled, runs once against default storage.
160
+ */
161
+ async runPatternReinforcementFanout(options: {
162
+ force?: boolean;
163
+ } = {}): Promise<NamespaceMaintenanceSummary> {
164
+ return this.deps.runNamespaceMaintenanceFanoutForJob(
165
+ "pattern-reinforcement",
166
+ async (ctx) => {
167
+ const result = await this.deps.runPatternReinforcement({
168
+ namespace: ctx.candidate.namespace,
169
+ force: options.force,
170
+ });
171
+ // runPatternReinforcement has its own per-namespace cadence gate
172
+ // (lastPatternReinforcementAtByNs). When it throttles (ran:false),
173
+ // signal skip so the planner records state:"skipped" and does NOT
174
+ // touch lastMaintenanceAt — otherwise a throttled namespace would
175
+ // look maintained while pattern reinforcement never ran (#1500
176
+ // review: cadence-skip accuracy).
177
+ if (!result.ran) {
178
+ return {
179
+ skipped: true,
180
+ skipReason: result.skippedReason ?? "throttled",
181
+ };
182
+ }
183
+ return result.result
184
+ ? { itemCount: result.result.clustersFound }
185
+ : { itemCount: 0 };
186
+ },
187
+ { enabled: resolveConsolidationCapabilities(this.deps.config).patternReinforcement },
188
+ );
189
+ }
190
+
191
+ async maybeRunFileHygiene(): Promise<void> {
192
+ const hygiene = this.deps.config.fileHygiene;
193
+ if (!hygiene?.enabled) return;
194
+
195
+ const now = Date.now();
196
+ if (now - this.deps.lastFileHygieneRunAtMs < hygiene.runMinIntervalMs) return;
197
+ this.deps.lastFileHygieneRunAtMs = now;
198
+
199
+ // Rotation first (keeps bootstrap files small).
200
+ if (hygiene.rotateEnabled) {
201
+ for (const rel of hygiene.rotatePaths) {
202
+ const abs = path.isAbsolute(rel)
203
+ ? rel
204
+ : path.join(this.deps.config.workspaceDir, rel);
205
+ try {
206
+ const raw = await readFile(abs, "utf-8");
207
+ if (raw.length > hygiene.rotateMaxBytes) {
208
+ const archiveDir = path.join(
209
+ this.deps.config.workspaceDir,
210
+ hygiene.archiveDir,
211
+ );
212
+ const base = path.basename(abs);
213
+ const prefix =
214
+ base
215
+ .toUpperCase()
216
+ .replace(/\.MD$/i, "")
217
+ .replace(/[^A-Z0-9]+/g, "-") || "FILE";
218
+ const { newContent } = await rotateMarkdownFileToArchive({
219
+ filePath: abs,
220
+ archiveDir,
221
+ archivePrefix: prefix,
222
+ keepTailChars: hygiene.rotateKeepTailChars,
223
+ });
224
+ await writeFile(abs, newContent, "utf-8");
225
+ }
226
+ } catch {
227
+ // ignore missing/unreadable targets
228
+ }
229
+ }
230
+ }
231
+
232
+ // Lint (warn before truncation risk).
233
+ if (hygiene.lintEnabled) {
234
+ const warnings = await lintWorkspaceFiles({
235
+ workspaceDir: this.deps.config.workspaceDir,
236
+ paths: hygiene.lintPaths,
237
+ budgetBytes: hygiene.lintBudgetBytes,
238
+ warnRatio: hygiene.lintWarnRatio,
239
+ });
240
+ for (const w of warnings) {
241
+ log.warn(w.message);
242
+ }
243
+
244
+ if (hygiene.warningsLogEnabled && warnings.length > 0) {
245
+ const fp = path.join(this.deps.config.memoryDir, hygiene.warningsLogPath);
246
+ await mkdir(path.dirname(fp), { recursive: true });
247
+ const stamp = new Date().toISOString();
248
+ const block =
249
+ `\n\n## ${stamp}\n\n` +
250
+ warnings.map((w) => `- ${w.message}`).join("\n") +
251
+ "\n";
252
+ let existing = "";
253
+ try {
254
+ existing = await readFile(fp, "utf-8");
255
+ } catch {
256
+ existing = "# Engram File Hygiene Warnings\n";
257
+ }
258
+ await writeFile(fp, existing + block, "utf-8");
259
+ }
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Read today's facts and hourly summaries from storage, returning them
265
+ * as a formatted string suitable for generateDaySummary().
266
+ */
267
+ async gatherTodayFacts(
268
+ namespace?: string,
269
+ options: DaySummaryGatherOptions = {},
270
+ ): Promise<string> {
271
+ const ns =
272
+ namespace && namespace.length > 0
273
+ ? namespace
274
+ : this.deps.config.defaultNamespace;
275
+ const storage = await this.deps.storageRouter.storageFor(ns);
276
+ const configuredTimeZone = normalizeIanaTimeZone(options.timeZone)
277
+ ?? normalizeIanaTimeZone(this.deps.config.daySummaryTimezone);
278
+ const timeZone =
279
+ configuredTimeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
280
+ const now = options.now instanceof Date && Number.isFinite(options.now.getTime())
281
+ ? options.now
282
+ : new Date();
283
+ const targetLocalDate = formatDateInTimeZone(now, timeZone);
284
+ // Facts are stored under UTC date directories, while the summary target is
285
+ // a local calendar day. Scan the UTC-date envelope that overlaps the local
286
+ // day, then filter parseable fact timestamps to that configured local day.
287
+ const datesToScan = utcDateKeysForLocalDay(now, timeZone);
288
+ const MAX_CHARS = 100_000;
289
+
290
+ // --- Read memory files from each category dir × date directory ---
291
+ // Iterate every recall category dir (RECALL_FALLBACK_DIRS — single source
292
+ // of truth) so the day summary includes decisions/, moments/, ... not just
293
+ // facts/ (#1546). corrections/ is flat, so corrections/<date>/ never exists
294
+ // and is skipped by the ENOENT guard — preserving the prior exclusion. The
295
+ // per-file created→local-day filter below is unchanged.
296
+ //
297
+ // Symlink/containment hardening (mirrors scanDir / the CLI walker): the
298
+ // gathered contents feed the day-summary LLM input, so a symlinked category
299
+ // dir (decisions/ → outside memoryDir) must not be followed and leak files.
300
+ // Resolve the store root once; skip symlinked / out-of-root dirs and
301
+ // entries; skip the scan gracefully if the root can't be resolved.
302
+ const facts: MemoryFile[] = [];
303
+ let memoryRootReal: string | null = null;
304
+ try {
305
+ memoryRootReal = await realpath(storage.dir);
306
+ } catch {
307
+ memoryRootReal = null;
308
+ }
309
+ for (const categoryDir of RECALL_FALLBACK_DIRS) {
310
+ if (memoryRootReal === null) break;
311
+ for (const date of datesToScan) {
312
+ const dateDir = path.join(storage.dir, categoryDir, date);
313
+ try {
314
+ const dirStat = await lstat(dateDir);
315
+ if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) continue;
316
+ assertPathInsideRoot(memoryRootReal, await realpath(dateDir), dateDir);
317
+ const entries = await readdir(dateDir, { withFileTypes: true });
318
+ for (const entry of entries) {
319
+ if (entry.isSymbolicLink()) continue;
320
+ if (!entry.name.endsWith(".md")) continue;
321
+ const fullPath = path.join(dateDir, entry.name);
322
+ try {
323
+ assertPathInsideRoot(memoryRootReal, await realpath(fullPath), fullPath);
324
+ const raw = await readFile(fullPath, "utf-8");
325
+ const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
326
+ if (!fmMatch) continue;
327
+ const fmBlock = fmMatch[1];
328
+ const content = fmMatch[2].trim();
329
+ const fm: Record<string, string> = {};
330
+ for (const line of fmBlock.split("\n")) {
331
+ const colonIdx = line.indexOf(":");
332
+ if (colonIdx === -1) continue;
333
+ fm[line.slice(0, colonIdx).trim()] = line
334
+ .slice(colonIdx + 1)
335
+ .trim();
336
+ }
337
+ const created = fm.created || "unknown";
338
+ const createdAt = parseFiniteDate(created);
339
+ if (
340
+ createdAt &&
341
+ formatDateInTimeZone(createdAt, timeZone) !== targetLocalDate
342
+ ) {
343
+ continue;
344
+ }
345
+ facts.push({
346
+ path: fullPath,
347
+ frontmatter: {
348
+ id: fm.id || path.basename(entry.name, ".md"),
349
+ category: (fm.category as any) || "fact",
350
+ created,
351
+ updated: fm.updated || created,
352
+ source: fm.source || "unknown",
353
+ confidence: parseFloat(fm.confidence || "0.8"),
354
+ confidenceTier: (fm.confidenceTier as any) || "implied",
355
+ tags: [],
356
+ },
357
+ content,
358
+ });
359
+ } catch {
360
+ // Skip unreadable files
361
+ }
362
+ }
363
+ } catch {
364
+ // Absent dir (ENOENT), symlinked/out-of-root dir, or containment
365
+ // violation — skip this category/date without aborting the summary.
366
+ }
367
+ }
368
+ }
369
+
370
+ // Sort facts by created timestamp (most recent last) so truncation keeps newest
371
+ facts.sort((a, b) => {
372
+ if (a.frontmatter.created === b.frontmatter.created) return 0;
373
+ return a.frontmatter.created < b.frontmatter.created ? -1 : 1;
374
+ });
375
+
376
+ // --- Read hourly summaries for the scanned dates ---
377
+ const hourlySummaries: string[] = [];
378
+ const hourlyBaseDir = path.join(storage.dir, "summaries", "hourly");
379
+ try {
380
+ const sessionKeys = await readdir(hourlyBaseDir, { withFileTypes: true });
381
+ for (const sk of sessionKeys) {
382
+ if (!sk.isDirectory()) continue;
383
+ for (const date of datesToScan) {
384
+ const summaryFile = path.join(hourlyBaseDir, sk.name, `${date}.md`);
385
+ try {
386
+ const raw = await readFile(summaryFile, "utf-8");
387
+ const filtered = filterHourlySummaryMarkdownForLocalDay(
388
+ raw,
389
+ date,
390
+ timeZone,
391
+ targetLocalDate,
392
+ );
393
+ if (filtered) {
394
+ hourlySummaries.push(filtered);
395
+ }
396
+ } catch {
397
+ // No summary file for this session/date
398
+ }
399
+ }
400
+ }
401
+ } catch {
402
+ // No hourly summaries directory
403
+ }
404
+
405
+ // --- Format and truncate ---
406
+ let formatted = formatDaySummaryMemories(facts);
407
+ if (hourlySummaries.length > 0) {
408
+ formatted +=
409
+ "\n\n---\n## Hourly Summaries\n\n" +
410
+ hourlySummaries.join("\n\n---\n\n");
411
+ }
412
+
413
+ // Truncate intelligently if over budget: drop oldest facts first
414
+ if (formatted.length > MAX_CHARS) {
415
+ // Re-build with fewer facts, keeping most recent
416
+ while (facts.length > 1 && formatted.length > MAX_CHARS) {
417
+ facts.shift(); // drop oldest
418
+ formatted = formatDaySummaryMemories(facts);
419
+ if (hourlySummaries.length > 0) {
420
+ formatted +=
421
+ "\n\n---\n## Hourly Summaries\n\n" +
422
+ hourlySummaries.join("\n\n---\n\n");
423
+ }
424
+ }
425
+ // If still over, hard truncate
426
+ if (formatted.length > MAX_CHARS) {
427
+ formatted = formatted.slice(0, MAX_CHARS);
428
+ }
429
+ }
430
+
431
+ log.info(
432
+ `gatherTodayFacts: collected ${facts.length} facts, ${hourlySummaries.length} hourly summaries for ${targetLocalDate} (${timeZone}, ${formatted.length} chars)`,
433
+ );
434
+
435
+ return formatted;
436
+ }
437
+
438
+ previewMemoryActionEvent(
439
+ event: Omit<MemoryActionEvent, "timestamp"> & { timestamp?: string },
440
+ ): MemoryActionEvent {
441
+ const namespace =
442
+ typeof event.namespace === "string" && event.namespace.length > 0
443
+ ? event.namespace
444
+ : this.deps.config.defaultNamespace;
445
+ const eligibility = parseMemoryActionEligibilityContext(
446
+ event.policyEligibility,
447
+ );
448
+ const policy = evaluateMemoryActionPolicy({
449
+ action: event.action,
450
+ eligibility,
451
+ options: {
452
+ actionsEnabled: resolveCompressionCapabilities(this.deps.config).contextCompressionActions,
453
+ maxCompressionTokensPerHour: this.deps.config.maxCompressionTokensPerHour,
454
+ },
455
+ });
456
+ const dryRun = event.dryRun === true;
457
+
458
+ const normalizedOutcome = dryRun
459
+ ? event.outcome === "failed"
460
+ ? "failed"
461
+ : "skipped"
462
+ : policy.decision === "allow"
463
+ ? event.outcome
464
+ : event.outcome === "failed"
465
+ ? "failed"
466
+ : "skipped";
467
+ const sourceSessionKey =
468
+ typeof event.sourceSessionKey === "string" &&
469
+ event.sourceSessionKey.length > 0
470
+ ? event.sourceSessionKey
471
+ : typeof event.sessionKey === "string" && event.sessionKey.length > 0
472
+ ? event.sessionKey
473
+ : undefined;
474
+ const outputMemoryIds = Array.isArray(event.outputMemoryIds)
475
+ ? Array.from(
476
+ new Set(
477
+ event.outputMemoryIds.filter(
478
+ (value): value is string =>
479
+ typeof value === "string" && value.length > 0,
480
+ ),
481
+ ),
482
+ )
483
+ : [];
484
+
485
+ const reasonParts = [
486
+ event.reason,
487
+ `policy:${policy.decision}`,
488
+ policy.rationale,
489
+ ].filter(
490
+ (part): part is string => typeof part === "string" && part.length > 0,
491
+ );
492
+
493
+ return {
494
+ ...event,
495
+ schemaVersion: event.schemaVersion ?? 1,
496
+ actionId:
497
+ typeof event.actionId === "string" && event.actionId.length > 0
498
+ ? event.actionId
499
+ : `memact-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
500
+ outcome: normalizedOutcome,
501
+ status:
502
+ event.status ??
503
+ (dryRun && policy.decision === "allow" && event.outcome !== "failed"
504
+ ? "validated"
505
+ : normalizedOutcome === "applied"
506
+ ? "applied"
507
+ : "rejected"),
508
+ actor:
509
+ typeof event.actor === "string" && event.actor.length > 0
510
+ ? event.actor
511
+ : "engram",
512
+ subsystem:
513
+ typeof event.subsystem === "string" && event.subsystem.length > 0
514
+ ? event.subsystem
515
+ : "memory_action",
516
+ reason: reasonParts.join(" | "),
517
+ namespace,
518
+ sessionKey: sourceSessionKey ?? event.sessionKey,
519
+ sourceSessionKey,
520
+ inputSummary:
521
+ typeof event.inputSummary === "string" && event.inputSummary.length > 0
522
+ ? event.inputSummary
523
+ : undefined,
524
+ outputMemoryIds,
525
+ dryRun,
526
+ policyVersion:
527
+ typeof event.policyVersion === "string" &&
528
+ event.policyVersion.length > 0
529
+ ? event.policyVersion
530
+ : "memory-action-policy.v1",
531
+ timestamp:
532
+ typeof event.timestamp === "string" && event.timestamp.length > 0
533
+ ? event.timestamp
534
+ : new Date().toISOString(),
535
+ policyDecision: policy.decision,
536
+ policyRationale: policy.rationale,
537
+ policyEligibility: eligibility,
538
+ };
539
+ }
540
+
541
+ /**
542
+ * Validate local LLM model availability and context window compatibility.
543
+ * Warns the user if there's a mismatch.
544
+ */
545
+ async validateLocalLlmModel(): Promise<void> {
546
+ log.debug("Local LLM: validating model configuration");
547
+ try {
548
+ const modelInfo = await this.deps.localLlm.getLoadedModelInfo();
549
+ if (!modelInfo) {
550
+ log.warn(
551
+ "Local LLM validation: Could not query model info from server",
552
+ );
553
+ log.warn(
554
+ "Local LLM validation: Could not query model info. " +
555
+ "Ensure LM Studio/Ollama is running with the model loaded.",
556
+ );
557
+ return;
558
+ }
559
+
560
+ // Check for context window mismatch
561
+ const configuredMaxContext = this.deps.config.localLlmMaxContext;
562
+
563
+ if (modelInfo.contextWindow) {
564
+ log.debug(
565
+ `Local LLM: ${modelInfo.id} loaded with ${modelInfo.contextWindow.toLocaleString()} token context window`,
566
+ );
567
+
568
+ if (
569
+ configuredMaxContext &&
570
+ configuredMaxContext > modelInfo.contextWindow
571
+ ) {
572
+ log.warn(
573
+ `Local LLM context mismatch: engram configured for ${configuredMaxContext.toLocaleString()} tokens, ` +
574
+ `but ${modelInfo.id} only supports ${modelInfo.contextWindow.toLocaleString()}. ` +
575
+ `Reducing to ${modelInfo.contextWindow.toLocaleString()} to avoid errors.`,
576
+ );
577
+ // Update the config in-memory to match actual capability
578
+ // (This is a temporary fix - user should update their config)
579
+ (this.deps.config as { localLlmMaxContext?: number }).localLlmMaxContext =
580
+ modelInfo.contextWindow;
581
+ }
582
+ } else {
583
+ log.debug(
584
+ `Local LLM: ${modelInfo.id} loaded (context window not reported by server)`,
585
+ );
586
+
587
+ if (!configuredMaxContext) {
588
+ log.warn(
589
+ "Local LLM: Server did not report context window. " +
590
+ "If you get 'context length exceeded' errors, set localLlmMaxContext in your config. " +
591
+ "Common defaults: LM Studio (32K), Ollama (2K-128K depending on model).",
592
+ );
593
+ }
594
+ }
595
+ } catch (err) {
596
+ log.warn(`Local LLM validation failed: ${err}`);
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Lazily-constructed wearables service (Limitless / Bee / Omi
602
+ * transcript ingestion). All wearables surfaces — CLI, MCP tools,
603
+ * HTTP routes — share this one instance so sync state, search, and
604
+ * memory writes stay consistent. Writes are pinned to the same
605
+ * deterministic namespace bulk-import uses.
606
+ */
607
+ getWearablesService(): WearablesService {
608
+ if (!this.deps.wearablesServiceInstance) {
609
+ this.deps.wearablesServiceInstance = new WearablesService({
610
+ config: this.deps.config.wearables,
611
+ getStorage: async () =>
612
+ await this.deps.getStorageForNamespace(this.deps.bulkImportWriteNamespace()),
613
+ extract: (turns) => this.deps.extraction.extract(turns),
614
+ // Smart memoryMode runs candidates through the SAME extraction
615
+ // judge (cache + defer counters included) the live extraction
616
+ // pipeline uses, so wearable facts get identical LLM-as-judge
617
+ // durability gating.
618
+ judgeFacts: (candidates) =>
619
+ judgeFactDurability(
620
+ candidates,
621
+ this.deps.config,
622
+ this.deps.localLlm,
623
+ new FallbackLlmClient(
624
+ this.deps.config.gatewayConfig,
625
+ fallbackLlmRuntimeContextFromConfig(this.deps.config),
626
+ ),
627
+ this.deps.judgeVerdictCache,
628
+ this.deps.judgeDeferCounts,
629
+ ),
630
+ searchBackend: {
631
+ search: async (query, maxResults) => {
632
+ if (!this.deps.qmd.isAvailable()) return null;
633
+ try {
634
+ const results = await this.deps.qmd.search(query, undefined, maxResults);
635
+ return results.map((result) => ({
636
+ path: result.path,
637
+ score: result.score,
638
+ preview: result.snippet,
639
+ }));
640
+ } catch {
641
+ // Backend hiccup → tell the service "unavailable" so it
642
+ // runs its bounded scan fallback instead of returning a
643
+ // silent empty result (CLAUDE.md rule 34).
644
+ return null;
645
+ }
646
+ },
647
+ },
648
+ reindexSearch: async () => {
649
+ await this.deps.qmd.update();
650
+ },
651
+ });
652
+ }
653
+ return this.deps.wearablesServiceInstance;
654
+ }
655
+
656
+ async autoConsolidateIdentity(): Promise<void> {
657
+ // Fan out over the catalog-union namespace set (issue #1499 sweep): a dynamic
658
+ // namespace that accumulated IDENTITY.md reflections must also be eligible for
659
+ // auto-consolidation, otherwise its identity file grows unbounded and is never
660
+ // consolidated. Falls back to the configured set on any catalog read failure.
661
+ const namespaces = resolveNamespaceCapabilities(this.deps.config).namespaces
662
+ ? await this.deps.maintenanceNamespaces()
663
+ : [this.deps.config.defaultNamespace];
664
+
665
+ for (const namespace of namespaces) {
666
+ const storage = await this.deps.storageRouter.storageFor(namespace);
667
+ const identityNamespace =
668
+ resolveNamespaceCapabilities(this.deps.config).namespaces &&
669
+ namespace !== this.deps.config.defaultNamespace
670
+ ? namespace
671
+ : undefined;
672
+ const reflectionsContent =
673
+ (await storage.readIdentityReflections()) ?? "";
674
+
675
+ const existingIdentity = await storage.readIdentity(
676
+ this.deps.config.workspaceDir,
677
+ identityNamespace,
678
+ );
679
+ const headerEnd =
680
+ existingIdentity.indexOf("## Learned Patterns") !== -1
681
+ ? existingIdentity.indexOf("## Learned Patterns")
682
+ : existingIdentity.indexOf("## Reflection");
683
+ const staticHeader =
684
+ (headerEnd !== -1
685
+ ? existingIdentity.slice(0, headerEnd)
686
+ : existingIdentity
687
+ ).trimEnd() || "# IDENTITY";
688
+ const identityContent = `${staticHeader}\n\n${reflectionsContent.trim()}\n`;
689
+ if (identityContent.length < Orchestrator.IDENTITY_CONSOLIDATE_THRESHOLD)
690
+ continue;
691
+
692
+ log.info(
693
+ `IDENTITY(${namespace}) is ${identityContent.length} chars — auto-consolidating reflections`,
694
+ );
695
+ const result = await this.deps.extraction.consolidateIdentity(
696
+ identityContent,
697
+ "## Reflection",
698
+ );
699
+
700
+ if (!result || result.learnedPatterns.length === 0) {
701
+ log.warn(
702
+ `identity consolidation produced no patterns for namespace=${namespace}`,
703
+ );
704
+ continue;
705
+ }
706
+
707
+ const patternsSection = [
708
+ "## Learned Patterns (consolidated from reflections, " +
709
+ new Date().toISOString().slice(0, 10) +
710
+ ")",
711
+ "",
712
+ ...result.learnedPatterns.map((p) => `- ${p}`),
713
+ "",
714
+ ].join("\n");
715
+
716
+ const newContent = staticHeader + "\n\n" + patternsSection + "\n";
717
+
718
+ await storage.writeIdentity(
719
+ this.deps.config.workspaceDir,
720
+ newContent,
721
+ identityNamespace,
722
+ );
723
+ await storage.writeIdentityReflections("");
724
+ // NRcCL (codex P2): record a per-namespace catalog write for THIS namespace
725
+ // after the identity files are updated. This fan-out can mutate a dynamic
726
+ // namespace via `writeIdentity`/`writeIdentityReflections`, but the
727
+ // consolidation pass's only consolidated touch covers `this.deps.storage` (the
728
+ // default) and only fires when `memoryItemMutated` was set by OTHER work — so
729
+ // a namespace whose sole mutation in the pass is identity consolidation would
730
+ // otherwise keep a stale `lastWriteAt`, making `listNamespaces({ writtenSince })`
731
+ // and catalog-recency consumers miss the write. Best-effort and
732
+ // failure-tolerant (the storage chokepoint (#1522) swallows errors, never crashing the
733
+ // consolidation; gotcha #13, rule #40). No double-count with the consolidated
734
+ // touch above: that one is gated on `memoryItemMutated` (which identity
735
+ // consolidation does not set), and `markWrite` is idempotent regardless.
736
+ log.info(
737
+ `IDENTITY(${namespace}) consolidated: ${identityContent.length} → ${newContent.length} chars, ${result.learnedPatterns.length} patterns`,
738
+ );
739
+ }
740
+ }
741
+
742
+ /**
743
+ * Flush access tracking buffer to disk.
744
+ * Called during consolidation or when buffer is full.
745
+ */
746
+ async flushAccessTracking(): Promise<void> {
747
+ if (this.deps.accessTrackingBuffer.size === 0) return;
748
+
749
+ // Build entries from buffer, merging with existing counts
750
+ const entries: AccessTrackingEntry[] = [];
751
+ const namespaces = resolveNamespaceCapabilities(this.deps.config).namespaces
752
+ ? Array.from(
753
+ new Set<string>([
754
+ this.deps.config.defaultNamespace,
755
+ this.deps.config.sharedNamespace,
756
+ ...this.deps.config.namespacePolicies.map((p) => p.name),
757
+ ]),
758
+ )
759
+ : [this.deps.config.defaultNamespace];
760
+ const memories = await this.deps.readAllMemoriesForNamespaces(namespaces);
761
+ const memoryMap = new Map(memories.map((m) => [m.frontmatter.id, m]));
762
+
763
+ for (const [memoryId, update] of this.deps.accessTrackingBuffer) {
764
+ const memory = memoryMap.get(memoryId);
765
+ const existingCount = memory?.frontmatter.accessCount ?? 0;
766
+ entries.push({
767
+ memoryId,
768
+ newCount: existingCount + update.count,
769
+ lastAccessed: update.lastAccessed,
770
+ });
771
+ }
772
+
773
+ const byNamespace = new Map<string, AccessTrackingEntry[]>();
774
+ for (const e of entries) {
775
+ const m = memoryMap.get(e.memoryId);
776
+ if (!m) continue;
777
+ const ns = this.deps.namespaceFromPath(m.path);
778
+ const list = byNamespace.get(ns) ?? [];
779
+ list.push(e);
780
+ byNamespace.set(ns, list);
781
+ }
782
+ for (const [ns, list] of byNamespace) {
783
+ const sm = await this.deps.storageRouter.storageFor(ns);
784
+ await sm.flushAccessTracking(list);
785
+ }
786
+ this.deps.accessTrackingBuffer.clear();
787
+ log.debug(`flushed ${entries.length} access tracking entries`);
788
+ }
789
+ }