@remnic/core 9.15.3 → 9.16.0

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 (311) hide show
  1. package/dist/access-admin-ops-surface.d.ts +8 -8
  2. package/dist/access-admin-ops-surface.js +7 -7
  3. package/dist/access-boundary.d.ts +8 -8
  4. package/dist/access-boundary.js +7 -7
  5. package/dist/access-cli.js +31 -30
  6. package/dist/access-cli.js.map +1 -1
  7. package/dist/access-http.d.ts +8 -8
  8. package/dist/access-http.js +11 -11
  9. package/dist/access-identity-continuity-surface.d.ts +6 -6
  10. package/dist/access-identity-continuity-surface.js +7 -7
  11. package/dist/access-lcm-surface.d.ts +8 -8
  12. package/dist/access-lcm-surface.js +7 -7
  13. package/dist/access-mcp.d.ts +8 -8
  14. package/dist/access-mcp.js +10 -10
  15. package/dist/access-memory-search-fanout.d.ts +2 -2
  16. package/dist/{access-namespace-preflight-B9e5GnyZ.d.ts → access-namespace-preflight-DwhEFYy3.d.ts} +1 -1
  17. package/dist/access-namespace-preflight.d.ts +3 -3
  18. package/dist/access-namespace-preflight.js +7 -7
  19. package/dist/access-observe-write-surface.d.ts +8 -8
  20. package/dist/access-observe-write-surface.js +7 -7
  21. package/dist/access-operations-batch.js +8 -8
  22. package/dist/access-operations.d.ts +8 -8
  23. package/dist/access-operations.js +9 -9
  24. package/dist/access-recall-concurrency.d.ts +8 -8
  25. package/dist/access-recall-concurrency.js +7 -7
  26. package/dist/access-recall-response.d.ts +8 -8
  27. package/dist/access-recall-response.js +7 -7
  28. package/dist/access-recall-surface.d.ts +8 -8
  29. package/dist/access-recall-surface.js +7 -7
  30. package/dist/{access-service-DgmJd_A-.d.ts → access-service-CSq1Wp2Z.d.ts} +6 -6
  31. package/dist/access-service.d.ts +8 -8
  32. package/dist/access-service.js +7 -7
  33. package/dist/access-surface-catalog.d.ts +8 -8
  34. package/dist/action-confidence.d.ts +1 -1
  35. package/dist/active-memory-bridge.d.ts +1 -1
  36. package/dist/active-recall.d.ts +1 -1
  37. package/dist/active-recall.js +2 -1
  38. package/dist/active-recall.js.map +1 -1
  39. package/dist/behavior-learner.d.ts +1 -1
  40. package/dist/behavior-signals.d.ts +1 -1
  41. package/dist/bootstrap.d.ts +6 -6
  42. package/dist/briefing.d.ts +2 -2
  43. package/dist/briefing.js +3 -3
  44. package/dist/buffer-surprise-report.d.ts +1 -1
  45. package/dist/buffer.d.ts +2 -2
  46. package/dist/bulk-import/index.d.ts +3 -3
  47. package/dist/calibration.d.ts +1 -1
  48. package/dist/capabilities.d.ts +1 -1
  49. package/dist/{catalog-g_dfT6oL.d.ts → catalog-B9uga4sG.d.ts} +1 -1
  50. package/dist/causal-behavior.d.ts +1 -1
  51. package/dist/causal-consolidation.d.ts +1 -1
  52. package/dist/causal-consolidation.js +4 -4
  53. package/dist/causal-trajectory-graph.d.ts +1 -1
  54. package/dist/{chunk-IRX5UCGN.js → chunk-2OH3ZRYH.js} +3 -3
  55. package/dist/{chunk-YXTSUIXC.js → chunk-5EKOBRCA.js} +6 -1
  56. package/dist/chunk-5EKOBRCA.js.map +1 -0
  57. package/dist/{chunk-42DJZZUB.js → chunk-7LXSPIVG.js} +2 -2
  58. package/dist/{chunk-47WFEKT7.js → chunk-7PK4W4RN.js} +2 -2
  59. package/dist/{chunk-O7ACJAHR.js → chunk-7YJLCNPF.js} +2 -2
  60. package/dist/{chunk-6EJL2O3G.js → chunk-AFH7PIFU.js} +2 -2
  61. package/dist/{chunk-JN4N2TMQ.js → chunk-ANGUOE2N.js} +7 -7
  62. package/dist/{chunk-GQOKO77V.js → chunk-AWT7SCWQ.js} +2 -2
  63. package/dist/{chunk-PXJEL55X.js → chunk-BIRPR5N7.js} +2 -2
  64. package/dist/{chunk-NAY25BGS.js → chunk-BTK2PIFL.js} +6 -6
  65. package/dist/{chunk-SYCVU556.js → chunk-C2RMQRPN.js} +3 -3
  66. package/dist/{chunk-T4EAQPQQ.js → chunk-G2WCVFDC.js} +2 -2
  67. package/dist/{chunk-UJIBXOJA.js → chunk-GEDBTSYU.js} +5 -5
  68. package/dist/{chunk-CXFVZ3PK.js → chunk-GRJ5UOFO.js} +84 -658
  69. package/dist/chunk-GRJ5UOFO.js.map +1 -0
  70. package/dist/chunk-GSQASASV.js +429 -0
  71. package/dist/chunk-GSQASASV.js.map +1 -0
  72. package/dist/{chunk-WIKOHCQZ.js → chunk-I6ODVWQB.js} +2 -2
  73. package/dist/{chunk-H4MDUPER.js → chunk-IZHXSLO7.js} +2 -2
  74. package/dist/{chunk-RRS5CDB6.js → chunk-JOCXI2O5.js} +2 -2
  75. package/dist/{chunk-EEUZQQRJ.js → chunk-KGKYTCDO.js} +2 -2
  76. package/dist/{chunk-JTW4TGPV.js → chunk-L3HAUO7K.js} +2 -2
  77. package/dist/{chunk-P53HGVJS.js → chunk-LMVAIZR5.js} +2 -2
  78. package/dist/{chunk-KLXG4BBO.js → chunk-M6TZQDTX.js} +2 -2
  79. package/dist/{chunk-FJ5IQ3SI.js → chunk-N7DK67GZ.js} +1 -1
  80. package/dist/chunk-N7DK67GZ.js.map +1 -0
  81. package/dist/{chunk-U6QWUFSM.js → chunk-OXT5TQDS.js} +2 -2
  82. package/dist/{chunk-T4OTDMX4.js → chunk-QDTEH6BS.js} +2 -2
  83. package/dist/{chunk-5MDE5MIC.js → chunk-SESF64QR.js} +2 -2
  84. package/dist/{chunk-4E5H4FPC.js → chunk-TUNI4GB3.js} +3 -3
  85. package/dist/{chunk-XJT6LNLY.js → chunk-V6LYAXGW.js} +834 -25
  86. package/dist/chunk-V6LYAXGW.js.map +1 -0
  87. package/dist/{chunk-5HFIHWOF.js → chunk-VLIY55Q5.js} +17 -17
  88. package/dist/{chunk-BNH2VVWP.js → chunk-WIS5JTJR.js} +2 -2
  89. package/dist/{chunk-OOGGUXAG.js → chunk-WQ2I6ODB.js} +2 -2
  90. package/dist/{cli-BXCd2cgK.d.ts → cli-DYLGXxuu.d.ts} +5 -5
  91. package/dist/cli.d.ts +10 -10
  92. package/dist/cli.js +25 -24
  93. package/dist/compounding/engine.d.ts +2 -2
  94. package/dist/compounding/engine.js +3 -3
  95. package/dist/compounding/preference-consolidator.d.ts +1 -1
  96. package/dist/compression-optimizer.d.ts +1 -1
  97. package/dist/config.d.ts +1 -1
  98. package/dist/config.js +2 -1
  99. package/dist/connectors/codex-materialize-runner.d.ts +1 -1
  100. package/dist/connectors/codex-materialize-runner.js +3 -3
  101. package/dist/connectors/codex-materialize.d.ts +1 -1
  102. package/dist/connectors/index.d.ts +1 -1
  103. package/dist/connectors/index.js +3 -3
  104. package/dist/consolidation-provenance-check.d.ts +2 -2
  105. package/dist/consolidation-undo.d.ts +2 -2
  106. package/dist/contradiction/index.d.ts +2 -2
  107. package/dist/conversation-index/backend.d.ts +1 -1
  108. package/dist/conversation-index/chunker.d.ts +1 -1
  109. package/dist/conversation-index/faiss-adapter.d.ts +1 -1
  110. package/dist/conversation-index/indexer.d.ts +1 -1
  111. package/dist/conversation-index/search.d.ts +1 -1
  112. package/dist/day-summary.d.ts +1 -1
  113. package/dist/delinearize.d.ts +1 -1
  114. package/dist/direct-answer-wiring.d.ts +1 -1
  115. package/dist/direct-answer.d.ts +1 -1
  116. package/dist/embedding-fallback.d.ts +1 -1
  117. package/dist/enrichment/index.d.ts +1 -1
  118. package/dist/entity-retrieval.d.ts +2 -2
  119. package/dist/entity-retrieval.js +3 -3
  120. package/dist/entity-schema.d.ts +1 -1
  121. package/dist/explicit-capture.d.ts +6 -6
  122. package/dist/extraction-faithfulness.d.ts +1 -1
  123. package/dist/extraction-judge-telemetry.d.ts +1 -1
  124. package/dist/extraction-judge-training.d.ts +1 -1
  125. package/dist/extraction-judge.d.ts +1 -1
  126. package/dist/extraction.d.ts +1 -1
  127. package/dist/fallback-llm.d.ts +1 -1
  128. package/dist/graph-dashboard-diff.d.ts +1 -1
  129. package/dist/graph-dashboard-key.d.ts +1 -1
  130. package/dist/graph-dashboard-parser.d.ts +1 -1
  131. package/dist/graph-edge-reinforcement.d.ts +1 -1
  132. package/dist/graph-snapshot.d.ts +1 -1
  133. package/dist/graph.d.ts +1 -1
  134. package/dist/importance.d.ts +1 -1
  135. package/dist/importers/index.d.ts +1 -1
  136. package/dist/in-flight-reads.d.ts +1 -1
  137. package/dist/index.d.ts +289 -99
  138. package/dist/index.js +70 -45
  139. package/dist/intent.d.ts +1 -1
  140. package/dist/lcm/engine.d.ts +1 -1
  141. package/dist/lcm/index.d.ts +1 -1
  142. package/dist/lcm/tools.d.ts +1 -1
  143. package/dist/lifecycle.d.ts +1 -1
  144. package/dist/live-connectors-runner.d.ts +1 -1
  145. package/dist/local-llm.d.ts +1 -1
  146. package/dist/local-model-endpoint.d.ts +1 -1
  147. package/dist/maintenance/memory-governance.d.ts +1 -1
  148. package/dist/maintenance/memory-governance.js +3 -3
  149. package/dist/maintenance/rebuild-memory-lifecycle-ledger.d.ts +2 -2
  150. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -3
  151. package/dist/maintenance/rebuild-memory-projection.js +4 -4
  152. package/dist/mcp-memory-inspector-app.d.ts +8 -8
  153. package/dist/memory-action-policy.d.ts +1 -1
  154. package/dist/memory-cache.d.ts +1 -1
  155. package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
  156. package/dist/memory-projection-store.d.ts +1 -1
  157. package/dist/memory-provenance.d.ts +1 -1
  158. package/dist/memory-worth-outcomes.d.ts +2 -2
  159. package/dist/models-json.d.ts +1 -1
  160. package/dist/namespaces/migrate.d.ts +3 -3
  161. package/dist/namespaces/migrate.js +4 -4
  162. package/dist/namespaces/principal.d.ts +1 -1
  163. package/dist/namespaces/search.d.ts +1 -1
  164. package/dist/namespaces/storage.d.ts +3 -3
  165. package/dist/namespaces/storage.js +3 -3
  166. package/dist/native-knowledge.d.ts +1 -1
  167. package/dist/offline-sync-impression-drain.d.ts +1 -1
  168. package/dist/operator-toolkit.d.ts +2 -2
  169. package/dist/operator-toolkit.js +9 -8
  170. package/dist/orchestration/compression-guideline-coordinator.d.ts +2 -2
  171. package/dist/orchestration/maintenance.d.ts +10 -7
  172. package/dist/orchestration/maintenance.js +7 -6
  173. package/dist/{orchestrator-BHfWtvEJ.d.ts → orchestrator-BHx-DWCa.d.ts} +7 -5
  174. package/dist/orchestrator.d.ts +6 -6
  175. package/dist/orchestrator.js +31 -30
  176. package/dist/patterns-cli.d.ts +1 -1
  177. package/dist/{pipeline-CBYcnmDC.d.ts → pipeline-DDHSE876.d.ts} +1 -1
  178. package/dist/policy-runtime.d.ts +1 -1
  179. package/dist/proactive-contention.d.ts +1 -1
  180. package/dist/provenance.d.ts +1 -1
  181. package/dist/{qmd-BIq-jrV4.d.ts → qmd-C4WVh6BM.d.ts} +1 -1
  182. package/dist/qmd-preflight.d.ts +2 -2
  183. package/dist/qmd-recall-cache.d.ts +1 -1
  184. package/dist/qmd.d.ts +2 -2
  185. package/dist/recall-concurrency-config.d.ts +1 -1
  186. package/dist/recall-disclosure-escalation.d.ts +1 -1
  187. package/dist/recall-explain-renderer.d.ts +1 -1
  188. package/dist/recall-explain-renderer.js +3 -3
  189. package/dist/recall-memory-map.d.ts +1 -1
  190. package/dist/recall-planner-llm.d.ts +1 -1
  191. package/dist/recall-state.d.ts +1 -1
  192. package/dist/recall-tag-filter.d.ts +1 -1
  193. package/dist/recall-timings.d.ts +1 -1
  194. package/dist/recall-xray-cli.d.ts +1 -1
  195. package/dist/recall-xray-cli.js +4 -4
  196. package/dist/recall-xray-renderer.d.ts +1 -1
  197. package/dist/recall-xray-renderer.js +3 -3
  198. package/dist/recall-xray.d.ts +1 -1
  199. package/dist/recall-xray.js +2 -2
  200. package/dist/replay/normalizers/chatgpt.d.ts +1 -1
  201. package/dist/replay/normalizers/claude.d.ts +1 -1
  202. package/dist/replay/normalizers/openclaw.d.ts +1 -1
  203. package/dist/replay/normalizers/shared.d.ts +1 -1
  204. package/dist/replay/runner.d.ts +1 -1
  205. package/dist/replay/types.d.ts +1 -1
  206. package/dist/resolve-auth-token.d.ts +1 -1
  207. package/dist/resume-bundles.js +3 -2
  208. package/dist/retrieval-agents.d.ts +2 -2
  209. package/dist/retrieval-tiers.d.ts +1 -1
  210. package/dist/routing/engine.d.ts +1 -1
  211. package/dist/routing/store.d.ts +1 -1
  212. package/dist/salvage-envelope.d.ts +1 -1
  213. package/dist/{scope-profiles-DDno-uvy.d.ts → scope-profiles-EqMXwzCz.d.ts} +1 -1
  214. package/dist/search/embed-helper.d.ts +1 -1
  215. package/dist/search/factory.d.ts +1 -1
  216. package/dist/search/index.d.ts +1 -1
  217. package/dist/search/lancedb-backend.d.ts +1 -1
  218. package/dist/search/meilisearch-backend.d.ts +1 -1
  219. package/dist/search/noop-backend.d.ts +1 -1
  220. package/dist/search/orama-backend.d.ts +1 -1
  221. package/dist/search/port.d.ts +1 -1
  222. package/dist/search/remote-backend.d.ts +1 -1
  223. package/dist/{semantic-consolidation-7RIJFDwY.d.ts → semantic-consolidation-CYpvwmQc.d.ts} +1 -1
  224. package/dist/semantic-consolidation.d.ts +2 -2
  225. package/dist/semantic-consolidation.js +4 -4
  226. package/dist/semantic-rule-promotion.js +3 -3
  227. package/dist/semantic-rule-verifier.d.ts +1 -1
  228. package/dist/semantic-rule-verifier.js +3 -3
  229. package/dist/session-observer-bands.d.ts +1 -1
  230. package/dist/session-observer-state.d.ts +1 -1
  231. package/dist/shared-context/manager.d.ts +1 -1
  232. package/dist/signal.d.ts +1 -1
  233. package/dist/{storage-DkphZiOJ.d.ts → storage-Dxd7zVZH.d.ts} +1 -1
  234. package/dist/storage.d.ts +2 -2
  235. package/dist/storage.js +2 -2
  236. package/dist/summarizer.d.ts +1 -1
  237. package/dist/summary-snapshot.d.ts +1 -1
  238. package/dist/temporal-supersession.d.ts +2 -2
  239. package/dist/temporal-timeline-recall.d.ts +1 -1
  240. package/dist/temporal-validity.d.ts +1 -1
  241. package/dist/threading.d.ts +1 -1
  242. package/dist/tier-migration.d.ts +2 -2
  243. package/dist/tier-routing.d.ts +1 -1
  244. package/dist/topics.d.ts +1 -1
  245. package/dist/transcript.d.ts +1 -1
  246. package/dist/trust-score-stage.d.ts +1 -1
  247. package/dist/trust-score.d.ts +1 -1
  248. package/dist/{types-2q_MRFcM.d.ts → types-OUCJALBK.d.ts} +98 -1
  249. package/dist/types.d.ts +1 -1
  250. package/dist/types.js +1 -1
  251. package/dist/utility-runtime.d.ts +1 -1
  252. package/dist/verified-recall.js +3 -3
  253. package/dist/write-envelope.d.ts +1 -1
  254. package/package.json +2 -2
  255. package/src/activity/config.test.ts +113 -0
  256. package/src/activity/config.ts +111 -0
  257. package/src/activity/digest.ts +2 -2
  258. package/src/activity/index.ts +6 -0
  259. package/src/activity/pipeline.test.ts +379 -0
  260. package/src/activity/pipeline.ts +223 -0
  261. package/src/activity/reindex.test.ts +97 -0
  262. package/src/activity/reindex.ts +40 -0
  263. package/src/activity/runner.test.ts +452 -0
  264. package/src/activity/runner.ts +237 -0
  265. package/src/activity/scheduler.test.ts +303 -0
  266. package/src/activity/scheduler.ts +147 -0
  267. package/src/activity/source-client.test.ts +208 -0
  268. package/src/activity/source-client.ts +159 -0
  269. package/src/activity/sync-registration.test.ts +134 -0
  270. package/src/activity/sync-registration.ts +139 -0
  271. package/src/activity/types.ts +17 -0
  272. package/src/config.test.ts +19 -0
  273. package/src/config.ts +3 -0
  274. package/src/orchestration/maintenance.test.ts +42 -42
  275. package/src/orchestration/maintenance.ts +37 -5
  276. package/src/orchestration/orchestrator-helpers.ts +36 -1
  277. package/src/orchestration/recall-internal.ts +6 -6
  278. package/src/orchestration/recall-search-pipeline.ts +15 -7
  279. package/src/orchestrator-background-writes.test.ts +2 -2
  280. package/src/orchestrator.ts +3 -3
  281. package/src/types.ts +4 -1
  282. package/dist/chunk-CXFVZ3PK.js.map +0 -1
  283. package/dist/chunk-FJ5IQ3SI.js.map +0 -1
  284. package/dist/chunk-XJT6LNLY.js.map +0 -1
  285. package/dist/chunk-YXTSUIXC.js.map +0 -1
  286. /package/dist/{chunk-IRX5UCGN.js.map → chunk-2OH3ZRYH.js.map} +0 -0
  287. /package/dist/{chunk-42DJZZUB.js.map → chunk-7LXSPIVG.js.map} +0 -0
  288. /package/dist/{chunk-47WFEKT7.js.map → chunk-7PK4W4RN.js.map} +0 -0
  289. /package/dist/{chunk-O7ACJAHR.js.map → chunk-7YJLCNPF.js.map} +0 -0
  290. /package/dist/{chunk-6EJL2O3G.js.map → chunk-AFH7PIFU.js.map} +0 -0
  291. /package/dist/{chunk-JN4N2TMQ.js.map → chunk-ANGUOE2N.js.map} +0 -0
  292. /package/dist/{chunk-GQOKO77V.js.map → chunk-AWT7SCWQ.js.map} +0 -0
  293. /package/dist/{chunk-PXJEL55X.js.map → chunk-BIRPR5N7.js.map} +0 -0
  294. /package/dist/{chunk-NAY25BGS.js.map → chunk-BTK2PIFL.js.map} +0 -0
  295. /package/dist/{chunk-SYCVU556.js.map → chunk-C2RMQRPN.js.map} +0 -0
  296. /package/dist/{chunk-T4EAQPQQ.js.map → chunk-G2WCVFDC.js.map} +0 -0
  297. /package/dist/{chunk-UJIBXOJA.js.map → chunk-GEDBTSYU.js.map} +0 -0
  298. /package/dist/{chunk-WIKOHCQZ.js.map → chunk-I6ODVWQB.js.map} +0 -0
  299. /package/dist/{chunk-H4MDUPER.js.map → chunk-IZHXSLO7.js.map} +0 -0
  300. /package/dist/{chunk-RRS5CDB6.js.map → chunk-JOCXI2O5.js.map} +0 -0
  301. /package/dist/{chunk-EEUZQQRJ.js.map → chunk-KGKYTCDO.js.map} +0 -0
  302. /package/dist/{chunk-JTW4TGPV.js.map → chunk-L3HAUO7K.js.map} +0 -0
  303. /package/dist/{chunk-P53HGVJS.js.map → chunk-LMVAIZR5.js.map} +0 -0
  304. /package/dist/{chunk-KLXG4BBO.js.map → chunk-M6TZQDTX.js.map} +0 -0
  305. /package/dist/{chunk-U6QWUFSM.js.map → chunk-OXT5TQDS.js.map} +0 -0
  306. /package/dist/{chunk-T4OTDMX4.js.map → chunk-QDTEH6BS.js.map} +0 -0
  307. /package/dist/{chunk-5MDE5MIC.js.map → chunk-SESF64QR.js.map} +0 -0
  308. /package/dist/{chunk-4E5H4FPC.js.map → chunk-TUNI4GB3.js.map} +0 -0
  309. /package/dist/{chunk-5HFIHWOF.js.map → chunk-VLIY55Q5.js.map} +0 -0
  310. /package/dist/{chunk-BNH2VVWP.js.map → chunk-WIS5JTJR.js.map} +0 -0
  311. /package/dist/{chunk-OOGGUXAG.js.map → chunk-WQ2I6ODB.js.map} +0 -0
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Host-agnostic activity sync runner (issue #1900).
3
+ *
4
+ * Bridges parsed `config.activity` to the durable sync pipeline: the parser
5
+ * (config.ts) validates settings but never contacts a daemon, and
6
+ * `syncActivitySource` (pipeline.ts) syncs one source for one day. This runner
7
+ * is the missing middle — it instantiates one `ActivityHttpSourceClient` per
8
+ * configured source from the parsed config and drives a sync across the
9
+ * `syncDays` local-day window, but ONLY when the feature is enabled.
10
+ *
11
+ * The feature is master default-off: source config alone never starts a sync.
12
+ * When `config.enabled` is false the runner opens no store, builds no client,
13
+ * and makes no HTTP call or write — it returns a disabled summary. This mirrors
14
+ * `runLiveConnectorsOnce`'s disabled-vs-result reporting so a host scheduler
15
+ * (CLI, cron) can trigger it through the runtime with no OpenClaw imports.
16
+ *
17
+ * Cursor semantics are delegated wholesale to `syncActivitySource`: the
18
+ * per-machine cursor advances only after every page and the digest are durable,
19
+ * so a runtime failure (or an aborted stop) leaves the cursor unadvanced and a
20
+ * restart resumes from the persisted position. A per-source failure is captured
21
+ * as an error item and never aborts the other sources or the host flow.
22
+ */
23
+
24
+ import { ActivityStore } from "./store.js";
25
+ import { syncActivitySource } from "./pipeline.js";
26
+ import { ActivityHttpSourceClient } from "./source-client.js";
27
+ import { displayErrorDetail } from "../runtime/better-sqlite.js";
28
+ import type { ActivityConfig, ActivitySourceClient, ActivitySourceConfig } from "./types.js";
29
+
30
+ type ActivityNow = Date | (() => Date);
31
+
32
+ /** One configured source's aggregate result across the synced day window. */
33
+ export interface ActivitySourceRunItem {
34
+ machineLabel: string;
35
+ /** True when at least one day synced durably (no error). */
36
+ ran: boolean;
37
+ fetched: number;
38
+ inserted: number;
39
+ duplicates: number;
40
+ digestsWritten: number;
41
+ /** Persisted cursor after the run; unadvanced from prior on failure. */
42
+ cursor: string | null;
43
+ /** Set when the source failed; distinguishes a fault from an empty page. */
44
+ error?: string;
45
+ /**
46
+ * Set when a durable day synced but its post-digest search reindex failed.
47
+ * The rows/digest/cursor are committed; the index is stale until the next
48
+ * refresh, so this is a signal (not a sync failure) for the scheduler.
49
+ */
50
+ reindexError?: string;
51
+ }
52
+
53
+ export interface ActivitySyncRunSummary {
54
+ ranAt: string;
55
+ /** False means the feature is off: no client built, no HTTP, no write. */
56
+ enabled: boolean;
57
+ ranCount: number;
58
+ errorCount: number;
59
+ /** Sources whose durable sync succeeded but whose search reindex failed. */
60
+ reindexErrorCount: number;
61
+ totalInserted: number;
62
+ results: ActivitySourceRunItem[];
63
+ }
64
+
65
+ export interface ActivitySyncRunOptions {
66
+ config: ActivityConfig;
67
+ memoryDir: string;
68
+ /** Injectable store; opened from memoryDir when omitted (and closed here). */
69
+ store?: ActivityStore;
70
+ /** Clock injection for deterministic day-window resolution in tests. */
71
+ now?: ActivityNow;
72
+ /** Abort a run in flight (stop); an aborted source leaves its cursor intact. */
73
+ signal?: AbortSignal;
74
+ /** Injectable client factory; builds an ActivityHttpSourceClient by default. */
75
+ createSourceClient?: (source: ActivitySourceConfig) => ActivitySourceClient;
76
+ /**
77
+ * Search-index refresh forwarded to each source sync as `afterWrites`, run
78
+ * after a digest is (re)written so it becomes discoverable (rule 31).
79
+ * Host wires the core index seam (SearchBackend.update); best-effort.
80
+ */
81
+ reindexSearch?: (signal?: AbortSignal) => Promise<void>;
82
+ }
83
+
84
+ /** Format a Date as YYYY-MM-DD in an IANA timezone (local calendar day). */
85
+ function localDateInTimezone(instant: Date, timezone: string): string {
86
+ try {
87
+ const parts = new Intl.DateTimeFormat("en-CA", {
88
+ timeZone: timezone,
89
+ year: "numeric",
90
+ month: "2-digit",
91
+ day: "2-digit",
92
+ }).formatToParts(instant);
93
+ const get = (type: string): string => parts.find((part) => part.type === type)?.value ?? "";
94
+ return `${get("year")}-${get("month")}-${get("day")}`;
95
+ } catch {
96
+ return instant.toISOString().slice(0, 10);
97
+ }
98
+ }
99
+
100
+ /** Previous calendar date via pure date arithmetic (no DST wall-clock drift). */
101
+ function previousIsoDate(date: string): string {
102
+ const parsed = new Date(`${date}T00:00:00Z`);
103
+ parsed.setUTCDate(parsed.getUTCDate() - 1);
104
+ return parsed.toISOString().slice(0, 10);
105
+ }
106
+
107
+ /**
108
+ * The local-day window to sync, oldest first: today back `syncDays` calendar
109
+ * days in the configured timezone. Walking by calendar days (not fixed 24h
110
+ * intervals) keeps the window correct across DST transitions.
111
+ */
112
+ export function resolveActivitySyncDates(syncDays: number, timezone: string, now: Date): string[] {
113
+ const dates: string[] = [];
114
+ let cursor = localDateInTimezone(now, timezone);
115
+ for (let count = 0; count < syncDays; count++) {
116
+ dates.unshift(cursor);
117
+ cursor = previousIsoDate(cursor);
118
+ }
119
+ return dates;
120
+ }
121
+
122
+ /**
123
+ * Operator-facing detail for a per-source/per-day sync fault. Our own
124
+ * controlled messages (activity.../Invalid activity...) are informative and
125
+ * safe to surface; opaque network/runtime errors can embed hostnames or paths,
126
+ * so they are reduced to a sanitized name+code (matches ActivityHttpSourceClient.verify).
127
+ */
128
+ function sanitizedSyncError(error: unknown): string {
129
+ return error instanceof Error && /^(activity|Invalid activity)/.test(error.message)
130
+ ? error.message
131
+ : displayErrorDetail(error) || "activity sync failed";
132
+ }
133
+
134
+ /**
135
+ * Run one activity sync pass across every configured source. Disabled config
136
+ * short-circuits before any store/client/HTTP work; an enabled config syncs
137
+ * each source across the `syncDays` window and isolates per-source faults.
138
+ */
139
+ export async function runActivitySyncOnce(options: ActivitySyncRunOptions): Promise<ActivitySyncRunSummary> {
140
+ const ranAt = typeof options.now === "function" ? options.now() : options.now ?? new Date();
141
+
142
+ if (!options.config.enabled) {
143
+ return {
144
+ ranAt: ranAt.toISOString(),
145
+ enabled: false,
146
+ ranCount: 0,
147
+ errorCount: 0,
148
+ reindexErrorCount: 0,
149
+ totalInserted: 0,
150
+ results: [],
151
+ };
152
+ }
153
+
154
+ const dates = resolveActivitySyncDates(options.config.syncDays, options.config.timezone, ranAt);
155
+ const createSourceClient =
156
+ options.createSourceClient ??
157
+ ((source: ActivitySourceConfig): ActivitySourceClient =>
158
+ new ActivityHttpSourceClient({
159
+ machineLabel: source.machineLabel,
160
+ baseUrl: source.baseUrl,
161
+ ...(source.token === undefined ? {} : { token: source.token }),
162
+ }));
163
+ const ownStore = options.store === undefined;
164
+ const store = options.store ?? ActivityStore.open(options.memoryDir);
165
+ const results: ActivitySourceRunItem[] = [];
166
+
167
+ try {
168
+ for (const sourceConfig of options.config.sources) {
169
+ const item: ActivitySourceRunItem = {
170
+ machineLabel: sourceConfig.machineLabel,
171
+ ran: false,
172
+ fetched: 0,
173
+ inserted: 0,
174
+ duplicates: 0,
175
+ digestsWritten: 0,
176
+ // Cursors are now per (machine, date); report the last synced day's
177
+ // cursor. Null until a day advances it.
178
+ cursor: null,
179
+ };
180
+ try {
181
+ const client = createSourceClient(sourceConfig);
182
+ for (const date of dates) {
183
+ try {
184
+ const result = await syncActivitySource(client, {
185
+ date,
186
+ timezone: options.config.timezone,
187
+ memoryDir: options.memoryDir,
188
+ store,
189
+ signal: options.signal,
190
+ ...(options.reindexSearch === undefined ? {} : { afterWrites: options.reindexSearch }),
191
+ });
192
+ item.fetched += result.fetched;
193
+ item.inserted += result.inserted;
194
+ item.duplicates += result.duplicates;
195
+ if (result.digestWritten) item.digestsWritten += 1;
196
+ item.cursor = result.cursor;
197
+ // A day that synced durably counts as ran, even if a later day in
198
+ // the window fails (ran = at least one durable day).
199
+ item.ran = true;
200
+ // The day's rows/digest/cursor are durable; a failed post-digest
201
+ // reindex is a separate, already-sanitized signal (not a sync
202
+ // failure) so the scheduler can observe a stale index.
203
+ if (result.reindexError !== undefined) item.reindexError = result.reindexError;
204
+ } catch (dateError) {
205
+ // One bad day (a malformed historical snapshot, a per-day daemon
206
+ // error) must not starve the remaining dates — record it and move
207
+ // on. Dates are processed oldest-first, so continuing keeps today's
208
+ // sync alive when an old backfill day is broken. An abort, though,
209
+ // halts the whole run.
210
+ item.error = sanitizedSyncError(dateError);
211
+ if (options.signal?.aborted) break;
212
+ }
213
+ }
214
+ } catch (error) {
215
+ // Client construction failed: no date could run for this source.
216
+ item.error = sanitizedSyncError(error);
217
+ }
218
+ results.push(item);
219
+ // An abort halts the WHOLE run: the in-flight source above is recorded
220
+ // (its fast-failing attempt reports an error), but no later source builds
221
+ // a client or contacts a daemon — teardown settles promptly.
222
+ if (options.signal?.aborted) break;
223
+ }
224
+ } finally {
225
+ if (ownStore) store.close();
226
+ }
227
+
228
+ return {
229
+ ranAt: ranAt.toISOString(),
230
+ enabled: true,
231
+ ranCount: results.filter((item) => item.ran).length,
232
+ errorCount: results.filter((item) => item.error !== undefined).length,
233
+ reindexErrorCount: results.filter((item) => item.reindexError !== undefined).length,
234
+ totalInserted: results.reduce((sum, item) => sum + item.inserted, 0),
235
+ results,
236
+ };
237
+ }
@@ -0,0 +1,303 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+
7
+ import { ACTIVITY_SYNC_DEFAULT_INTERVAL_MS, ActivitySyncScheduler } from "./scheduler.js";
8
+ import { runActivitySyncOnce, type ActivitySyncRunSummary } from "./runner.js";
9
+ import { ActivityStore } from "./store.js";
10
+ import { activityCursorKey } from "./pipeline.js";
11
+ import type { ActivityConfig, ActivitySnapshot, ActivitySourceClient } from "./types.js";
12
+
13
+ /** A hand-driven timer: records armed callbacks + intervals, fires on demand. */
14
+ function fakeTimer(): {
15
+ setTimer: (fn: () => void, ms: number) => number;
16
+ clearTimer: (handle: unknown) => void;
17
+ armed: Array<{ fn: () => void; ms: number }>;
18
+ cleared: number;
19
+ } {
20
+ const armed: Array<{ fn: () => void; ms: number }> = [];
21
+ let cleared = 0;
22
+ return {
23
+ armed,
24
+ get cleared() {
25
+ return cleared;
26
+ },
27
+ setTimer(fn, ms) {
28
+ armed.push({ fn, ms });
29
+ return armed.length;
30
+ },
31
+ clearTimer() {
32
+ cleared += 1;
33
+ },
34
+ };
35
+ }
36
+
37
+ /** Drain the microtask queue so a fired tick's async invoke chain settles. */
38
+ function flush(): Promise<void> {
39
+ const { promise, resolve } = Promise.withResolvers<void>();
40
+ setImmediate(resolve);
41
+ return promise;
42
+ }
43
+
44
+ const SUMMARY: ActivitySyncRunSummary = {
45
+ ranAt: "2026-07-22T18:00:00.000Z",
46
+ enabled: true,
47
+ ranCount: 1,
48
+ errorCount: 0,
49
+ reindexErrorCount: 0,
50
+ totalInserted: 0,
51
+ results: [],
52
+ };
53
+
54
+ function enabledConfig(): ActivityConfig {
55
+ return {
56
+ enabled: true,
57
+ timezone: "UTC",
58
+ syncDays: 1,
59
+ autoSyncIntervalMinutes: 15,
60
+ sources: [{ machineLabel: "workstation-a", baseUrl: "http://127.0.0.1:8760" }],
61
+ };
62
+ }
63
+
64
+ function disabledConfig(): ActivityConfig {
65
+ return {
66
+ enabled: false,
67
+ timezone: "UTC",
68
+ syncDays: 1,
69
+ autoSyncIntervalMinutes: 15,
70
+ sources: [{ machineLabel: "workstation-a", baseUrl: "http://127.0.0.1:8760" }],
71
+ };
72
+ }
73
+
74
+ test("default-off: start registers no timer and never invokes", async () => {
75
+ const timer = fakeTimer();
76
+ let calls = 0;
77
+ const scheduler = new ActivitySyncScheduler({
78
+ config: disabledConfig(),
79
+ memoryDir: "/tmp/unused-activity-scheduler",
80
+ invoke: async () => {
81
+ calls += 1;
82
+ return SUMMARY;
83
+ },
84
+ setTimer: timer.setTimer,
85
+ clearTimer: timer.clearTimer,
86
+ });
87
+
88
+ const registration = scheduler.start();
89
+
90
+ assert.equal(registration.registered, false);
91
+ assert.equal(registration.intervalMs, ACTIVITY_SYNC_DEFAULT_INTERVAL_MS);
92
+ assert.equal(timer.armed.length, 0, "no timer armed while disabled");
93
+ await flush();
94
+ assert.equal(calls, 0, "disabled scheduler never invokes the runner");
95
+ });
96
+
97
+ test("enabled: registers a periodic timer that invokes the runner on cadence", async () => {
98
+ const timer = fakeTimer();
99
+ const summaries: ActivitySyncRunSummary[] = [];
100
+ let calls = 0;
101
+ const scheduler = new ActivitySyncScheduler({
102
+ config: enabledConfig(),
103
+ memoryDir: "/tmp/unused-activity-scheduler",
104
+ invoke: async () => {
105
+ calls += 1;
106
+ return SUMMARY;
107
+ },
108
+ setTimer: timer.setTimer,
109
+ clearTimer: timer.clearTimer,
110
+ onRun: (summary) => summaries.push(summary),
111
+ });
112
+
113
+ const registration = scheduler.start();
114
+ assert.equal(registration.registered, true);
115
+ assert.equal(registration.intervalMs, ACTIVITY_SYNC_DEFAULT_INTERVAL_MS);
116
+ assert.equal(timer.armed.length, 1, "exactly one periodic timer armed");
117
+ assert.equal(timer.armed[0].ms, ACTIVITY_SYNC_DEFAULT_INTERVAL_MS);
118
+
119
+ // Each cadence tick invokes one durable sync pass.
120
+ for (let i = 0; i < 3; i++) {
121
+ timer.armed[0].fn();
122
+ await flush();
123
+ }
124
+ assert.equal(calls, 3, "three ticks -> three sync invocations");
125
+ assert.equal(summaries.length, 3, "each run's summary is surfaced");
126
+ });
127
+
128
+ test("cadence override is honored; default applies when omitted", () => {
129
+ const timer = fakeTimer();
130
+ new ActivitySyncScheduler({
131
+ config: enabledConfig(),
132
+ memoryDir: "/tmp/unused",
133
+ intervalMs: 1_000,
134
+ invoke: async () => SUMMARY,
135
+ setTimer: timer.setTimer,
136
+ clearTimer: timer.clearTimer,
137
+ }).start();
138
+ assert.equal(timer.armed[0].ms, 1_000, "explicit cadence used");
139
+
140
+ // Issue #1899 contracts activity.autoSyncIntervalMinutes default 15, i.e. a
141
+ // 15-minute effective cadence — not connectors' 5-minute poll.
142
+ assert.equal(ACTIVITY_SYNC_DEFAULT_INTERVAL_MS, 15 * 60_000, "default cadence is 15 minutes (#1899)");
143
+ const defaulted = fakeTimer();
144
+ new ActivitySyncScheduler({
145
+ config: enabledConfig(),
146
+ memoryDir: "/tmp/unused",
147
+ invoke: async () => SUMMARY,
148
+ setTimer: defaulted.setTimer,
149
+ clearTimer: defaulted.clearTimer,
150
+ }).start();
151
+ assert.equal(defaulted.armed[0].ms, 900_000, "omitted cadence uses the 15-minute default");
152
+ });
153
+
154
+ test("stop cancels the timer so no later tick invokes", async () => {
155
+ const timer = fakeTimer();
156
+ let calls = 0;
157
+ const scheduler = new ActivitySyncScheduler({
158
+ config: enabledConfig(),
159
+ memoryDir: "/tmp/unused-activity-scheduler",
160
+ invoke: async () => {
161
+ calls += 1;
162
+ return SUMMARY;
163
+ },
164
+ setTimer: timer.setTimer,
165
+ clearTimer: timer.clearTimer,
166
+ });
167
+
168
+ scheduler.start();
169
+ timer.armed[0].fn();
170
+ await flush();
171
+ assert.equal(calls, 1);
172
+
173
+ await scheduler.stop();
174
+ assert.equal(timer.cleared, 1, "stop cancels the underlying timer");
175
+
176
+ // A stray callback firing after stop must not invoke (latched-stop guard).
177
+ timer.armed[0].fn();
178
+ await flush();
179
+ assert.equal(calls, 1, "no invocation after stop");
180
+
181
+ // Restart after stop is a no-op: a stopped scheduler stays stopped.
182
+ const restart = scheduler.start();
183
+ assert.equal(restart.registered, false);
184
+ assert.equal(timer.armed.length, 1, "no new timer armed after stop");
185
+ });
186
+
187
+ test("overlapping ticks do not stack: a slow sync blocks the next tick", async () => {
188
+ const timer = fakeTimer();
189
+ let calls = 0;
190
+ let releaseFirst: () => void = () => {};
191
+ const scheduler = new ActivitySyncScheduler({
192
+ config: enabledConfig(),
193
+ memoryDir: "/tmp/unused-activity-scheduler",
194
+ invoke: () => {
195
+ calls += 1;
196
+ const { promise, resolve } = Promise.withResolvers<ActivitySyncRunSummary>();
197
+ releaseFirst = () => resolve(SUMMARY);
198
+ return promise;
199
+ },
200
+ setTimer: timer.setTimer,
201
+ clearTimer: timer.clearTimer,
202
+ });
203
+
204
+ scheduler.start();
205
+ timer.armed[0].fn(); // starts a sync that stays in-flight
206
+ await flush();
207
+ timer.armed[0].fn(); // second tick while the first is unresolved
208
+ await flush();
209
+ assert.equal(calls, 1, "the in-flight guard drops the overlapping tick");
210
+
211
+ releaseFirst();
212
+ await flush();
213
+ // Once the first settles, a later tick runs again.
214
+ timer.armed[0].fn();
215
+ await flush();
216
+ assert.equal(calls, 2, "a tick after the sync settles invokes again");
217
+ });
218
+
219
+ test("parser -> scheduler -> durable sync: a tick performs a real durable sync", async () => {
220
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-activity-scheduler-"));
221
+ const store = ActivityStore.open(memoryDir);
222
+ try {
223
+ const snapshot: ActivitySnapshot = {
224
+ machine: "wire-label",
225
+ capturedAtUtc: "2026-07-22T14:00:00.000Z",
226
+ app: "Browser",
227
+ windowTitle: "Example",
228
+ text: "synthetic snapshot",
229
+ textSource: "ax",
230
+ contentHash: "hash-1",
231
+ };
232
+ const client: ActivitySourceClient = {
233
+ machineLabel: "workstation-a",
234
+ async verify() {
235
+ return { ok: true };
236
+ },
237
+ async fetchSnapshots() {
238
+ return { snapshots: [snapshot], nextCursor: null };
239
+ },
240
+ };
241
+
242
+ const timer = fakeTimer();
243
+ const scheduler = new ActivitySyncScheduler({
244
+ config: enabledConfig(),
245
+ memoryDir,
246
+ // Real durable runner, fixture daemon + shared store — the scheduler
247
+ // drives the same code path the default invoke uses.
248
+ invoke: (signal) =>
249
+ runActivitySyncOnce({
250
+ config: enabledConfig(),
251
+ memoryDir,
252
+ store,
253
+ now: new Date("2026-07-22T18:00:00.000Z"),
254
+ signal,
255
+ createSourceClient: () => client,
256
+ }),
257
+ setTimer: timer.setTimer,
258
+ clearTimer: timer.clearTimer,
259
+ });
260
+
261
+ scheduler.start();
262
+ timer.armed[0].fn();
263
+ await flush();
264
+
265
+ assert.equal(store.getCursor(activityCursorKey("workstation-a", "2026-07-22")), null, "cursor advanced by the durable sync");
266
+ const rows = store.listSnapshotsForDay(null, "2026-07-22T00:00:00.000Z", "2026-07-23T00:00:00.000Z");
267
+ assert.equal(rows.length, 1, "the tick persisted the snapshot durably");
268
+ } finally {
269
+ store.close();
270
+ await rm(memoryDir, { recursive: true, force: true });
271
+ }
272
+ });
273
+
274
+ test("stop aborts an in-flight tick and drains before resolving", async () => {
275
+ const timer = fakeTimer();
276
+ let observedSignal: AbortSignal | undefined;
277
+ let unwound = false;
278
+ const scheduler = new ActivitySyncScheduler({
279
+ config: enabledConfig(),
280
+ memoryDir: "/tmp/unused-activity-scheduler",
281
+ invoke: (signal) =>
282
+ new Promise<ActivitySyncRunSummary>((_resolve, reject) => {
283
+ observedSignal = signal;
284
+ signal?.addEventListener("abort", () => {
285
+ unwound = true;
286
+ reject(new Error("aborted"));
287
+ });
288
+ }),
289
+ setTimer: timer.setTimer,
290
+ clearTimer: timer.clearTimer,
291
+ onError: () => {}, // swallow the abort rejection so the drain resolves
292
+ });
293
+
294
+ scheduler.start();
295
+ timer.armed[0].fn(); // launch a sync that stays in-flight until aborted
296
+ await flush();
297
+ assert.ok(observedSignal, "tick threads an abort signal into invoke");
298
+ assert.equal(observedSignal?.aborted, false, "not aborted while running");
299
+
300
+ await scheduler.stop();
301
+ assert.equal(observedSignal?.aborted, true, "stop aborts the in-flight tick");
302
+ assert.equal(unwound, true, "stop waited for the aborted sync to unwind");
303
+ });
@@ -0,0 +1,147 @@
1
+ /**
2
+ * In-process periodic driver for activity (screen-capture) sync (issue #1900).
3
+ *
4
+ * The parser (config.ts) validates `config.activity` and the runner
5
+ * (runner.ts) performs one durable sync pass, but nothing drove that pass on a
6
+ * cadence — parsed config alone never synced. This scheduler is the missing
7
+ * link: parser -> scheduler -> durable sync. It registers a repeating timer
8
+ * that invokes the runner, and cancels it on stop.
9
+ *
10
+ * Master default-off: when `config.activity.enabled` is false, `start()`
11
+ * registers no timer and never invokes — source config alone starts nothing.
12
+ * `stop()` clears the timer and latches so no later tick invokes, which is what
13
+ * a runtime teardown (or a config disable + restart) needs. Ticks are
14
+ * overlap-guarded (a slow sync never stacks) and never throw out of the timer.
15
+ *
16
+ * Host-agnostic: the timer and clock are injectable, so a host schedules
17
+ * through this without any OpenClaw import, and tests drive it deterministically.
18
+ */
19
+
20
+ import { runActivitySyncOnce, type ActivitySyncRunSummary } from "./runner.js";
21
+ import type { ActivityConfig } from "./types.js";
22
+
23
+ /**
24
+ * Default sync cadence: 15 minutes. Issue #1899 contracts
25
+ * `activity.autoSyncIntervalMinutes` with a default of 15, so the effective
26
+ * default interval is 15 * 60_000 = 900_000 ms. Overridable via `intervalMs`.
27
+ */
28
+ export const ACTIVITY_SYNC_DEFAULT_INTERVAL_MS = 900_000;
29
+
30
+ /** An opaque timer handle from whichever timer implementation is injected. */
31
+ type TimerHandle = unknown;
32
+
33
+ export interface ActivitySyncSchedulerOptions {
34
+ config: ActivityConfig;
35
+ memoryDir: string;
36
+ /** Cadence in ms; defaults to ACTIVITY_SYNC_DEFAULT_INTERVAL_MS. */
37
+ intervalMs?: number;
38
+ /** The sync pass to run each tick; defaults to the durable runner. */
39
+ invoke?: (signal?: AbortSignal) => Promise<ActivitySyncRunSummary>;
40
+ /** Search-index refresh forwarded to the default runner as `reindexSearch`. */
41
+ reindexSearch?: (signal?: AbortSignal) => Promise<void>;
42
+ /** Timer factory (injectable for tests); defaults to an unref'd setInterval. */
43
+ setTimer?: (fn: () => void, ms: number) => TimerHandle;
44
+ /** Timer canceller matching setTimer; defaults to clearInterval. */
45
+ clearTimer?: (handle: TimerHandle) => void;
46
+ /** Called with each successful run's summary (telemetry/tests). */
47
+ onRun?: (summary: ActivitySyncRunSummary) => void;
48
+ /** Called when a tick's sync rejects; defaults to swallowing (best-effort). */
49
+ onError?: (error: unknown) => void;
50
+ }
51
+
52
+ export interface ActivitySyncRegistration {
53
+ /** True when a periodic timer was armed (feature enabled). */
54
+ registered: boolean;
55
+ /** The cadence the timer runs at (reported even when not registered). */
56
+ intervalMs: number;
57
+ }
58
+
59
+ /**
60
+ * Registers/cancels a periodic activity sync. One instance per memory dir;
61
+ * `start()` and `stop()` are both idempotent.
62
+ */
63
+ export class ActivitySyncScheduler {
64
+ private readonly config: ActivityConfig;
65
+ private readonly memoryDir: string;
66
+ private readonly intervalMs: number;
67
+ private readonly invoke: (signal?: AbortSignal) => Promise<ActivitySyncRunSummary>;
68
+ private readonly setTimer: (fn: () => void, ms: number) => TimerHandle;
69
+ private readonly clearTimer: (handle: TimerHandle) => void;
70
+ private readonly onRun: ((summary: ActivitySyncRunSummary) => void) | undefined;
71
+ private readonly onError: ((error: unknown) => void) | undefined;
72
+
73
+ private handle: TimerHandle = null;
74
+ private stopped = false;
75
+ private inFlight = false;
76
+ private inFlightPromise: Promise<void> | null = null;
77
+ private readonly abortController = new AbortController();
78
+
79
+ constructor(options: ActivitySyncSchedulerOptions) {
80
+ this.config = options.config;
81
+ this.memoryDir = options.memoryDir;
82
+ this.intervalMs = options.intervalMs ?? ACTIVITY_SYNC_DEFAULT_INTERVAL_MS;
83
+ this.invoke =
84
+ options.invoke ??
85
+ ((signal) =>
86
+ runActivitySyncOnce({
87
+ config: this.config,
88
+ memoryDir: this.memoryDir,
89
+ signal,
90
+ ...(options.reindexSearch === undefined ? {} : { reindexSearch: options.reindexSearch }),
91
+ }));
92
+ this.setTimer =
93
+ options.setTimer ??
94
+ ((fn, ms) => {
95
+ const timer = setInterval(fn, ms);
96
+ // Never keep the host process alive solely for the sync cadence.
97
+ timer.unref?.();
98
+ return timer;
99
+ });
100
+ this.clearTimer =
101
+ options.clearTimer ?? ((handle) => clearInterval(handle as NodeJS.Timeout));
102
+ this.onRun = options.onRun;
103
+ this.onError = options.onError;
104
+ }
105
+
106
+ /**
107
+ * Arm the periodic sync. Disabled config (default-off) registers nothing.
108
+ * Idempotent: a second call while running (or after stop) re-arms nothing.
109
+ */
110
+ start(): ActivitySyncRegistration {
111
+ if (this.handle !== null || this.stopped || !this.config.enabled) {
112
+ return { registered: this.handle !== null, intervalMs: this.intervalMs };
113
+ }
114
+ this.handle = this.setTimer(() => this.tick(), this.intervalMs);
115
+ return { registered: true, intervalMs: this.intervalMs };
116
+ }
117
+
118
+ /**
119
+ * Cancel the periodic sync and abort any in-flight tick, then resolve once it
120
+ * has unwound. The abort + timer-clear + stop-latch happen synchronously, so
121
+ * even a caller that cannot await (a sync teardown) still signals the running
122
+ * sync to stop; async callers can await the returned drain. Idempotent.
123
+ */
124
+ async stop(): Promise<void> {
125
+ this.stopped = true;
126
+ this.abortController.abort();
127
+ if (this.handle !== null) {
128
+ this.clearTimer(this.handle);
129
+ this.handle = null;
130
+ }
131
+ await this.inFlightPromise?.catch(() => undefined);
132
+ }
133
+
134
+ private tick(): void {
135
+ // Latched-stop and overlap guards: a callback that fires after stop(), or
136
+ // while a prior slow sync is still running, must not invoke.
137
+ if (this.stopped || this.inFlight) return;
138
+ this.inFlight = true;
139
+ this.inFlightPromise = this.invoke(this.abortController.signal)
140
+ .then((summary) => this.onRun?.(summary))
141
+ .catch((error) => this.onError?.(error))
142
+ .finally(() => {
143
+ this.inFlight = false;
144
+ this.inFlightPromise = null;
145
+ });
146
+ }
147
+ }