vellum 0.2.1 → 0.2.7

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 (361) hide show
  1. package/README.md +15 -2
  2. package/bun.lock +71 -100
  3. package/package.json +5 -3
  4. package/scripts/capture-x-graphql.ts +562 -0
  5. package/scripts/ipc/check-swift-decoder-drift.ts +2 -1
  6. package/scripts/test.sh +5 -0
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +133 -34
  8. package/src/__tests__/account-registry.test.ts +2 -1
  9. package/src/__tests__/agent-heartbeat-service.test.ts +250 -0
  10. package/src/__tests__/asset-materialize-tool.test.ts +16 -15
  11. package/src/__tests__/asset-search-tool.test.ts +23 -22
  12. package/src/__tests__/attachments-store.test.ts +56 -127
  13. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +5 -4
  14. package/src/__tests__/browser-skill-endstate.test.ts +4 -3
  15. package/src/__tests__/call-bridge.test.ts +385 -0
  16. package/src/__tests__/call-constants.test.ts +40 -0
  17. package/src/__tests__/call-orchestrator.test.ts +130 -4
  18. package/src/__tests__/call-recovery.test.ts +518 -0
  19. package/src/__tests__/call-routes-http.test.ts +459 -0
  20. package/src/__tests__/call-state-machine.test.ts +143 -0
  21. package/src/__tests__/call-store.test.ts +216 -1
  22. package/src/__tests__/cli-discover.test.ts +1 -1
  23. package/src/__tests__/commit-message-enrichment-service.test.ts +148 -7
  24. package/src/__tests__/compaction.benchmark.test.ts +176 -0
  25. package/src/__tests__/computer-use-tools.test.ts +250 -0
  26. package/src/__tests__/config-schema.test.ts +305 -3
  27. package/src/__tests__/conflict-store.test.ts +2 -1
  28. package/src/__tests__/contacts-tools.test.ts +331 -0
  29. package/src/__tests__/conversation-store.test.ts +30 -32
  30. package/src/__tests__/credential-security-invariants.test.ts +4 -0
  31. package/src/__tests__/date-context.test.ts +373 -0
  32. package/src/__tests__/db-schedule-syntax-migration.test.ts +129 -0
  33. package/src/__tests__/fixtures/media-reuse-fixtures.ts +3 -3
  34. package/src/__tests__/followup-tools.test.ts +303 -0
  35. package/src/__tests__/handlers-twilio-config.test.ts +221 -0
  36. package/src/__tests__/handlers-twitter-config.test.ts +718 -0
  37. package/src/__tests__/intent-routing.test.ts +64 -57
  38. package/src/__tests__/ipc-roundtrip.benchmark.test.ts +237 -0
  39. package/src/__tests__/ipc-snapshot.test.ts +71 -28
  40. package/src/__tests__/llm-usage-store.test.ts +3 -8
  41. package/src/__tests__/media-generate-image.test.ts +1 -1
  42. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
  43. package/src/__tests__/memory-regressions.test.ts +100 -2
  44. package/src/__tests__/memory-retrieval.benchmark.test.ts +430 -0
  45. package/src/__tests__/parallel-tool.benchmark.test.ts +294 -0
  46. package/src/__tests__/playbook-tools.test.ts +342 -0
  47. package/src/__tests__/profile-compiler.test.ts +2 -1
  48. package/src/__tests__/provider-commit-message-generator.test.ts +303 -0
  49. package/src/__tests__/provider-streaming.benchmark.test.ts +773 -0
  50. package/src/__tests__/recurrence-engine-rruleset.test.ts +78 -0
  51. package/src/__tests__/recurrence-engine.test.ts +69 -0
  52. package/src/__tests__/recurrence-types.test.ts +71 -0
  53. package/src/__tests__/registry.test.ts +5 -3
  54. package/src/__tests__/relay-server.test.ts +633 -0
  55. package/src/__tests__/reminder-store.test.ts +6 -3
  56. package/src/__tests__/reminder.test.ts +43 -77
  57. package/src/__tests__/run-orchestrator-assistant-events.test.ts +8 -4
  58. package/src/__tests__/run-orchestrator.test.ts +4 -4
  59. package/src/__tests__/runtime-attachment-metadata.test.ts +7 -6
  60. package/src/__tests__/runtime-runs-http.test.ts +4 -4
  61. package/src/__tests__/runtime-runs.test.ts +4 -4
  62. package/src/__tests__/schedule-store.test.ts +482 -0
  63. package/src/__tests__/schedule-tools.test.ts +700 -0
  64. package/src/__tests__/scheduler-recurrence.test.ts +329 -0
  65. package/src/__tests__/server-history-render.test.ts +14 -13
  66. package/src/__tests__/session-conflict-gate.test.ts +28 -25
  67. package/src/__tests__/session-error.test.ts +28 -0
  68. package/src/__tests__/session-init.benchmark.test.ts +462 -0
  69. package/src/__tests__/session-queue.test.ts +71 -48
  70. package/src/__tests__/session-runtime-assembly.test.ts +161 -0
  71. package/src/__tests__/session-surfaces-task-progress.test.ts +104 -0
  72. package/src/__tests__/signup-e2e.test.ts +2 -1
  73. package/src/__tests__/skill-projection.benchmark.test.ts +328 -0
  74. package/src/__tests__/skill-script-runner.test.ts +159 -0
  75. package/src/__tests__/speaker-identification.test.ts +52 -0
  76. package/src/__tests__/subagent-manager-notify.test.ts +42 -10
  77. package/src/__tests__/subagent-tools.test.ts +141 -41
  78. package/src/__tests__/task-compiler.test.ts +2 -1
  79. package/src/__tests__/task-runner.test.ts +2 -1
  80. package/src/__tests__/task-scheduler.test.ts +2 -1
  81. package/src/__tests__/task-tools.test.ts +49 -56
  82. package/src/__tests__/tool-audit-listener.test.ts +1 -0
  83. package/src/__tests__/tool-domain-event-publisher.test.ts +2 -0
  84. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +500 -0
  85. package/src/__tests__/tool-executor.test.ts +13 -17
  86. package/src/__tests__/turn-commit.test.ts +218 -3
  87. package/src/__tests__/twilio-provider.test.ts +143 -0
  88. package/src/__tests__/twilio-routes.test.ts +789 -0
  89. package/src/__tests__/twitter-auth-handler.test.ts +581 -0
  90. package/src/__tests__/view-image-tool.test.ts +217 -0
  91. package/src/__tests__/workspace-git-service.test.ts +186 -0
  92. package/src/__tests__/workspace-heartbeat-service.test.ts +13 -3
  93. package/src/agent-heartbeat/agent-heartbeat-service.ts +155 -0
  94. package/src/bundler/app-bundler.ts +12 -8
  95. package/src/calls/__tests__/twilio-webhook-urls.test.ts +162 -0
  96. package/src/calls/call-bridge.ts +95 -0
  97. package/src/calls/call-constants.ts +43 -5
  98. package/src/calls/call-domain.ts +276 -0
  99. package/src/calls/call-orchestrator.ts +43 -17
  100. package/src/calls/call-recovery.ts +207 -0
  101. package/src/calls/call-state-machine.ts +68 -0
  102. package/src/calls/call-store.ts +192 -5
  103. package/src/calls/relay-server.ts +41 -4
  104. package/src/calls/speaker-identification.ts +213 -0
  105. package/src/calls/twilio-config.ts +8 -8
  106. package/src/calls/twilio-provider.ts +13 -9
  107. package/src/calls/twilio-routes.ts +90 -76
  108. package/src/calls/twilio-webhook-urls.ts +50 -0
  109. package/src/calls/types.ts +1 -1
  110. package/src/cli/config-commands.ts +334 -0
  111. package/src/cli/core-commands.ts +776 -0
  112. package/src/cli/doordash.ts +251 -1
  113. package/src/cli/ipc-client.ts +82 -0
  114. package/src/cli/map.ts +270 -0
  115. package/src/cli/twitter.ts +575 -0
  116. package/src/cli.ts +7 -5
  117. package/src/commands/__tests__/cc-command-registry.test.ts +319 -0
  118. package/src/commands/cc-command-registry.ts +209 -0
  119. package/src/config/bundled-skills/contacts/SKILL.md +39 -0
  120. package/src/config/bundled-skills/contacts/TOOLS.json +122 -0
  121. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +9 -0
  122. package/src/config/bundled-skills/contacts/tools/contact-search.ts +9 -0
  123. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +9 -0
  124. package/src/config/bundled-skills/document/SKILL.md +18 -0
  125. package/src/config/bundled-skills/document/TOOLS.json +53 -0
  126. package/src/config/bundled-skills/document/tools/document-create.ts +9 -0
  127. package/src/config/bundled-skills/document/tools/document-update.ts +9 -0
  128. package/src/config/bundled-skills/doordash/SKILL.md +82 -23
  129. package/src/config/bundled-skills/followups/SKILL.md +32 -0
  130. package/src/config/bundled-skills/followups/TOOLS.json +100 -0
  131. package/src/config/bundled-skills/followups/tools/followup-create.ts +9 -0
  132. package/src/config/bundled-skills/followups/tools/followup-list.ts +9 -0
  133. package/src/config/bundled-skills/followups/tools/followup-resolve.ts +9 -0
  134. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +1 -23
  135. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -1
  136. package/src/config/bundled-skills/playbooks/SKILL.md +31 -0
  137. package/src/config/bundled-skills/playbooks/TOOLS.json +126 -0
  138. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +9 -0
  139. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +9 -0
  140. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +9 -0
  141. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +9 -0
  142. package/src/config/bundled-skills/reminder/SKILL.md +20 -0
  143. package/src/config/bundled-skills/reminder/TOOLS.json +67 -0
  144. package/src/config/bundled-skills/reminder/tools/reminder-cancel.ts +9 -0
  145. package/src/config/bundled-skills/reminder/tools/reminder-create.ts +9 -0
  146. package/src/config/bundled-skills/reminder/tools/reminder-list.ts +9 -0
  147. package/src/config/bundled-skills/schedule/SKILL.md +74 -0
  148. package/src/config/bundled-skills/schedule/TOOLS.json +135 -0
  149. package/src/config/bundled-skills/schedule/tools/schedule-create.ts +9 -0
  150. package/src/config/bundled-skills/schedule/tools/schedule-delete.ts +9 -0
  151. package/src/config/bundled-skills/schedule/tools/schedule-list.ts +9 -0
  152. package/src/config/bundled-skills/schedule/tools/schedule-update.ts +9 -0
  153. package/src/config/bundled-skills/subagent/SKILL.md +25 -0
  154. package/src/config/bundled-skills/subagent/TOOLS.json +107 -0
  155. package/src/config/bundled-skills/subagent/tools/subagent-abort.ts +9 -0
  156. package/src/config/bundled-skills/subagent/tools/subagent-message.ts +9 -0
  157. package/src/config/bundled-skills/subagent/tools/subagent-read.ts +9 -0
  158. package/src/config/bundled-skills/subagent/tools/subagent-spawn.ts +9 -0
  159. package/src/config/bundled-skills/subagent/tools/subagent-status.ts +9 -0
  160. package/src/config/bundled-skills/tasks/SKILL.md +28 -0
  161. package/src/config/bundled-skills/tasks/TOOLS.json +256 -0
  162. package/src/config/bundled-skills/tasks/tools/task-delete.ts +9 -0
  163. package/src/config/bundled-skills/tasks/tools/task-list-add.ts +9 -0
  164. package/src/config/bundled-skills/tasks/tools/task-list-remove.ts +9 -0
  165. package/src/config/bundled-skills/tasks/tools/task-list-show.ts +9 -0
  166. package/src/config/bundled-skills/tasks/tools/task-list-update.ts +9 -0
  167. package/src/config/bundled-skills/tasks/tools/task-list.ts +9 -0
  168. package/src/config/bundled-skills/tasks/tools/task-run.ts +9 -0
  169. package/src/config/bundled-skills/tasks/tools/task-save.ts +9 -0
  170. package/src/config/bundled-skills/twitter/SKILL.md +134 -0
  171. package/src/config/bundled-skills/watcher/SKILL.md +27 -0
  172. package/src/config/bundled-skills/watcher/TOOLS.json +147 -0
  173. package/src/config/bundled-skills/watcher/tools/watcher-create.ts +9 -0
  174. package/src/config/bundled-skills/watcher/tools/watcher-delete.ts +9 -0
  175. package/src/config/bundled-skills/watcher/tools/watcher-digest.ts +9 -0
  176. package/src/config/bundled-skills/watcher/tools/watcher-list.ts +9 -0
  177. package/src/config/bundled-skills/watcher/tools/watcher-update.ts +9 -0
  178. package/src/config/defaults.ts +34 -0
  179. package/src/config/loader.ts +4 -1
  180. package/src/config/schema.ts +165 -1
  181. package/src/config/system-prompt.ts +61 -16
  182. package/src/config/templates/IDENTITY.md +7 -0
  183. package/src/config/types.ts +4 -0
  184. package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -5
  185. package/src/contacts/contact-store.ts +4 -4
  186. package/src/daemon/assistant-attachments.ts +10 -0
  187. package/src/daemon/classifier.ts +3 -1
  188. package/src/daemon/computer-use-session.ts +3 -1
  189. package/src/daemon/date-context.ts +136 -0
  190. package/src/daemon/handlers/apps.ts +16 -1
  191. package/src/daemon/handlers/browser.ts +54 -0
  192. package/src/daemon/handlers/computer-use.ts +7 -1
  193. package/src/daemon/handlers/config.ts +205 -5
  194. package/src/daemon/handlers/diagnostics.ts +5 -1
  195. package/src/daemon/handlers/documents.ts +18 -29
  196. package/src/daemon/handlers/home-base.ts +5 -1
  197. package/src/daemon/handlers/index.ts +40 -277
  198. package/src/daemon/handlers/misc.ts +9 -1
  199. package/src/daemon/handlers/publish.ts +6 -1
  200. package/src/daemon/handlers/sessions.ts +65 -12
  201. package/src/daemon/handlers/shared.ts +36 -1
  202. package/src/daemon/handlers/signing.ts +37 -0
  203. package/src/daemon/handlers/skills.ts +20 -6
  204. package/src/daemon/handlers/subagents.ts +8 -3
  205. package/src/daemon/handlers/twitter-auth.ts +169 -0
  206. package/src/daemon/handlers/work-items.ts +384 -68
  207. package/src/daemon/ipc-contract-inventory.json +32 -4
  208. package/src/daemon/ipc-contract.ts +156 -37
  209. package/src/daemon/ipc-protocol.ts +7 -2
  210. package/src/daemon/lifecycle.ts +21 -0
  211. package/src/daemon/main.ts +10 -4
  212. package/src/daemon/ride-shotgun-handler.ts +75 -10
  213. package/src/daemon/server.ts +143 -26
  214. package/src/daemon/session-agent-loop.ts +922 -0
  215. package/src/daemon/session-attachments.ts +28 -5
  216. package/src/daemon/session-conflict-gate.ts +18 -109
  217. package/src/daemon/session-error.ts +24 -3
  218. package/src/daemon/session-lifecycle.ts +147 -0
  219. package/src/daemon/session-media-retry.ts +147 -0
  220. package/src/daemon/session-messaging.ts +145 -0
  221. package/src/daemon/session-notifiers.ts +164 -0
  222. package/src/daemon/session-process.ts +2 -2
  223. package/src/daemon/session-queue-manager.ts +1 -0
  224. package/src/daemon/session-runtime-assembly.ts +52 -0
  225. package/src/daemon/session-skill-tools.ts +124 -5
  226. package/src/daemon/session-slash.ts +3 -0
  227. package/src/daemon/session-surfaces.ts +77 -2
  228. package/src/daemon/session-tool-setup.ts +216 -2
  229. package/src/daemon/session-usage.ts +0 -2
  230. package/src/daemon/session.ts +114 -1404
  231. package/src/daemon/video-thumbnail.ts +60 -0
  232. package/src/doordash/client.ts +121 -27
  233. package/src/doordash/queries.ts +1 -2
  234. package/src/export/formatter.ts +3 -1
  235. package/src/followups/followup-store.ts +4 -2
  236. package/src/followups/types.ts +6 -0
  237. package/src/hooks/templates.ts +1 -1
  238. package/src/index.ts +32 -1153
  239. package/src/memory/attachments-store.ts +28 -83
  240. package/src/memory/channel-delivery-store.ts +7 -21
  241. package/src/memory/clarification-resolver.ts +6 -5
  242. package/src/memory/conflict-intent.ts +114 -0
  243. package/src/memory/contradiction-checker.ts +3 -2
  244. package/src/memory/conversation-key-store.ts +10 -29
  245. package/src/memory/conversation-store.ts +2 -1
  246. package/src/memory/db.ts +96 -2
  247. package/src/memory/entity-extractor.ts +6 -3
  248. package/src/memory/items-extractor.ts +5 -4
  249. package/src/memory/job-handlers/conflict.ts +23 -1
  250. package/src/memory/jobs-store.ts +3 -2
  251. package/src/memory/llm-usage-store.ts +1 -2
  252. package/src/memory/runs-store.ts +1 -2
  253. package/src/memory/schema.ts +23 -2
  254. package/src/messaging/style-analyzer.ts +3 -2
  255. package/src/messaging/thread-summarizer.ts +8 -12
  256. package/src/messaging/triage-engine.ts +4 -2
  257. package/src/providers/openrouter/client.ts +20 -0
  258. package/src/providers/registry.ts +8 -0
  259. package/src/runtime/gateway-client.ts +36 -0
  260. package/src/runtime/http-server.ts +166 -22
  261. package/src/runtime/routes/attachment-routes.ts +2 -3
  262. package/src/runtime/routes/call-routes.ts +140 -0
  263. package/src/runtime/routes/channel-routes.ts +125 -88
  264. package/src/runtime/routes/conversation-routes.ts +5 -5
  265. package/src/runtime/routes/run-routes.ts +2 -2
  266. package/src/runtime/run-orchestrator.ts +9 -3
  267. package/src/schedule/recurrence-engine.ts +138 -0
  268. package/src/schedule/recurrence-types.ts +67 -0
  269. package/src/schedule/schedule-store.ts +102 -57
  270. package/src/schedule/scheduler.ts +9 -6
  271. package/src/security/oauth2.ts +29 -4
  272. package/src/security/secret-allowlist.ts +46 -0
  273. package/src/skills/clawhub.ts +1 -1
  274. package/src/subagent/manager.ts +40 -8
  275. package/src/swarm/backend-claude-code.ts +64 -9
  276. package/src/swarm/worker-prompts.ts +2 -1
  277. package/src/tasks/SPEC.md +34 -28
  278. package/src/tasks/ephemeral-permissions.ts +16 -7
  279. package/src/tasks/task-compiler.ts +5 -4
  280. package/src/tasks/task-runner.ts +10 -5
  281. package/src/tasks/task-scheduler.ts +1 -1
  282. package/src/tasks/tool-sanitizer.ts +36 -0
  283. package/src/tools/assets/search.ts +4 -4
  284. package/src/tools/browser/api-map.ts +293 -0
  285. package/src/tools/browser/auto-navigate.ts +270 -0
  286. package/src/tools/browser/browser-execution.ts +2 -1
  287. package/src/tools/browser/browser-manager.ts +2 -2
  288. package/src/tools/browser/network-recorder.ts +5 -4
  289. package/src/tools/browser/x-auto-navigate.ts +207 -0
  290. package/src/tools/calls/call-end.ts +17 -67
  291. package/src/tools/calls/call-start.ts +24 -85
  292. package/src/tools/calls/call-status.ts +35 -51
  293. package/src/tools/claude-code/claude-code.ts +207 -11
  294. package/src/tools/contacts/contact-merge.ts +46 -78
  295. package/src/tools/contacts/contact-search.ts +35 -79
  296. package/src/tools/contacts/contact-upsert.ts +35 -108
  297. package/src/tools/credentials/vault.ts +20 -4
  298. package/src/tools/document/document-tool.ts +71 -144
  299. package/src/tools/executor.ts +129 -10
  300. package/src/tools/followups/followup_create.ts +46 -88
  301. package/src/tools/followups/followup_list.ts +34 -74
  302. package/src/tools/followups/followup_resolve.ts +31 -66
  303. package/src/tools/host-terminal/cli-discover.ts +2 -1
  304. package/src/tools/host-terminal/host-shell.ts +10 -0
  305. package/src/tools/memory/handlers.ts +5 -4
  306. package/src/tools/network/__tests__/web-search.test.ts +427 -0
  307. package/src/tools/network/script-proxy/__tests__/logging.test.ts +248 -0
  308. package/src/tools/network/script-proxy/__tests__/policy.test.ts +234 -0
  309. package/src/tools/network/script-proxy/__tests__/router.test.ts +76 -0
  310. package/src/tools/network/web-fetch.ts +18 -6
  311. package/src/tools/playbooks/index.ts +4 -5
  312. package/src/tools/playbooks/playbook-create.ts +3 -47
  313. package/src/tools/playbooks/playbook-delete.ts +1 -25
  314. package/src/tools/playbooks/playbook-list.ts +1 -28
  315. package/src/tools/playbooks/playbook-update.ts +3 -51
  316. package/src/tools/reminder/reminder.ts +5 -78
  317. package/src/tools/schedule/create.ts +69 -74
  318. package/src/tools/schedule/delete.ts +21 -47
  319. package/src/tools/schedule/list.ts +55 -74
  320. package/src/tools/schedule/update.ts +77 -84
  321. package/src/tools/subagent/abort.ts +29 -58
  322. package/src/tools/subagent/message.ts +30 -63
  323. package/src/tools/subagent/read.ts +53 -84
  324. package/src/tools/subagent/spawn.ts +43 -82
  325. package/src/tools/subagent/status.ts +42 -71
  326. package/src/tools/swarm/delegate.ts +2 -1
  327. package/src/tools/tasks/index.ts +8 -8
  328. package/src/tools/tasks/task-delete.ts +60 -88
  329. package/src/tools/tasks/task-list.ts +31 -52
  330. package/src/tools/tasks/task-run.ts +72 -108
  331. package/src/tools/tasks/task-save.ts +33 -65
  332. package/src/tools/tasks/work-item-enqueue.ts +183 -215
  333. package/src/tools/tasks/work-item-list.ts +33 -63
  334. package/src/tools/tasks/work-item-remove.ts +45 -97
  335. package/src/tools/tasks/work-item-update.ts +91 -163
  336. package/src/tools/terminal/backends/native.ts +3 -1
  337. package/src/tools/tool-manifest.ts +0 -62
  338. package/src/tools/types.ts +6 -0
  339. package/src/tools/ui-surface/definitions.ts +3 -1
  340. package/src/tools/watch/screen-watch.ts +3 -1
  341. package/src/tools/watcher/create.ts +52 -98
  342. package/src/tools/watcher/delete.ts +20 -46
  343. package/src/tools/watcher/digest.ts +36 -70
  344. package/src/tools/watcher/list.ts +49 -79
  345. package/src/tools/watcher/update.ts +45 -91
  346. package/src/twitter/client.ts +690 -0
  347. package/src/twitter/session.ts +91 -0
  348. package/src/usage/types.ts +0 -1
  349. package/src/util/truncate.ts +6 -0
  350. package/src/watcher/providers/slack.ts +2 -1
  351. package/src/watcher/watcher-store.ts +3 -2
  352. package/src/work-items/work-item-store.ts +27 -2
  353. package/src/workspace/commit-message-enrichment-service.ts +31 -7
  354. package/src/workspace/git-service.ts +87 -22
  355. package/src/workspace/provider-commit-message-generator.ts +269 -0
  356. package/src/workspace/turn-commit.ts +62 -3
  357. package/src/tools/contacts/index.ts +0 -4
  358. package/src/tools/document/index.ts +0 -5
  359. package/src/tools/followups/index.ts +0 -3
  360. package/src/tools/subagent/index.ts +0 -5
  361. /package/src/__tests__/{memory-context-benchmark.test.ts → memory-context-benchmark.benchmark.test.ts} +0 -0
@@ -10,10 +10,10 @@ import { renderHistoryContent } from '../../daemon/handlers.js';
10
10
  import { checkIngressForSecrets } from '../../security/secret-ingress.js';
11
11
  import { IngressBlockedError } from '../../util/errors.js';
12
12
  import { getLogger } from '../../util/logger.js';
13
+ import { deliverChannelReply } from '../gateway-client.js';
13
14
  import type {
14
15
  MessageProcessor,
15
16
  RuntimeAttachmentMetadata,
16
- RuntimeMessagePayload,
17
17
  } from '../http-types.js';
18
18
 
19
19
  const log = getLogger('runtime-http');
@@ -34,7 +34,7 @@ export async function handleDeleteConversation(req: Request): Promise<Response>
34
34
  }
35
35
 
36
36
  const conversationKey = `${sourceChannel}:${externalChatId}`;
37
- deleteConversationKey("self", conversationKey);
37
+ deleteConversationKey(conversationKey);
38
38
 
39
39
  return Response.json({ ok: true });
40
40
  }
@@ -54,6 +54,7 @@ export async function handleChannelInbound(
54
54
  senderExternalUserId?: string;
55
55
  senderUsername?: string;
56
56
  sourceMetadata?: Record<string, unknown>;
57
+ replyCallbackUrl?: string;
57
58
  };
58
59
 
59
60
  const {
@@ -89,7 +90,7 @@ export async function handleChannelInbound(
89
90
  }
90
91
 
91
92
  if (hasAttachments) {
92
- const resolved = attachmentsStore.getAttachmentsByIds("self", attachmentIds);
93
+ const resolved = attachmentsStore.getAttachmentsByIds(attachmentIds);
93
94
  if (resolved.length !== attachmentIds.length) {
94
95
  const resolvedIds = new Set(resolved.map((a) => a.id));
95
96
  const missing = attachmentIds.filter((id) => !resolvedIds.has(id));
@@ -112,7 +113,6 @@ export async function handleChannelInbound(
112
113
  if (isEdit && sourceMessageId) {
113
114
  // Dedup the edit event itself (retried edited_message webhooks)
114
115
  const editResult = channelDeliveryStore.recordInbound(
115
- "self",
116
116
  sourceChannel,
117
117
  externalChatId,
118
118
  externalMessageId,
@@ -136,7 +136,6 @@ export async function handleChannelInbound(
136
136
  let original: { messageId: string; conversationId: string } | null = null;
137
137
  for (let attempt = 0; attempt <= EDIT_LOOKUP_RETRIES; attempt++) {
138
138
  original = channelDeliveryStore.findMessageBySourceId(
139
- "self",
140
139
  sourceChannel,
141
140
  externalChatId,
142
141
  sourceMessageId,
@@ -173,7 +172,6 @@ export async function handleChannelInbound(
173
172
 
174
173
  // ── New message path ──
175
174
  const result = channelDeliveryStore.recordInbound(
176
- "self",
177
175
  sourceChannel,
178
176
  externalChatId,
179
177
  externalMessageId,
@@ -188,41 +186,92 @@ export async function handleChannelInbound(
188
186
  ? sourceMetadata.uxBrief.trim()
189
187
  : undefined;
190
188
 
191
- // For new (non-duplicate) messages, run the agent loop to generate a reply.
192
- let processingSucceeded = false;
189
+ const replyCallbackUrl = body.replyCallbackUrl;
190
+
191
+ // For new (non-duplicate) messages, run the secret ingress check
192
+ // synchronously, then fire off the agent loop in the background.
193
193
  if (!result.duplicate && processMessage) {
194
+ // Persist the raw payload first so dead-lettered events can always be
195
+ // replayed. If the ingress check later detects secrets we clear it
196
+ // before throwing, so secret-bearing content is never left on disk.
197
+ channelDeliveryStore.storePayload(result.eventId, {
198
+ sourceChannel, externalChatId, externalMessageId, content,
199
+ attachmentIds, sourceMetadata: body.sourceMetadata,
200
+ senderName: body.senderName,
201
+ senderExternalUserId: body.senderExternalUserId,
202
+ senderUsername: body.senderUsername,
203
+ replyCallbackUrl,
204
+ });
205
+
206
+ const contentToCheck = content ?? '';
207
+ let ingressCheck: ReturnType<typeof checkIngressForSecrets>;
194
208
  try {
195
- // Persist the raw payload first so dead-lettered events can always be
196
- // replayed. If the ingress check later detects secrets we clear it
197
- // before throwing, so secret-bearing content is never left on disk.
198
- channelDeliveryStore.storePayload(result.eventId, {
199
- sourceChannel, externalChatId, externalMessageId, content,
200
- attachmentIds, sourceMetadata: body.sourceMetadata,
201
- senderName: body.senderName,
202
- senderExternalUserId: body.senderExternalUserId,
203
- senderUsername: body.senderUsername,
204
- });
209
+ ingressCheck = checkIngressForSecrets(contentToCheck);
210
+ } catch (checkErr) {
211
+ channelDeliveryStore.clearPayload(result.eventId);
212
+ throw checkErr;
213
+ }
214
+ if (ingressCheck.blocked) {
215
+ channelDeliveryStore.clearPayload(result.eventId);
216
+ throw new IngressBlockedError(ingressCheck.userNotice!, ingressCheck.detectedTypes);
217
+ }
205
218
 
206
- const contentToCheck = content ?? '';
207
- let ingressCheck: ReturnType<typeof checkIngressForSecrets>;
208
- try {
209
- ingressCheck = checkIngressForSecrets(contentToCheck);
210
- } catch (checkErr) {
211
- // If the secret check itself throws (e.g. ConfigError from corrupt
212
- // config), clear the stored payload so secret-bearing content is
213
- // never left on disk.
214
- channelDeliveryStore.clearPayload(result.eventId);
215
- throw checkErr;
216
- }
217
- if (ingressCheck.blocked) {
218
- channelDeliveryStore.clearPayload(result.eventId);
219
- throw new IngressBlockedError(ingressCheck.userNotice!, ingressCheck.detectedTypes);
220
- }
219
+ // Fire-and-forget: process the message and deliver the reply in the background.
220
+ // The HTTP response returns immediately so the gateway webhook is not blocked.
221
+ processChannelMessageInBackground({
222
+ processMessage,
223
+ conversationId: result.conversationId,
224
+ eventId: result.eventId,
225
+ content: content ?? '',
226
+ attachmentIds: hasAttachments ? attachmentIds : undefined,
227
+ sourceChannel,
228
+ externalChatId,
229
+ metadataHints,
230
+ metadataUxBrief,
231
+ replyCallbackUrl,
232
+ });
233
+ }
234
+
235
+ return Response.json({
236
+ accepted: result.accepted,
237
+ duplicate: result.duplicate,
238
+ eventId: result.eventId,
239
+ });
240
+ }
221
241
 
242
+ interface BackgroundProcessingParams {
243
+ processMessage: MessageProcessor;
244
+ conversationId: string;
245
+ eventId: string;
246
+ content: string;
247
+ attachmentIds?: string[];
248
+ sourceChannel: string;
249
+ externalChatId: string;
250
+ metadataHints: string[];
251
+ metadataUxBrief?: string;
252
+ replyCallbackUrl?: string;
253
+ }
254
+
255
+ function processChannelMessageInBackground(params: BackgroundProcessingParams): void {
256
+ const {
257
+ processMessage,
258
+ conversationId,
259
+ eventId,
260
+ content,
261
+ attachmentIds,
262
+ sourceChannel,
263
+ externalChatId,
264
+ metadataHints,
265
+ metadataUxBrief,
266
+ replyCallbackUrl,
267
+ } = params;
268
+
269
+ (async () => {
270
+ try {
222
271
  const { messageId: userMessageId } = await processMessage(
223
- result.conversationId,
224
- content ?? '',
225
- hasAttachments ? attachmentIds : undefined,
272
+ conversationId,
273
+ content,
274
+ attachmentIds,
226
275
  {
227
276
  transport: {
228
277
  channelId: sourceChannel,
@@ -232,65 +281,54 @@ export async function handleChannelInbound(
232
281
  },
233
282
  sourceChannel,
234
283
  );
235
- // Link the user message to the inbound event so edits can find it later
236
- channelDeliveryStore.linkMessage(result.eventId, userMessageId);
237
- channelDeliveryStore.markProcessed(result.eventId);
238
- processingSucceeded = true;
284
+ channelDeliveryStore.linkMessage(eventId, userMessageId);
285
+ channelDeliveryStore.markProcessed(eventId);
286
+
287
+ if (replyCallbackUrl) {
288
+ await deliverReplyViaCallback(conversationId, externalChatId, replyCallbackUrl);
289
+ }
239
290
  } catch (err) {
240
- // Secret ingress blocks are not retryable let the top-level handler return 422
241
- if (err instanceof IngressBlockedError) throw err;
242
- console.error(`[runtime-http] Processing failed`, err);
243
- log.error({ err, conversationId: result.conversationId }, 'Failed to process channel inbound message');
244
- channelDeliveryStore.recordProcessingFailure(result.eventId, err);
291
+ log.error({ err, conversationId }, 'Background channel message processing failed');
292
+ channelDeliveryStore.recordProcessingFailure(eventId, err);
245
293
  }
246
- }
294
+ })();
295
+ }
247
296
 
248
- // Only look up the assistant reply when processing succeeded for a new
249
- // (non-duplicate) message. For duplicates or failed processing, returning
250
- // a stale assistant message could cause the caller to resend old replies.
251
- let assistantMessage: RuntimeMessagePayload | undefined;
252
- if (processingSucceeded) {
253
- const msgs = conversationStore.getMessages(result.conversationId);
254
- for (let i = msgs.length - 1; i >= 0; i--) {
255
- if (msgs[i].role === 'assistant') {
256
- let parsed: unknown;
257
- try { parsed = JSON.parse(msgs[i].content); } catch { parsed = msgs[i].content; }
258
- const rendered = renderHistoryContent(parsed);
259
-
260
- const linked = attachmentsStore.getAttachmentMetadataForMessage(msgs[i].id, "self");
261
- const replyAttachments: RuntimeAttachmentMetadata[] = linked.map((a) => ({
262
- id: a.id,
263
- filename: a.originalFilename,
264
- mimeType: a.mimeType,
265
- sizeBytes: a.sizeBytes,
266
- kind: a.kind,
267
- }));
268
-
269
- // Include the reply if it has text or attachments
270
- if (rendered.text || replyAttachments.length > 0) {
271
- assistantMessage = {
272
- id: msgs[i].id,
273
- role: 'assistant',
274
- content: rendered.text,
275
- timestamp: new Date(msgs[i].createdAt).toISOString(),
276
- attachments: replyAttachments,
277
- };
278
- }
279
- break;
297
+ async function deliverReplyViaCallback(
298
+ conversationId: string,
299
+ externalChatId: string,
300
+ callbackUrl: string,
301
+ ): Promise<void> {
302
+ const msgs = conversationStore.getMessages(conversationId);
303
+ for (let i = msgs.length - 1; i >= 0; i--) {
304
+ if (msgs[i].role === 'assistant') {
305
+ let parsed: unknown;
306
+ try { parsed = JSON.parse(msgs[i].content); } catch { parsed = msgs[i].content; }
307
+ const rendered = renderHistoryContent(parsed);
308
+
309
+ const linked = attachmentsStore.getAttachmentMetadataForMessage(msgs[i].id);
310
+ const replyAttachments: RuntimeAttachmentMetadata[] = linked.map((a) => ({
311
+ id: a.id,
312
+ filename: a.originalFilename,
313
+ mimeType: a.mimeType,
314
+ sizeBytes: a.sizeBytes,
315
+ kind: a.kind,
316
+ }));
317
+
318
+ if (rendered.text || replyAttachments.length > 0) {
319
+ await deliverChannelReply(callbackUrl, {
320
+ chatId: externalChatId,
321
+ text: rendered.text || undefined,
322
+ attachments: replyAttachments.length > 0 ? replyAttachments : undefined,
323
+ });
280
324
  }
325
+ break;
281
326
  }
282
327
  }
283
-
284
- return Response.json({
285
- accepted: result.accepted,
286
- duplicate: result.duplicate,
287
- eventId: result.eventId,
288
- ...(assistantMessage ? { assistantMessage } : {}),
289
- });
290
328
  }
291
329
 
292
330
  export function handleListDeadLetters(): Response {
293
- const events = channelDeliveryStore.getDeadLetterEvents("self");
331
+ const events = channelDeliveryStore.getDeadLetterEvents();
294
332
  return Response.json({ events });
295
333
  }
296
334
 
@@ -302,7 +340,7 @@ export async function handleReplayDeadLetters(req: Request): Promise<Response> {
302
340
  return Response.json({ error: 'eventIds array is required' }, { status: 400 });
303
341
  }
304
342
 
305
- const replayed = channelDeliveryStore.replayDeadLetters("self", eventIds);
343
+ const replayed = channelDeliveryStore.replayDeadLetters(eventIds);
306
344
  return Response.json({ replayed });
307
345
  }
308
346
 
@@ -326,7 +364,6 @@ export async function handleChannelDeliveryAck(req: Request): Promise<Response>
326
364
  }
327
365
 
328
366
  const acked = channelDeliveryStore.acknowledgeDelivery(
329
- "self",
330
367
  sourceChannel,
331
368
  externalChatId,
332
369
  externalMessageId,
@@ -54,7 +54,7 @@ export function handleListMessages(
54
54
  );
55
55
  }
56
56
 
57
- const mapping = getConversationByKey("self", conversationKey);
57
+ const mapping = getConversationByKey(conversationKey);
58
58
  if (!mapping) {
59
59
  return Response.json({ messages: [] });
60
60
  }
@@ -89,7 +89,7 @@ export function handleListMessages(
89
89
  const messages: RuntimeMessagePayload[] = merged.map((m) => {
90
90
  let msgAttachments: RuntimeAttachmentMetadata[] = [];
91
91
  if (m.role === 'assistant' && m.id) {
92
- const linked = attachmentsStore.getAttachmentMetadataForMessage(m.id, "self");
92
+ const linked = attachmentsStore.getAttachmentMetadataForMessage(m.id);
93
93
  if (linked.length > 0) {
94
94
  msgAttachments = linked.map((a) => ({
95
95
  id: a.id,
@@ -170,7 +170,7 @@ export async function handleSendMessage(
170
170
 
171
171
  // Validate that all attachment IDs resolve
172
172
  if (hasAttachments) {
173
- const resolved = attachmentsStore.getAttachmentsByIds("self", attachmentIds);
173
+ const resolved = attachmentsStore.getAttachmentsByIds(attachmentIds);
174
174
  if (resolved.length !== attachmentIds.length) {
175
175
  const resolvedIds = new Set(resolved.map((a) => a.id));
176
176
  const missing = attachmentIds.filter((id) => !resolvedIds.has(id));
@@ -181,7 +181,7 @@ export async function handleSendMessage(
181
181
  }
182
182
  }
183
183
 
184
- const mapping = getOrCreateConversation("self", conversationKey);
184
+ const mapping = getOrCreateConversation(conversationKey);
185
185
 
186
186
  const processor = deps.persistAndProcessMessage ?? deps.processMessage;
187
187
  if (!processor) {
@@ -247,7 +247,7 @@ export async function handleGetSuggestion(
247
247
  );
248
248
  }
249
249
 
250
- const mapping = getConversationByKey("self", conversationKey);
250
+ const mapping = getConversationByKey(conversationKey);
251
251
  if (!mapping) {
252
252
  return Response.json({ suggestion: null, messageId: null, source: 'none' as const });
253
253
  }
@@ -38,7 +38,7 @@ export async function handleCreateRun(
38
38
  }
39
39
 
40
40
  if (hasAttachments) {
41
- const resolved = attachmentsStore.getAttachmentsByIds("self", attachmentIds);
41
+ const resolved = attachmentsStore.getAttachmentsByIds(attachmentIds);
42
42
  if (resolved.length !== attachmentIds.length) {
43
43
  const resolvedIds = new Set(resolved.map((a) => a.id));
44
44
  const missing = attachmentIds.filter((id) => !resolvedIds.has(id));
@@ -49,7 +49,7 @@ export async function handleCreateRun(
49
49
  }
50
50
  }
51
51
 
52
- const mapping = getOrCreateConversation("self", conversationKey);
52
+ const mapping = getOrCreateConversation(conversationKey);
53
53
 
54
54
  try {
55
55
  const run = await runOrchestrator.startRun(
@@ -14,6 +14,7 @@ import * as runsStore from '../memory/runs-store.js';
14
14
  import type { Run } from '../memory/runs-store.js';
15
15
  import type { Session } from '../daemon/session.js';
16
16
  import type { ServerMessage } from '../daemon/ipc-protocol.js';
17
+ import { resolveChannelCapabilities } from '../daemon/session-runtime-assembly.js';
17
18
  import type { UserDecision } from '../permissions/types.js';
18
19
  import { checkIngressForSecrets } from '../security/secret-ingress.js';
19
20
  import { IngressBlockedError } from '../util/errors.js';
@@ -88,10 +89,11 @@ export class RunOrchestrator {
88
89
 
89
90
  const requestId = crypto.randomUUID();
90
91
  const messageId = session.persistUserMessage(content, attachments, requestId);
91
- const run = runsStore.createRun('self', conversationId, messageId);
92
+ const run = runsStore.createRun(conversationId, messageId);
92
93
 
93
- // Set the assistant ID so attachments are scoped correctly.
94
- session.setAssistantId('self');
94
+ // Runs are always HTTP-originated; set channel capabilities so the attachment
95
+ // scope heuristic resolves to 'self' rather than 'local-assistant'.
96
+ session.setChannelCapabilities(resolveChannelCapabilities('http-api'));
95
97
 
96
98
  // Serialized publish chain so hub subscribers observe events in order.
97
99
  let hubChain: Promise<void> = Promise.resolve();
@@ -110,6 +112,7 @@ export class RunOrchestrator {
110
112
  });
111
113
  };
112
114
 
115
+
113
116
  // Hook into session to intercept confirmation_request events.
114
117
  // When the prompter sends a confirmation_request, we record it in the
115
118
  // run store so the web UI can poll and submit a decision.
@@ -144,6 +147,9 @@ export class RunOrchestrator {
144
147
  // Fire-and-forget the agent loop
145
148
  const cleanup = () => {
146
149
  this.pending.delete(run.id);
150
+ // Reset channel capabilities so a subsequent IPC/desktop session on the
151
+ // same conversation is not incorrectly treated as an HTTP-API client.
152
+ session.setChannelCapabilities(null);
147
153
  // Reset the session's client callback to a no-op so the stale
148
154
  // closure doesn't intercept events from future runs on the same session.
149
155
  // Set hasNoClient=true here since the run is done and no HTTP caller
@@ -0,0 +1,138 @@
1
+ import { Cron } from 'croner';
2
+ import { rrulestr, RRuleSet } from 'rrule';
3
+ import type { ScheduleSyntax } from './recurrence-types.js';
4
+
5
+ export interface ScheduleSpec {
6
+ syntax: ScheduleSyntax;
7
+ expression: string;
8
+ timezone?: string | null;
9
+ }
10
+
11
+ const SUPPORTED_RRULE_PREFIXES = ['DTSTART', 'RRULE:', 'RDATE', 'EXDATE', 'EXRULE'];
12
+
13
+ function normalizeRruleExpression(expression: string): string {
14
+ // Handle escaped newlines from JSON transport
15
+ return expression.replace(/\\n/g, '\n').trim();
16
+ }
17
+
18
+ function parseRruleLines(expression: string): string[] {
19
+ return normalizeRruleExpression(expression)
20
+ .split(/\r?\n/)
21
+ .map(l => l.trim())
22
+ .filter(Boolean);
23
+ }
24
+
25
+ function validateRruleLines(lines: string[]): string | null {
26
+ let hasInclusion = false;
27
+ let hasDtstart = false;
28
+
29
+ for (const line of lines) {
30
+ const upper = line.toUpperCase();
31
+ if (!SUPPORTED_RRULE_PREFIXES.some(p => upper.startsWith(p))) {
32
+ return `Unsupported recurrence line: ${line}`;
33
+ }
34
+ if (upper.startsWith('DTSTART')) hasDtstart = true;
35
+ if (upper.startsWith('RRULE:') || upper.startsWith('RDATE')) hasInclusion = true;
36
+ }
37
+
38
+ if (!hasDtstart) return 'RRULE expression must include DTSTART for deterministic scheduling';
39
+ if (!hasInclusion) return 'RRULE expression must include at least one RRULE or RDATE';
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * Detect whether an RRULE expression contains set constructs (RDATE, EXDATE,
45
+ * EXRULE, or multiple RRULE lines) that require RRuleSet parsing.
46
+ */
47
+ export function hasSetConstructs(expression: string): boolean {
48
+ const lines = parseRruleLines(expression);
49
+ let rruleCount = 0;
50
+ for (const line of lines) {
51
+ const upper = line.toUpperCase();
52
+ if (upper.startsWith('RDATE') || upper.startsWith('EXDATE') || upper.startsWith('EXRULE')) return true;
53
+ if (upper.startsWith('RRULE:')) rruleCount++;
54
+ }
55
+ return rruleCount > 1;
56
+ }
57
+
58
+ /**
59
+ * Validate RRULE set lines in an expression. Returns null if valid, or an
60
+ * actionable error string describing the problem. This is intended for tool
61
+ * layers that want to surface a specific error message before calling the
62
+ * store.
63
+ */
64
+ export function validateRruleSetLines(expression: string): string | null {
65
+ const lines = parseRruleLines(expression);
66
+ return validateRruleLines(lines);
67
+ }
68
+
69
+ /**
70
+ * Validate a schedule expression. Returns true if the expression is valid
71
+ * for the given syntax, false otherwise.
72
+ */
73
+ export function isValidScheduleExpression(spec: ScheduleSpec): boolean {
74
+ try {
75
+ if (spec.syntax === 'cron') {
76
+ new Cron(spec.expression, { maxRuns: 0 });
77
+ return true;
78
+ }
79
+
80
+ if (spec.syntax === 'rrule') {
81
+ const lines = parseRruleLines(spec.expression);
82
+ const error = validateRruleLines(lines);
83
+ if (error) return false;
84
+
85
+ const normalized = normalizeRruleExpression(spec.expression);
86
+ const tzid = spec.timezone ?? undefined;
87
+ if (hasSetConstructs(normalized)) {
88
+ rrulestr(normalized, { forceset: true, tzid });
89
+ } else {
90
+ rrulestr(normalized, { tzid });
91
+ }
92
+ return true;
93
+ }
94
+
95
+ return false;
96
+ } catch {
97
+ return false;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Compute the next run timestamp (epoch ms) for a schedule expression.
103
+ * Throws if no future runs exist.
104
+ */
105
+ export function computeNextRunAt(spec: ScheduleSpec, nowMs?: number): number {
106
+ const now = nowMs ?? Date.now();
107
+
108
+ if (spec.syntax === 'cron') {
109
+ const cron = new Cron(spec.expression, {
110
+ timezone: spec.timezone ?? undefined,
111
+ });
112
+ const next = cron.nextRun(new Date(now));
113
+ if (!next) {
114
+ throw new Error(`Cron expression "${spec.expression}" has no upcoming runs`);
115
+ }
116
+ return next.getTime();
117
+ }
118
+
119
+ if (spec.syntax === 'rrule') {
120
+ const normalized = normalizeRruleExpression(spec.expression);
121
+ const lines = parseRruleLines(normalized);
122
+ const error = validateRruleLines(lines);
123
+ if (error) throw new Error(error);
124
+
125
+ const useSet = hasSetConstructs(normalized);
126
+ const tzid = spec.timezone ?? undefined;
127
+ const parsed = useSet
128
+ ? (rrulestr(normalized, { forceset: true, tzid }) as RRuleSet)
129
+ : rrulestr(normalized, { tzid });
130
+ const next = parsed.after(new Date(now));
131
+ if (!next) {
132
+ throw new Error(`RRULE expression has no upcoming runs after ${new Date(now).toISOString()}`);
133
+ }
134
+ return next.getTime();
135
+ }
136
+
137
+ throw new Error(`Unsupported schedule syntax: ${spec.syntax}`);
138
+ }
@@ -0,0 +1,67 @@
1
+ export type ScheduleSyntax = 'cron' | 'rrule';
2
+
3
+ /**
4
+ * Detect whether an expression string is cron or RRULE syntax.
5
+ * Returns null for ambiguous or invalid expressions.
6
+ */
7
+ export function detectScheduleSyntax(expression: string): ScheduleSyntax | null {
8
+ if (!expression || typeof expression !== 'string') return null;
9
+ const trimmed = expression.trim();
10
+ if (!trimmed) return null;
11
+
12
+ // RRULE detection: starts with RRULE:, DTSTART, or contains FREQ=
13
+ if (/^(RRULE:|DTSTART)/m.test(trimmed) || /FREQ=/i.test(trimmed)) {
14
+ return 'rrule';
15
+ }
16
+
17
+ // Cron detection: 5 space-separated fields
18
+ const fields = trimmed.split(/\s+/);
19
+ if (fields.length === 5) {
20
+ // Basic sanity check: each field should match cron-like characters
21
+ const cronFieldPattern = /^[\d\*\/\-\,\?LW#]+$/;
22
+ if (fields.every(f => cronFieldPattern.test(f))) {
23
+ return 'cron';
24
+ }
25
+ }
26
+
27
+ return null;
28
+ }
29
+
30
+ /**
31
+ * Normalize schedule syntax from tool/API inputs.
32
+ * Resolution order:
33
+ * 1. If explicit `syntax` is provided, use it
34
+ * 2. If `expression` is provided, auto-detect from expression
35
+ * 3. If `legacyCronExpression` is provided, treat as cron
36
+ * 4. Return null if nothing resolved
37
+ */
38
+ export function normalizeScheduleSyntax(input: {
39
+ syntax?: ScheduleSyntax;
40
+ expression?: string;
41
+ legacyCronExpression?: string;
42
+ }): { syntax: ScheduleSyntax; expression: string } | null {
43
+ // Explicit syntax + expression
44
+ if (input.syntax && input.expression) {
45
+ return { syntax: input.syntax, expression: input.expression };
46
+ }
47
+
48
+ // Auto-detect from expression
49
+ if (input.expression) {
50
+ const detected = detectScheduleSyntax(input.expression);
51
+ if (detected) {
52
+ return { syntax: detected, expression: input.expression };
53
+ }
54
+ // If we have an explicit syntax but couldn't detect, trust the explicit syntax
55
+ if (input.syntax) {
56
+ return { syntax: input.syntax, expression: input.expression };
57
+ }
58
+ return null;
59
+ }
60
+
61
+ // Legacy cron_expression fallback
62
+ if (input.legacyCronExpression) {
63
+ return { syntax: 'cron', expression: input.legacyCronExpression };
64
+ }
65
+
66
+ return null;
67
+ }