@vellumai/assistant 0.8.1 → 0.8.3

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 (630) hide show
  1. package/ARCHITECTURE.md +13 -19
  2. package/Dockerfile +75 -1
  3. package/bun.lock +11 -1
  4. package/docker-entrypoint.sh +17 -0
  5. package/docker-init-apt-root.sh +167 -0
  6. package/docker-kata-apt-env.sh +39 -0
  7. package/docs/plugins.md +88 -47
  8. package/docs/skills.md +9 -7
  9. package/examples/plugins/echo/README.md +27 -27
  10. package/examples/plugins/echo/package.json +3 -0
  11. package/examples/plugins/echo/register.ts +31 -31
  12. package/node_modules/@vellumai/slack-text/src/index.test.ts +114 -14
  13. package/node_modules/@vellumai/slack-text/src/index.ts +82 -18
  14. package/openapi.yaml +642 -5
  15. package/package.json +3 -1
  16. package/scripts/generate-openapi.ts +83 -10
  17. package/scripts/sync-llm-catalog.ts +2 -2
  18. package/scripts/sync-web-search-catalog.ts +47 -25
  19. package/src/__tests__/agent-image-optimize.test.ts +11 -3
  20. package/src/__tests__/agent-loop-exit-reason.test.ts +272 -0
  21. package/src/__tests__/agent-loop-provider-error-recording.test.ts +195 -0
  22. package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +131 -0
  23. package/src/__tests__/anthropic-provider.test.ts +45 -0
  24. package/src/__tests__/app-builder-tool-scripts.test.ts +9 -3
  25. package/src/__tests__/app-executors.test.ts +220 -4
  26. package/src/__tests__/auto-analysis-end-to-end.test.ts +35 -0
  27. package/src/__tests__/bundled-asset.test.ts +6 -6
  28. package/src/__tests__/channel-availability-routes.test.ts +206 -0
  29. package/src/__tests__/channel-delivery-store.test.ts +289 -1
  30. package/src/__tests__/circuit-breaker-pipeline.test.ts +0 -1
  31. package/src/__tests__/clawhub.test.ts +75 -16
  32. package/src/__tests__/compactor-tail-resolution.test.ts +147 -0
  33. package/src/__tests__/config-get-vision-flag.test.ts +136 -0
  34. package/src/__tests__/config-loader-backfill.test.ts +115 -18
  35. package/src/__tests__/config-schema.test.ts +21 -0
  36. package/src/__tests__/config-set-route.test.ts +80 -0
  37. package/src/__tests__/config-sounds-sync.test.ts +97 -0
  38. package/src/__tests__/config-watcher-skill-reseed.test.ts +453 -0
  39. package/src/__tests__/context-search-conversations-source.test.ts +117 -2
  40. package/src/__tests__/context-search-memory-v2-source.test.ts +0 -1
  41. package/src/__tests__/context-search-workspace-source.test.ts +7 -0
  42. package/src/__tests__/context-token-estimator.test.ts +31 -65
  43. package/src/__tests__/conversation-abort-tool-results.test.ts +4 -1
  44. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -0
  45. package/src/__tests__/conversation-agent-loop-overflow.test.ts +92 -92
  46. package/src/__tests__/conversation-agent-loop.test.ts +59 -1
  47. package/src/__tests__/conversation-error.test.ts +42 -3
  48. package/src/__tests__/conversation-fork-crud.test.ts +82 -0
  49. package/src/__tests__/conversation-inference-profile-route.test.ts +40 -4
  50. package/src/__tests__/conversation-lifecycle.test.ts +173 -0
  51. package/src/__tests__/conversation-media-retry.test.ts +19 -8
  52. package/src/__tests__/conversation-message-sync-tags.test.ts +97 -0
  53. package/src/__tests__/conversation-pairing.test.ts +54 -0
  54. package/src/__tests__/conversation-process-callsite.test.ts +4 -1
  55. package/src/__tests__/conversation-provider-retry-repair.test.ts +5 -1
  56. package/src/__tests__/conversation-queue.test.ts +4 -1
  57. package/src/__tests__/conversation-runtime-assembly.test.ts +102 -13
  58. package/src/__tests__/conversation-slash-queue.test.ts +59 -1
  59. package/src/__tests__/conversation-slash-unknown.test.ts +4 -1
  60. package/src/__tests__/conversation-surfaces-table-action.test.ts +360 -0
  61. package/src/__tests__/conversation-sync-tags.test.ts +235 -0
  62. package/src/__tests__/conversation-workspace-injection.test.ts +5 -1
  63. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +5 -1
  64. package/src/__tests__/credential-security-invariants.test.ts +3 -2
  65. package/src/__tests__/date-context.test.ts +45 -0
  66. package/src/__tests__/db-slack-external-content-normalization.test.ts +301 -0
  67. package/src/__tests__/delete-managed-skill-tool.test.ts +55 -13
  68. package/src/__tests__/disk-pressure-tools.test.ts +1 -0
  69. package/src/__tests__/dm-backfill.test.ts +121 -10
  70. package/src/__tests__/document-tool-security.test.ts +258 -0
  71. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +0 -1
  72. package/src/__tests__/edit-propagation.test.ts +33 -0
  73. package/src/__tests__/empty-response-pipeline.test.ts +0 -4
  74. package/src/__tests__/external-plugin-loader.test.ts +151 -55
  75. package/src/__tests__/filing-service.test.ts +140 -0
  76. package/src/__tests__/get-skill-detail-audit.test.ts +0 -4
  77. package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +0 -1
  78. package/src/__tests__/guardian-dispatch.test.ts +1 -0
  79. package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +43 -62
  80. package/src/__tests__/heartbeat-service.test.ts +24 -164
  81. package/src/__tests__/helpers/channel-test-adapter.ts +0 -2
  82. package/src/__tests__/helpers/tar-fixtures.ts +39 -0
  83. package/src/__tests__/helpers/wait-for.ts +21 -0
  84. package/src/__tests__/history-repair-pipeline.test.ts +0 -3
  85. package/src/__tests__/history-repair.test.ts +73 -0
  86. package/src/__tests__/host-app-control-proxy.test.ts +507 -10
  87. package/src/__tests__/host-proxy-preactivation.test.ts +200 -13
  88. package/src/__tests__/image-credentials.test.ts +1 -1
  89. package/src/__tests__/inbound-slack-persistence.test.ts +2 -0
  90. package/src/__tests__/inference-no-mode-boot-e2e.test.ts +1 -1
  91. package/src/__tests__/inference-profile-reaper.test.ts +4 -2
  92. package/src/__tests__/inference-profile-session-handler.test.ts +18 -6
  93. package/src/__tests__/inference-profile-session-ipc.test.ts +17 -5
  94. package/src/__tests__/injector-background-turn.test.ts +153 -0
  95. package/src/__tests__/injector-chain.test.ts +15 -8
  96. package/src/__tests__/install-skill-routing.test.ts +155 -37
  97. package/src/__tests__/lifecycle-memory-v2-seed.test.ts +99 -3
  98. package/src/__tests__/list-messages-page-latest.test.ts +55 -0
  99. package/src/__tests__/llm-call-pipeline.test.ts +0 -3
  100. package/src/__tests__/llm-callsite-catalog.test.ts +25 -0
  101. package/src/__tests__/llm-catalog-parity.test.ts +58 -13
  102. package/src/__tests__/llm-request-log-agent-loop-exit-reason.test.ts +116 -0
  103. package/src/__tests__/llm-request-log-error-payload.test.ts +138 -0
  104. package/src/__tests__/llm-request-log-source-clickhouse.test.ts +36 -0
  105. package/src/__tests__/llm-request-log-source-factory.test.ts +29 -53
  106. package/src/__tests__/llm-resolver.test.ts +255 -2
  107. package/src/__tests__/llm-usage-store.test.ts +114 -0
  108. package/src/__tests__/managed-profile-guard.test.ts +41 -29
  109. package/src/__tests__/managed-skill-lifecycle.test.ts +109 -18
  110. package/src/__tests__/managed-store.test.ts +84 -192
  111. package/src/__tests__/media-generate-image.test.ts +1 -1
  112. package/src/__tests__/memory-retrieval-pipeline.test.ts +0 -2
  113. package/src/__tests__/messages-after-tiebreaker.test.ts +122 -0
  114. package/src/__tests__/notification-decision-fallback.test.ts +0 -91
  115. package/src/__tests__/notification-decision-strategy.test.ts +14 -31
  116. package/src/__tests__/notification-deep-link.test.ts +15 -0
  117. package/src/__tests__/notification-guardian-path.test.ts +1 -2
  118. package/src/__tests__/notification-platform-adapter.test.ts +5 -4
  119. package/src/__tests__/notification-telegram-adapter.test.ts +1 -0
  120. package/src/__tests__/notification-vellum-adapter.test.ts +113 -0
  121. package/src/__tests__/oauth-commands-routes.test.ts +168 -16
  122. package/src/__tests__/oauth-provider-profiles.test.ts +9 -0
  123. package/src/__tests__/openai-provider.test.ts +242 -3
  124. package/src/__tests__/openai-responses-cutover-guard.test.ts +17 -9
  125. package/src/__tests__/openrouter-provider-only.test.ts +51 -3
  126. package/src/__tests__/openrouter-token-estimation.test.ts +34 -25
  127. package/src/__tests__/overflow-reduce-pipeline.test.ts +0 -2
  128. package/src/__tests__/persistence-pipeline.test.ts +0 -2
  129. package/src/__tests__/{managed-proxy-context.test.ts → platform-proxy-context.test.ts} +7 -2
  130. package/src/__tests__/platform.test.ts +2 -0
  131. package/src/__tests__/plugin-api-shim.test.ts +125 -0
  132. package/src/__tests__/plugin-bootstrap.test.ts +10 -36
  133. package/src/__tests__/plugin-external-api.test.ts +68 -0
  134. package/src/__tests__/plugin-registry.test.ts +0 -77
  135. package/src/__tests__/plugin-route-contribution.test.ts +0 -1
  136. package/src/__tests__/plugin-skill-contribution.test.ts +0 -2
  137. package/src/__tests__/plugin-tool-contribution.test.ts +16 -15
  138. package/src/__tests__/plugin-types.test.ts +3 -13
  139. package/src/__tests__/process-message-background-slack.test.ts +8 -1
  140. package/src/__tests__/process-message-display-content.test.ts +421 -0
  141. package/src/__tests__/provider-catalog-visibility.test.ts +158 -0
  142. package/src/__tests__/provider-error-scenarios.test.ts +111 -0
  143. package/src/__tests__/{provider-managed-proxy-integration.test.ts → provider-platform-proxy-integration.test.ts} +33 -31
  144. package/src/__tests__/scaffold-managed-skill-tool.test.ts +65 -13
  145. package/src/__tests__/schedule-routes.test.ts +50 -3
  146. package/src/__tests__/schedule-store.test.ts +94 -0
  147. package/src/__tests__/scheduler-reuse-conversation.test.ts +54 -7
  148. package/src/__tests__/schema-transforms.test.ts +20 -0
  149. package/src/__tests__/search-skills-unified.test.ts +0 -5
  150. package/src/__tests__/{secret-routes-managed-proxy.test.ts → secret-routes-platform-proxy.test.ts} +1 -1
  151. package/src/__tests__/server-history-render.test.ts +43 -0
  152. package/src/__tests__/skill-load-feature-flag.test.ts +0 -12
  153. package/src/__tests__/skill-load-tool.test.ts +27 -89
  154. package/src/__tests__/skill-memory.test.ts +23 -3
  155. package/src/__tests__/skills-file-content-endpoint.test.ts +9 -38
  156. package/src/__tests__/skills-files-catalog-fallback.test.ts +0 -3
  157. package/src/__tests__/skills-install-extract.test.ts +49 -38
  158. package/src/__tests__/skills-install-staging.test.ts +159 -0
  159. package/src/__tests__/skills-uninstall.test.ts +9 -41
  160. package/src/__tests__/skills.test.ts +51 -58
  161. package/src/__tests__/slack-channel-config.test.ts +9 -0
  162. package/src/__tests__/subagent-tool-filtering.test.ts +50 -0
  163. package/src/__tests__/system-prompt.test.ts +670 -63
  164. package/src/__tests__/terminal-tools.test.ts +28 -1
  165. package/src/__tests__/thread-backfill.test.ts +557 -27
  166. package/src/__tests__/title-generate-pipeline.test.ts +0 -13
  167. package/src/__tests__/token-estimate-pipeline.test.ts +0 -3
  168. package/src/__tests__/tool-error-pipeline.test.ts +0 -3
  169. package/src/__tests__/tool-execute-pipeline.test.ts +0 -5
  170. package/src/__tests__/tool-executor-lifecycle-events.test.ts +1 -1
  171. package/src/__tests__/tool-executor.test.ts +16 -4
  172. package/src/__tests__/tool-result-truncate-pipeline.test.ts +0 -12
  173. package/src/__tests__/turn-events-store.test.ts +256 -0
  174. package/src/__tests__/twilio-routes.test.ts +4 -0
  175. package/src/__tests__/user-plugin-loader.test.ts +0 -7
  176. package/src/__tests__/voice-session-bridge.test.ts +198 -0
  177. package/src/__tests__/web-search-catalog-parity.test.ts +32 -10
  178. package/src/__tests__/workspace-migration-057-repair-stale-gemini-model-ids.test.ts +115 -3
  179. package/src/__tests__/workspace-migration-072-seed-reply-suggestion-callsite.test.ts +50 -0
  180. package/src/__tests__/workspace-migration-073-repair-recall-callsite-empty-profile.test.ts +153 -0
  181. package/src/__tests__/workspace-migration-085-memory-v2-bm25-b-reembed-disabled-v2-pages.test.ts +220 -0
  182. package/src/__tests__/workspace-migration-086-revert-stale-gemini-mis-rewrites.test.ts +269 -0
  183. package/src/__tests__/workspace-migration-087-memory-router-balanced-profile.test.ts +228 -0
  184. package/src/__tests__/workspace-migration-remove-legacy-skills-index.test.ts +309 -0
  185. package/src/__tests__/workspace-migrations-runner.test.ts +111 -3
  186. package/src/a2a/__tests__/agent-card.test.ts +98 -0
  187. package/src/a2a/__tests__/e2e-a2a-channel.test.ts +597 -0
  188. package/src/a2a/__tests__/protocol-helpers.test.ts +113 -0
  189. package/src/a2a/__tests__/task-store.test.ts +246 -0
  190. package/src/a2a/agent-card.ts +58 -0
  191. package/src/a2a/feature-gate.ts +8 -0
  192. package/src/a2a/protocol-constants.ts +21 -0
  193. package/src/a2a/protocol-errors.ts +50 -0
  194. package/src/a2a/protocol-types.ts +162 -0
  195. package/src/a2a/task-store.ts +168 -0
  196. package/src/acp/resolve-agent.ts +1 -1
  197. package/src/agent/image-optimize.ts +13 -5
  198. package/src/agent/loop.ts +167 -18
  199. package/src/calls/voice-session-bridge.ts +61 -42
  200. package/src/channels/config.ts +9 -0
  201. package/src/channels/types.ts +122 -0
  202. package/src/cli/__tests__/unknown-command.test.ts +24 -0
  203. package/src/cli/commands/__tests__/changelog.test.ts +304 -319
  204. package/src/cli/{__tests__ → commands/__tests__}/notifications.test.ts +201 -28
  205. package/src/cli/commands/__tests__/schedules.test.ts +960 -0
  206. package/src/cli/commands/changelog.ts +106 -42
  207. package/src/cli/commands/conversations.ts +102 -17
  208. package/src/cli/commands/default-action.ts +10 -53
  209. package/src/cli/commands/notifications.ts +388 -346
  210. package/src/cli/commands/plugins.ts +252 -0
  211. package/src/cli/commands/schedules.ts +683 -0
  212. package/src/cli/commands/telemetry.ts +40 -0
  213. package/src/cli/lib/__tests__/cli-colors.test.ts +48 -0
  214. package/src/cli/lib/__tests__/confirm-prompt.test.ts +159 -0
  215. package/src/cli/lib/__tests__/install-from-github.test.ts +355 -0
  216. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +154 -0
  217. package/src/cli/lib/__tests__/search-plugins.test.ts +261 -0
  218. package/src/cli/lib/__tests__/uninstall-plugin.test.ts +124 -0
  219. package/src/cli/lib/__tests__/unknown-command.test.ts +106 -0
  220. package/src/cli/lib/cli-colors.ts +12 -0
  221. package/src/cli/lib/confirm-prompt.ts +79 -0
  222. package/src/cli/lib/install-from-github.ts +303 -0
  223. package/src/cli/lib/list-installed-plugins.ts +137 -0
  224. package/src/cli/lib/search-plugins.ts +163 -0
  225. package/src/cli/lib/uninstall-plugin.ts +82 -0
  226. package/src/cli/lib/unknown-command.ts +111 -0
  227. package/src/cli/program.ts +52 -2
  228. package/src/config/assistant-feature-flags.ts +24 -54
  229. package/src/config/bundled-skills/app-builder/SKILL.md +140 -22
  230. package/src/config/bundled-skills/app-builder/TOOLS.json +7 -0
  231. package/src/config/bundled-skills/computer-use/TOOLS.json +15 -52
  232. package/src/config/bundled-skills/document/SKILL.md +23 -3
  233. package/src/config/bundled-skills/document/TOOLS.json +53 -0
  234. package/src/config/bundled-skills/document/tools/document-delete.ts +12 -0
  235. package/src/config/bundled-skills/document/tools/document-list.ts +12 -0
  236. package/src/config/bundled-skills/document/tools/document-read.ts +12 -0
  237. package/src/config/bundled-skills/phone-calls/SKILL.md +1 -1
  238. package/src/config/bundled-skills/skill-management/SKILL.md +2 -2
  239. package/src/config/bundled-skills/skill-management/TOOLS.json +7 -7
  240. package/src/config/bundled-tool-registry.ts +6 -0
  241. package/src/config/call-site-defaults.ts +105 -0
  242. package/src/config/feature-flag-registry.json +41 -9
  243. package/src/config/llm-resolver.ts +52 -1
  244. package/src/config/loader.ts +64 -38
  245. package/src/config/schema.ts +9 -10
  246. package/src/config/schemas/__tests__/llm-request-logs.test.ts +36 -0
  247. package/src/config/schemas/__tests__/memory-v2.test.ts +3 -3
  248. package/src/config/schemas/channels.ts +17 -0
  249. package/src/config/schemas/compaction.ts +28 -0
  250. package/src/config/schemas/conversations.ts +10 -0
  251. package/src/config/schemas/heartbeat.ts +23 -0
  252. package/src/config/schemas/llm-request-logs.ts +31 -7
  253. package/src/config/schemas/llm.ts +1 -0
  254. package/src/config/schemas/memory-retrieval.ts +18 -0
  255. package/src/config/schemas/memory-retrospective.ts +1 -1
  256. package/src/config/schemas/memory-v2.ts +4 -4
  257. package/src/config/schemas/memory.ts +3 -1
  258. package/src/config/schemas/tools.ts +14 -0
  259. package/src/config/seed-inference-profiles.ts +99 -29
  260. package/src/config/skills.ts +3 -96
  261. package/src/context/compactor.ts +1107 -0
  262. package/src/context/token-estimator.ts +34 -36
  263. package/src/context/window-manager.ts +197 -1520
  264. package/src/credential-execution/managed-catalog.ts +37 -0
  265. package/src/credential-health/credential-health-service.ts +280 -19
  266. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +33 -18
  267. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +138 -0
  268. package/src/daemon/__tests__/conversation-tool-setup.test.ts +74 -0
  269. package/src/daemon/approval-generators.ts +8 -6
  270. package/src/daemon/config-watcher.ts +94 -31
  271. package/src/daemon/conversation-agent-loop-handlers.ts +78 -0
  272. package/src/daemon/conversation-agent-loop.ts +198 -11
  273. package/src/daemon/conversation-error.ts +171 -37
  274. package/src/daemon/conversation-lifecycle.ts +53 -40
  275. package/src/daemon/conversation-messaging.ts +25 -6
  276. package/src/daemon/conversation-process.ts +49 -12
  277. package/src/daemon/conversation-runtime-assembly.ts +25 -1
  278. package/src/daemon/conversation-slash.ts +12 -5
  279. package/src/daemon/conversation-store.ts +11 -4
  280. package/src/daemon/conversation-tool-setup.ts +39 -7
  281. package/src/daemon/conversation.ts +33 -8
  282. package/src/daemon/date-context.ts +40 -0
  283. package/src/daemon/external-plugins-bootstrap.ts +217 -181
  284. package/src/daemon/first-greeting.ts +22 -2
  285. package/src/daemon/guardian-action-generators.ts +1 -125
  286. package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +248 -0
  287. package/src/daemon/handlers/__tests__/config-a2a-invite.test.ts +154 -0
  288. package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +133 -0
  289. package/src/daemon/handlers/__tests__/config-a2a.test.ts +95 -0
  290. package/src/daemon/handlers/config-a2a.ts +289 -0
  291. package/src/daemon/handlers/config-model.ts +6 -5
  292. package/src/daemon/handlers/config-slack-channel.ts +15 -3
  293. package/src/daemon/handlers/conversations.ts +1 -0
  294. package/src/daemon/handlers/shared.ts +14 -5
  295. package/src/daemon/handlers/skills.ts +111 -108
  296. package/src/daemon/history-repair.ts +28 -1
  297. package/src/daemon/host-app-control-proxy.ts +153 -27
  298. package/src/daemon/host-proxy-preactivation.ts +85 -18
  299. package/src/daemon/lifecycle.ts +89 -91
  300. package/src/daemon/meet-host-supervisor.ts +5 -4
  301. package/src/daemon/memory-v2-startup.ts +85 -0
  302. package/src/daemon/message-protocol.ts +1 -0
  303. package/src/daemon/message-types/conversations.ts +25 -0
  304. package/src/daemon/message-types/messages.ts +61 -0
  305. package/src/daemon/message-types/notifications.ts +21 -0
  306. package/src/daemon/message-types/subagents.ts +1 -0
  307. package/src/daemon/message-types/sync.ts +1 -0
  308. package/src/daemon/pkb-reminder-builder.test.ts +11 -54
  309. package/src/daemon/pkb-reminder-builder.ts +5 -20
  310. package/src/daemon/plugin-source-watcher.ts +146 -0
  311. package/src/daemon/process-message.ts +24 -3
  312. package/src/daemon/server.ts +11 -2
  313. package/src/daemon/skill-memory-refresh.ts +33 -0
  314. package/src/daemon/wake-target-adapter.ts +2 -0
  315. package/src/documents/document-store.ts +221 -3
  316. package/src/embedded/plugin-api.ts +40 -0
  317. package/src/export/__tests__/transcript-formatter.test.ts +121 -0
  318. package/src/export/transcript-formatter.ts +54 -20
  319. package/src/filing/filing-service.ts +39 -0
  320. package/src/heartbeat/__tests__/heartbeat-service.test.ts +135 -6
  321. package/src/heartbeat/heartbeat-run-store.ts +2 -1
  322. package/src/heartbeat/heartbeat-service.ts +73 -189
  323. package/src/home/__tests__/feed-types.test.ts +80 -0
  324. package/src/home/feed-types.ts +36 -2
  325. package/src/home/post-connect-feed.ts +1 -0
  326. package/src/index.ts +18 -1
  327. package/src/ipc/cli-client.ts +147 -45
  328. package/src/live-voice/__tests__/live-voice-stt.test.ts +57 -0
  329. package/src/mcp/client.ts +20 -4
  330. package/src/media/image-credentials.ts +3 -3
  331. package/src/memory/__tests__/bookmark-crud.test.ts +33 -27
  332. package/src/memory/__tests__/conversation-queries.test.ts +483 -0
  333. package/src/memory/__tests__/jobs-worker-v2-graph-trigger-embed.test.ts +113 -0
  334. package/src/memory/__tests__/memory-retrospective-enqueue.test.ts +2 -50
  335. package/src/memory/__tests__/memory-retrospective-job.test.ts +87 -4
  336. package/src/memory/__tests__/memory-retrospective-startup-cleanup.test.ts +119 -14
  337. package/src/memory/__tests__/message-content.test.ts +35 -0
  338. package/src/memory/bookmark-crud.ts +42 -10
  339. package/src/memory/context-search/sources/conversations.ts +62 -2
  340. package/src/memory/context-search/sources/workspace.ts +4 -0
  341. package/src/memory/conversation-crud.ts +63 -19
  342. package/src/memory/conversation-queries.ts +197 -11
  343. package/src/memory/conversation-title-service.ts +26 -4
  344. package/src/memory/db-init.ts +12 -0
  345. package/src/memory/delivery-crud.ts +152 -5
  346. package/src/memory/embedding-backend.ts +4 -4
  347. package/src/memory/external-conversation-store.ts +66 -5
  348. package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +150 -12
  349. package/src/memory/graph/conversation-graph-memory.ts +49 -21
  350. package/src/memory/graph/tools.ts +9 -40
  351. package/src/memory/indexer.ts +34 -29
  352. package/src/memory/invite-store.ts +53 -0
  353. package/src/memory/jobs/__tests__/embed-concept-page.test.ts +73 -0
  354. package/src/memory/jobs/embed-concept-page.ts +20 -11
  355. package/src/memory/jobs-worker.ts +6 -1
  356. package/src/memory/llm-request-log-source-clickhouse.ts +24 -12
  357. package/src/memory/llm-request-log-source.ts +19 -52
  358. package/src/memory/llm-request-log-store.ts +92 -1
  359. package/src/memory/llm-usage-store.ts +125 -5
  360. package/src/memory/memory-retrospective-enqueue.ts +1 -20
  361. package/src/memory/memory-retrospective-job.ts +33 -6
  362. package/src/memory/memory-retrospective-startup-cleanup.ts +72 -5
  363. package/src/memory/message-content.ts +1 -1
  364. package/src/memory/migrations/109-external-conversation-bindings.ts +15 -4
  365. package/src/memory/migrations/229-delete-private-conversations.test.ts +38 -1
  366. package/src/memory/migrations/229-delete-private-conversations.ts +7 -0
  367. package/src/memory/migrations/247-external-conversation-binding-thread-id.ts +78 -0
  368. package/src/memory/migrations/248-create-onboarding-events.ts +21 -0
  369. package/src/memory/migrations/249-normalize-slack-external-content.ts +240 -0
  370. package/src/memory/migrations/250-provider-connection-base-url-and-models.ts +28 -0
  371. package/src/memory/migrations/251-a2a-tasks.ts +49 -0
  372. package/src/memory/migrations/252-llm-request-log-agent-loop-exit-reason.ts +32 -0
  373. package/src/memory/migrations/index.ts +9 -0
  374. package/src/memory/migrations/registry.ts +16 -0
  375. package/src/memory/onboarding-events-store.ts +106 -0
  376. package/src/memory/schema/a2a.ts +15 -0
  377. package/src/memory/schema/bookmarks.ts +0 -2
  378. package/src/memory/schema/calls.ts +1 -0
  379. package/src/memory/schema/index.ts +1 -0
  380. package/src/memory/schema/inference.ts +3 -3
  381. package/src/memory/schema/infrastructure.ts +13 -0
  382. package/src/memory/turn-events-store.ts +127 -2
  383. package/src/memory/v2/__tests__/activation-store.test.ts +25 -23
  384. package/src/memory/v2/__tests__/activation.test.ts +0 -8
  385. package/src/memory/v2/__tests__/cli-command-store.test.ts +404 -0
  386. package/src/memory/v2/__tests__/frontmatter-sweep.test.ts +25 -4
  387. package/src/memory/v2/__tests__/injection.test.ts +288 -11
  388. package/src/memory/v2/__tests__/migration.test.ts +87 -0
  389. package/src/memory/v2/__tests__/page-index.test.ts +83 -0
  390. package/src/memory/v2/__tests__/prompts-router.test.ts +58 -6
  391. package/src/memory/v2/__tests__/qdrant.test.ts +66 -3
  392. package/src/memory/v2/__tests__/router.test.ts +15 -0
  393. package/src/memory/v2/__tests__/skill-store.test.ts +387 -8
  394. package/src/memory/v2/__tests__/static-context.test.ts +12 -1
  395. package/src/memory/v2/activation-store.ts +14 -16
  396. package/src/memory/v2/cli-command-content.ts +19 -0
  397. package/src/memory/v2/cli-command-store.ts +304 -0
  398. package/src/memory/v2/frontmatter-sweep.ts +7 -1
  399. package/src/memory/v2/injection.ts +81 -26
  400. package/src/memory/v2/migration.ts +49 -19
  401. package/src/memory/v2/page-index.ts +63 -8
  402. package/src/memory/v2/prompts/router.ts +11 -8
  403. package/src/memory/v2/prompts/sweep.ts +2 -2
  404. package/src/memory/v2/qdrant.ts +135 -7
  405. package/src/memory/v2/router.ts +9 -8
  406. package/src/memory/v2/skill-store.ts +120 -35
  407. package/src/memory/v2/static-context.ts +4 -4
  408. package/src/memory/v2/types.ts +23 -0
  409. package/src/messaging/providers/a2a/__tests__/deliver.test.ts +274 -0
  410. package/src/messaging/providers/a2a/deliver.ts +156 -0
  411. package/src/messaging/providers/gmail/client.ts +9 -2
  412. package/src/messaging/providers/index.ts +11 -2
  413. package/src/messaging/providers/slack/__tests__/adapter-token-routing.test.ts +45 -5
  414. package/src/messaging/providers/slack/__tests__/download.test.ts +231 -0
  415. package/src/messaging/providers/slack/adapter.ts +43 -5
  416. package/src/messaging/providers/slack/client.ts +27 -0
  417. package/src/messaging/providers/slack/deep-link.ts +65 -0
  418. package/src/messaging/providers/slack/download.ts +104 -0
  419. package/src/messaging/providers/slack/message-metadata.test.ts +32 -0
  420. package/src/messaging/providers/slack/message-metadata.ts +27 -0
  421. package/src/messaging/providers/slack/render-transcript.test.ts +134 -0
  422. package/src/messaging/providers/slack/render-transcript.ts +69 -5
  423. package/src/messaging/providers/slack/types.ts +20 -1
  424. package/src/notifications/__tests__/broadcaster.test.ts +203 -0
  425. package/src/notifications/__tests__/decision-engine.test.ts +283 -0
  426. package/src/notifications/__tests__/deterministic-checks.test.ts +286 -0
  427. package/src/notifications/__tests__/emit-signal-home-feed.test.ts +1 -0
  428. package/src/notifications/__tests__/home-feed-side-effect.test.ts +430 -7
  429. package/src/notifications/adapters/macos.ts +12 -2
  430. package/src/notifications/broadcaster.ts +29 -4
  431. package/src/notifications/conversation-pairing.ts +2 -1
  432. package/src/notifications/copy-composer.ts +17 -64
  433. package/src/notifications/decision-engine.ts +113 -45
  434. package/src/notifications/deterministic-checks.ts +96 -0
  435. package/src/notifications/emit-signal.ts +21 -1
  436. package/src/notifications/home-feed-side-effect.ts +138 -5
  437. package/src/notifications/signal.ts +3 -5
  438. package/src/notifications/types.ts +8 -0
  439. package/src/oauth/connection-resolver.ts +8 -4
  440. package/src/oauth/platform-connection.test.ts +43 -3
  441. package/src/oauth/platform-connection.ts +19 -6
  442. package/src/oauth/seed-providers.ts +10 -1
  443. package/src/permissions/checker.ts +2 -0
  444. package/src/permissions/ipc-risk-types.ts +1 -0
  445. package/src/permissions/question-prompter.test.ts +416 -0
  446. package/src/permissions/question-prompter.ts +294 -0
  447. package/src/platform/client.test.ts +1 -1
  448. package/src/platform/client.ts +1 -1
  449. package/src/plugin-api/constants.ts +26 -0
  450. package/src/plugin-api/index.ts +34 -1
  451. package/src/plugin-api/types.ts +104 -22
  452. package/src/plugins/defaults/circuit-breaker.ts +0 -5
  453. package/src/plugins/defaults/compaction.ts +0 -4
  454. package/src/plugins/defaults/empty-response.ts +0 -2
  455. package/src/plugins/defaults/history-repair.ts +0 -2
  456. package/src/plugins/defaults/injectors.ts +74 -22
  457. package/src/plugins/defaults/llm-call.ts +0 -2
  458. package/src/plugins/defaults/memory-retrieval.ts +0 -1
  459. package/src/plugins/defaults/overflow-reduce.ts +0 -1
  460. package/src/plugins/defaults/persistence.ts +0 -2
  461. package/src/plugins/defaults/title-generate.ts +0 -5
  462. package/src/plugins/defaults/token-estimate.ts +0 -2
  463. package/src/plugins/defaults/tool-error.ts +0 -7
  464. package/src/plugins/defaults/tool-execute.ts +0 -2
  465. package/src/plugins/defaults/tool-result-truncate.ts +0 -4
  466. package/src/plugins/ensure-plugin-api-shim.ts +96 -0
  467. package/src/plugins/external-api.ts +104 -0
  468. package/src/plugins/external-plugin-loader.ts +187 -42
  469. package/src/plugins/feature-gate.ts +22 -0
  470. package/src/plugins/pipeline.ts +37 -0
  471. package/src/plugins/registry.ts +48 -80
  472. package/src/plugins/types.ts +40 -26
  473. package/src/plugins/user-loader.ts +21 -2
  474. package/src/proactive-artifact/aux-message-injector.ts +11 -0
  475. package/src/proactive-artifact/job.test.ts +37 -5
  476. package/src/prompts/__tests__/system-prompt.test.ts +10 -43
  477. package/src/prompts/__tests__/task-progress-hint-section.test.ts +95 -0
  478. package/src/prompts/normalize-onboarding.ts +27 -0
  479. package/src/prompts/sections.ts +302 -0
  480. package/src/prompts/system-prompt.ts +63 -174
  481. package/src/prompts/templates/BOOTSTRAP.md +17 -1
  482. package/src/prompts/templates/system-sections.ts +164 -0
  483. package/src/providers/__tests__/inference.test.ts +24 -7
  484. package/src/providers/anthropic/client.ts +28 -28
  485. package/src/providers/call-site-routing.ts +24 -6
  486. package/src/providers/connection-resolution.ts +68 -11
  487. package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +74 -0
  488. package/src/providers/inference/__tests__/connections-openai-compatible.test.ts +175 -0
  489. package/src/providers/inference/__tests__/connections-status-label.test.ts +15 -0
  490. package/src/providers/inference/adapter-factory.ts +32 -6
  491. package/src/providers/inference/auth.ts +12 -0
  492. package/src/providers/inference/backfill.ts +14 -1
  493. package/src/providers/inference/connections.ts +159 -34
  494. package/src/providers/inference/resolve-auth.ts +14 -4
  495. package/src/providers/model-catalog.ts +249 -12
  496. package/src/providers/model-intents.ts +3 -3
  497. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +235 -0
  498. package/src/providers/openai/chat-completions-provider.ts +169 -8
  499. package/src/providers/openrouter/client.ts +49 -4
  500. package/src/providers/{managed-proxy → platform-proxy}/constants.ts +4 -2
  501. package/src/providers/{managed-proxy → platform-proxy}/context.ts +3 -3
  502. package/src/providers/provider-availability.ts +17 -2
  503. package/src/providers/provider-catalog-visibility.ts +38 -0
  504. package/src/providers/provider-send-message.ts +27 -12
  505. package/src/providers/registry.ts +52 -15
  506. package/src/providers/retry.ts +47 -1
  507. package/src/runtime/__tests__/agent-wake.test.ts +152 -0
  508. package/src/runtime/agent-wake.ts +103 -15
  509. package/src/runtime/auth/route-policy.ts +21 -1
  510. package/src/runtime/btw-sidechain.ts +2 -0
  511. package/src/runtime/http-server.ts +7 -16
  512. package/src/runtime/http-types.ts +19 -47
  513. package/src/runtime/migrations/origin-mode.ts +1 -1
  514. package/src/runtime/pending-interactions.ts +1 -0
  515. package/src/runtime/routes/__tests__/bookmark-routes.test.ts +17 -0
  516. package/src/runtime/routes/__tests__/consolidation-routes.test.ts +258 -0
  517. package/src/runtime/routes/__tests__/conversation-management-routes.test.ts +5 -1
  518. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +172 -23
  519. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +275 -44
  520. package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +12 -0
  521. package/src/runtime/routes/__tests__/question-routes.test.ts +395 -0
  522. package/src/runtime/routes/__tests__/tts-routes.test.ts +64 -1
  523. package/src/runtime/routes/acp-routes-list.test.ts +143 -0
  524. package/src/runtime/routes/acp-routes.ts +5 -3
  525. package/src/runtime/routes/auth-routes.ts +1 -1
  526. package/src/runtime/routes/bookmark-routes.ts +5 -3
  527. package/src/runtime/routes/btw-routes.ts +5 -1
  528. package/src/runtime/routes/channel-availability-routes.ts +126 -0
  529. package/src/runtime/routes/consolidation-routes.ts +100 -0
  530. package/src/runtime/routes/conversation-cli-routes.ts +44 -3
  531. package/src/runtime/routes/conversation-list-routes.ts +3 -20
  532. package/src/runtime/routes/conversation-management-routes.ts +17 -42
  533. package/src/runtime/routes/conversation-query-routes.ts +99 -35
  534. package/src/runtime/routes/conversation-routes.ts +97 -11
  535. package/src/runtime/routes/documents-routes.ts +25 -86
  536. package/src/runtime/routes/group-routes.ts +5 -0
  537. package/src/runtime/routes/inbound-conversation.ts +28 -8
  538. package/src/runtime/routes/inbound-message-handler.ts +236 -41
  539. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +111 -0
  540. package/src/runtime/routes/inbound-stages/background-dispatch.ts +32 -1
  541. package/src/runtime/routes/inbound-stages/edit-intercept.ts +17 -4
  542. package/src/runtime/routes/index.ts +8 -0
  543. package/src/runtime/routes/inference-profile-session-handler.ts +17 -44
  544. package/src/runtime/routes/inference-profile-session-reaper.ts +7 -21
  545. package/src/runtime/routes/inference-provider-connection-routes.ts +199 -22
  546. package/src/runtime/routes/integrations/a2a.ts +235 -0
  547. package/src/runtime/routes/integrations/slack/share.ts +4 -52
  548. package/src/runtime/routes/integrations/slack/token.ts +43 -0
  549. package/src/runtime/routes/integrations/twilio.ts +6 -13
  550. package/src/runtime/routes/llm-call-sites-routes.ts +11 -1
  551. package/src/runtime/routes/notification-routes.ts +1 -1
  552. package/src/runtime/routes/oauth-commands-routes.ts +105 -15
  553. package/src/runtime/routes/oauth-lifecycle-routes.ts +43 -0
  554. package/src/runtime/routes/question-routes.ts +259 -0
  555. package/src/runtime/routes/rename-conversation-routes.ts +2 -33
  556. package/src/runtime/routes/schedule-routes.ts +4 -7
  557. package/src/runtime/routes/subagents-routes.ts +98 -18
  558. package/src/runtime/routes/telemetry-routes.ts +27 -0
  559. package/src/runtime/routes/tts-routes.ts +27 -2
  560. package/src/runtime/routes/workspace-routes.test.ts +43 -0
  561. package/src/runtime/routes/workspace-routes.ts +28 -0
  562. package/src/runtime/services/conversation-serializer.ts +39 -7
  563. package/src/runtime/sync/resource-sync-events.ts +93 -1
  564. package/src/schedule/schedule-store.ts +27 -2
  565. package/src/schedule/scheduler.ts +9 -1
  566. package/src/security/__tests__/untrusted-content.test.ts +86 -0
  567. package/src/security/untrusted-content.ts +93 -8
  568. package/src/skills/catalog-files.ts +1 -1
  569. package/src/skills/catalog-install.ts +233 -116
  570. package/src/skills/clawhub.ts +70 -13
  571. package/src/skills/managed-store.ts +4 -119
  572. package/src/skills/skillssh-registry.ts +27 -48
  573. package/src/subagent/manager.ts +17 -7
  574. package/src/telemetry/types.ts +113 -1
  575. package/src/telemetry/usage-telemetry-reporter.test.ts +312 -5
  576. package/src/telemetry/usage-telemetry-reporter.ts +113 -7
  577. package/src/tools/apps/executors.ts +58 -7
  578. package/src/tools/ask-question/ask-question-tool.test.ts +509 -0
  579. package/src/tools/ask-question/ask-question-tool.ts +304 -0
  580. package/src/tools/browser/browser-execution.ts +15 -11
  581. package/src/tools/computer-use/definitions.ts +3 -3
  582. package/src/tools/credentials/vault.ts +1 -1
  583. package/src/tools/document/document-tool.ts +124 -1
  584. package/src/tools/filesystem/edit.ts +1 -1
  585. package/src/tools/filesystem/list.ts +1 -1
  586. package/src/tools/filesystem/read.ts +1 -1
  587. package/src/tools/filesystem/write.ts +5 -2
  588. package/src/tools/host-filesystem/transfer.ts +1 -1
  589. package/src/tools/host-terminal/host-shell.ts +1 -1
  590. package/src/tools/memory/register.ts +1 -9
  591. package/src/tools/permission-checker.ts +1 -1
  592. package/src/tools/registry.ts +17 -7
  593. package/src/tools/schedule/create.ts +2 -2
  594. package/src/tools/schema-transforms.ts +7 -2
  595. package/src/tools/side-effects.ts +1 -0
  596. package/src/tools/skills/delete-managed.ts +4 -4
  597. package/src/tools/skills/execute.ts +1 -1
  598. package/src/tools/skills/scaffold-managed.ts +3 -2
  599. package/src/tools/subagent/notify-parent.ts +1 -1
  600. package/src/tools/system/request-permission.ts +2 -2
  601. package/src/tools/terminal/safe-env.ts +60 -1
  602. package/src/tools/tool-manifest.ts +2 -0
  603. package/src/tools/types.ts +107 -21
  604. package/src/tools/ui-surface/definitions.ts +6 -5
  605. package/src/tts/__tests__/provider-adapters.test.ts +76 -2
  606. package/src/tts/providers/elevenlabs-provider.ts +75 -1
  607. package/src/types/onboarding-context.ts +2 -0
  608. package/src/util/errors.ts +17 -0
  609. package/src/util/platform.ts +10 -0
  610. package/src/watcher/__tests__/engine.test.ts +22 -0
  611. package/src/watcher/engine.ts +6 -2
  612. package/src/workspace/migrations/057-repair-stale-gemini-model-ids.ts +80 -15
  613. package/src/workspace/migrations/072-seed-reply-suggestion-callsite.ts +35 -22
  614. package/src/workspace/migrations/073-repair-recall-callsite-empty-profile.ts +3 -1
  615. package/src/workspace/migrations/083-system-prompt-prefix-to-file.ts +191 -0
  616. package/src/workspace/migrations/084-remove-legacy-skills-index.ts +276 -0
  617. package/src/workspace/migrations/085-memory-v2-bm25-b-reembed-disabled-v2-pages.ts +137 -0
  618. package/src/workspace/migrations/086-revert-stale-gemini-mis-rewrites.ts +198 -0
  619. package/src/workspace/migrations/087-memory-router-balanced-profile.ts +91 -0
  620. package/src/workspace/migrations/registry.ts +10 -0
  621. package/src/workspace/migrations/runner.ts +39 -9
  622. package/src/workspace/migrations/types.ts +4 -0
  623. package/examples/plugins/echo/bun.lock +0 -25
  624. package/src/__tests__/context-window-manager.test.ts +0 -2481
  625. package/src/__tests__/guardian-action-conversation-turn.test.ts +0 -441
  626. package/src/context/__tests__/compact-prompt.test.ts +0 -63
  627. package/src/context/prompts/compact.md +0 -26
  628. package/src/memory/graph/__tests__/remember-description.test.ts +0 -55
  629. package/src/prompts/__tests__/build-cli-reference-section.test.ts +0 -37
  630. package/src/runtime/guardian-action-conversation-turn.ts +0 -99
@@ -47,12 +47,24 @@ mock.module("../../util/platform.js", () => ({
47
47
 
48
48
  // Stub config so heartbeat is enabled. Must export every symbol from
49
49
  // the real module because Bun's mock.module replaces the entire module.
50
- const stubConfig = {
50
+ // Tests that need to flex maxConsecutiveRuns mutate this in-place.
51
+ const stubConfig: {
52
+ heartbeat: {
53
+ enabled: boolean;
54
+ intervalMs: number;
55
+ activeHoursStart: number | null;
56
+ activeHoursEnd: number | null;
57
+ maxConsecutiveRuns: number | null;
58
+ disposition: string;
59
+ };
60
+ } = {
51
61
  heartbeat: {
52
62
  enabled: true,
53
63
  intervalMs: 60_000,
54
64
  activeHoursStart: null,
55
65
  activeHoursEnd: null,
66
+ maxConsecutiveRuns: null,
67
+ disposition: "Default disposition text.",
56
68
  },
57
69
  };
58
70
  mock.module("../../config/loader.js", () => ({
@@ -91,7 +103,6 @@ mock.module("../../prompts/system-prompt.js", () => ({
91
103
  SYSTEM_PROMPT_CACHE_BOUNDARY: "<<CACHE_BOUNDARY>>",
92
104
  buildCoreIdentityContext: () => "",
93
105
  buildSystemPrompt: () => "",
94
- buildCliReferenceSection: () => "",
95
106
  ensurePromptFiles: () => {},
96
107
  stripCommentLines: (s: string) => s,
97
108
  }));
@@ -165,7 +176,6 @@ mock.module("../../runtime/pre-first-message-gate.js", () => ({
165
176
  hasReceivedUserMessage: () => preFirstMessageGateOpen,
166
177
  }));
167
178
 
168
-
169
179
  const { HeartbeatService } = await import("../heartbeat-service.js");
170
180
 
171
181
  let origWorkspaceDir: string | undefined;
@@ -178,6 +188,7 @@ beforeEach(() => {
178
188
  runBackgroundJobCalls.length = 0;
179
189
  skipHeartbeatRunCalls.length = 0;
180
190
  preFirstMessageGateOpen = true;
191
+ stubConfig.heartbeat.maxConsecutiveRuns = null;
181
192
  runBackgroundJobImpl = async () => ({
182
193
  conversationId: STUB_CONVERSATION_ID,
183
194
  ok: true,
@@ -351,9 +362,127 @@ describe("HeartbeatService", () => {
351
362
 
352
363
  expect(runBackgroundJobCalls).toHaveLength(1);
353
364
  expect(
354
- skipHeartbeatRunCalls.some(
355
- (c) => c.reason === "pre_first_user_message",
356
- ),
365
+ skipHeartbeatRunCalls.some((c) => c.reason === "pre_first_user_message"),
357
366
  ).toBe(false);
358
367
  });
368
+
369
+ describe("max consecutive runs cap", () => {
370
+ test("skips with reason 'max_consecutive_runs' after the cap is hit", async () => {
371
+ stubConfig.heartbeat.maxConsecutiveRuns = 2;
372
+ const service = new HeartbeatService({ alerter: () => {} });
373
+
374
+ expect(await service.runOnce({ force: false })).toBe(true);
375
+ expect(await service.runOnce({ force: false })).toBe(true);
376
+ expect(await service.runOnce({ force: false })).toBe(false);
377
+
378
+ expect(runBackgroundJobCalls).toHaveLength(2);
379
+ expect(
380
+ skipHeartbeatRunCalls.some((c) => c.reason === "max_consecutive_runs"),
381
+ ).toBe(true);
382
+ });
383
+
384
+ test("resetTimer() clears the counter so auto runs resume", async () => {
385
+ stubConfig.heartbeat.maxConsecutiveRuns = 1;
386
+ const service = new HeartbeatService({ alerter: () => {} });
387
+ service.start();
388
+ try {
389
+ await service.runOnce({ force: false });
390
+ expect(await service.runOnce({ force: false })).toBe(false);
391
+
392
+ service.resetTimer();
393
+ expect(await service.runOnce({ force: false })).toBe(true);
394
+ expect(runBackgroundJobCalls).toHaveLength(2);
395
+ } finally {
396
+ await service.stop();
397
+ }
398
+ });
399
+
400
+ test("resetTimer() during an in-flight run is not undone by that run's increment", async () => {
401
+ // Regression: if a guardian message arrives mid-run, `resetTimer()`
402
+ // zeroes the counter but the in-flight run's `finally` block used to
403
+ // unconditionally `_consecutiveRuns++`, leaving the counter at 1 and
404
+ // tripping the cap-at-1 path one auto run too early.
405
+ stubConfig.heartbeat.maxConsecutiveRuns = 1;
406
+
407
+ let releaseInflight: () => void = () => {};
408
+ const inflight = new Promise<void>((resolve) => {
409
+ releaseInflight = resolve;
410
+ });
411
+ runBackgroundJobImpl = async () => {
412
+ await inflight;
413
+ return { conversationId: STUB_CONVERSATION_ID, ok: true };
414
+ };
415
+
416
+ const service = new HeartbeatService({ alerter: () => {} });
417
+ service.start();
418
+ try {
419
+ const runPromise = service.runOnce({ force: false });
420
+ // Guardian message arrives while the run is still executing.
421
+ service.resetTimer();
422
+ releaseInflight();
423
+ expect(await runPromise).toBe(true);
424
+
425
+ // The reset during the in-flight run must survive: the next auto run
426
+ // should proceed because the counter is still 0, not 1.
427
+ runBackgroundJobImpl = async () => ({
428
+ conversationId: STUB_CONVERSATION_ID,
429
+ ok: true,
430
+ });
431
+ expect(await service.runOnce({ force: false })).toBe(true);
432
+ expect(
433
+ skipHeartbeatRunCalls.some(
434
+ (c) => c.reason === "max_consecutive_runs",
435
+ ),
436
+ ).toBe(false);
437
+ } finally {
438
+ await service.stop();
439
+ }
440
+ });
441
+
442
+ test("null disables the cap entirely", async () => {
443
+ stubConfig.heartbeat.maxConsecutiveRuns = null;
444
+ const service = new HeartbeatService({ alerter: () => {} });
445
+
446
+ for (let i = 0; i < 5; i++) {
447
+ expect(await service.runOnce({ force: false })).toBe(true);
448
+ }
449
+ expect(runBackgroundJobCalls).toHaveLength(5);
450
+ expect(
451
+ skipHeartbeatRunCalls.some((c) => c.reason === "max_consecutive_runs"),
452
+ ).toBe(false);
453
+ });
454
+
455
+ test("force runs bypass the cap and do not increment the counter", async () => {
456
+ stubConfig.heartbeat.maxConsecutiveRuns = 2;
457
+ const service = new HeartbeatService({ alerter: () => {} });
458
+
459
+ // Five force runs would push us well past the cap if force counted.
460
+ for (let i = 0; i < 5; i++) {
461
+ expect(await service.runOnce({ force: true })).toBe(true);
462
+ }
463
+ // Two auto runs should still proceed because the counter is at zero.
464
+ expect(await service.runOnce({ force: false })).toBe(true);
465
+ expect(await service.runOnce({ force: false })).toBe(true);
466
+ // The third auto run trips the cap.
467
+ expect(await service.runOnce({ force: false })).toBe(false);
468
+ expect(
469
+ skipHeartbeatRunCalls.some((c) => c.reason === "max_consecutive_runs"),
470
+ ).toBe(true);
471
+ });
472
+
473
+ test("reconfigure() resets the counter", async () => {
474
+ stubConfig.heartbeat.maxConsecutiveRuns = 1;
475
+ const service = new HeartbeatService({ alerter: () => {} });
476
+ service.start();
477
+ try {
478
+ await service.runOnce({ force: false });
479
+ expect(await service.runOnce({ force: false })).toBe(false);
480
+
481
+ service.reconfigure();
482
+ expect(await service.runOnce({ force: false })).toBe(true);
483
+ } finally {
484
+ await service.stop();
485
+ }
486
+ });
487
+ });
359
488
  });
@@ -23,7 +23,8 @@ export type HeartbeatSkipReason =
23
23
  | "disabled"
24
24
  | "outside_active_hours"
25
25
  | "overlap"
26
- | "pre_first_user_message";
26
+ | "pre_first_user_message"
27
+ | "max_consecutive_runs";
27
28
 
28
29
  export interface HeartbeatRunRecord {
29
30
  id: string;
@@ -9,8 +9,6 @@ import {
9
9
  shouldLogDiskPressureBackgroundSkip,
10
10
  } from "../daemon/disk-pressure-background-gate.js";
11
11
  import type { HeartbeatAlert } from "../daemon/message-protocol.js";
12
- import { getConversation, getMessages } from "../memory/conversation-crud.js";
13
- import { GENERATING_TITLE } from "../memory/conversation-title-service.js";
14
12
  import { emitNotificationSignal } from "../notifications/emit-signal.js";
15
13
  import {
16
14
  GUARDIAN_PERSONA_TEMPLATE,
@@ -47,9 +45,6 @@ const DEFAULT_CHECKLIST = `- Check in with yourself. Read NOW.md. Is it still ac
47
45
  const EARLY_HEARTBEAT_THRESHOLD = 3;
48
46
  const REENGAGEMENT_COOLDOWN_MS = 18 * 60 * 60 * 1000; // 18 hours
49
47
  const HEARTBEAT_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
50
- const HEARTBEAT_ALERT_MARKER = "HEARTBEAT_ALERT";
51
- const HEARTBEAT_OK_MARKER = "HEARTBEAT_OK";
52
- const HEARTBEAT_ALERT_SUMMARY_MAX_CHARS = 700;
53
48
 
54
49
  // Stripped-comment form of the guardian persona scaffold. Computed
55
50
  // once at module load because stripping comment lines is deterministic
@@ -102,69 +97,6 @@ function recordReengagementTimestamp(): void {
102
97
  }
103
98
  }
104
99
 
105
- type HeartbeatDisposition = "alert" | "ok" | "unknown";
106
-
107
- function parseHeartbeatDisposition(text: string | null): HeartbeatDisposition {
108
- if (!text) return "unknown";
109
- const lines = text
110
- .trim()
111
- .split(/\r?\n/)
112
- .map((line) => line.trim())
113
- .filter((line) => line.length > 0);
114
- const lastLine = lines.at(-1);
115
- if (lastLine === HEARTBEAT_ALERT_MARKER) return "alert";
116
- if (lastLine === HEARTBEAT_OK_MARKER) return "ok";
117
- return "unknown";
118
- }
119
-
120
- function stripHeartbeatDispositionMarkers(text: string): string {
121
- return text
122
- .replace(
123
- new RegExp(
124
- `(?:\\r?\\n)?\\s*(?:${HEARTBEAT_ALERT_MARKER}|${HEARTBEAT_OK_MARKER})\\s*$`,
125
- ),
126
- "",
127
- )
128
- .trim();
129
- }
130
-
131
- function truncateSummary(text: string, maxChars: number): string {
132
- if (text.length <= maxChars) return text;
133
- return `${text.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`;
134
- }
135
-
136
- function buildHeartbeatAlertSummary(text: string | null): string {
137
- const summary = text ? stripHeartbeatDispositionMarkers(text) : "";
138
- return truncateSummary(
139
- summary || "Your assistant found something worth your attention.",
140
- HEARTBEAT_ALERT_SUMMARY_MAX_CHARS,
141
- );
142
- }
143
-
144
- function extractVisibleTextFromStoredMessageContent(raw: string): string {
145
- try {
146
- const parsed = JSON.parse(raw) as unknown;
147
- if (typeof parsed === "string") return parsed;
148
- if (!Array.isArray(parsed)) return "";
149
- const texts: string[] = [];
150
- for (const block of parsed) {
151
- if (
152
- block != null &&
153
- typeof block === "object" &&
154
- "type" in block &&
155
- block.type === "text" &&
156
- "text" in block &&
157
- typeof block.text === "string"
158
- ) {
159
- texts.push(block.text);
160
- }
161
- }
162
- return texts.join("\n").trim();
163
- } catch {
164
- return raw;
165
- }
166
- }
167
-
168
100
  export interface HeartbeatDeps {
169
101
  alerter: (alert: HeartbeatAlert) => void;
170
102
  onConversationCreated?: (info: {
@@ -198,6 +130,13 @@ export class HeartbeatService {
198
130
  private _startupMissedCount = 0;
199
131
  private _startupCrashedCount = 0;
200
132
  private _hasRunStartupRecovery = false;
133
+ // Counter of consecutive auto-heartbeats since the last guardian message.
134
+ // Reset by resetTimer (guardian message), reconfigure, and stop. Force runs
135
+ // bypass the cap and do not increment.
136
+ private _consecutiveRuns = 0;
137
+ // Bumped every time the counter is reset so an in-flight run that finishes
138
+ // after a guardian message can detect the reset and skip its increment.
139
+ private _resetGeneration = 0;
201
140
 
202
141
  constructor(deps: HeartbeatDeps) {
203
142
  this.deps = deps;
@@ -262,6 +201,11 @@ export class HeartbeatService {
262
201
  isAsyncBackground: true,
263
202
  visibleInSourceNow: false,
264
203
  },
204
+ conversationMetadata: {
205
+ source: "heartbeat",
206
+ groupId: "system:background",
207
+ conversationType: "background",
208
+ },
265
209
  }).catch((err) => {
266
210
  log.warn(
267
211
  { err },
@@ -350,6 +294,8 @@ export class HeartbeatService {
350
294
 
351
295
  /** Restart the timer with the latest config (e.g. after settings change). */
352
296
  reconfigure(): void {
297
+ this._consecutiveRuns = 0;
298
+ this._resetGeneration++;
353
299
  this.configEpoch++;
354
300
  if (this._pendingRunId) {
355
301
  supersedePendingRun(this._pendingRunId);
@@ -371,6 +317,10 @@ export class HeartbeatService {
371
317
  * after an active conversation.
372
318
  */
373
319
  resetTimer(): void {
320
+ // Counter resets even when the timer is null so a guardian message during
321
+ // a stopped window still clears the count.
322
+ this._consecutiveRuns = 0;
323
+ this._resetGeneration++;
374
324
  if (!this.timer) return;
375
325
  if (this.cronMode) {
376
326
  clearTimeout(this.timer as ReturnType<typeof setTimeout>);
@@ -390,6 +340,8 @@ export class HeartbeatService {
390
340
  }
391
341
 
392
342
  async stop(): Promise<void> {
343
+ this._consecutiveRuns = 0;
344
+ this._resetGeneration++;
393
345
  this.stopped = true;
394
346
  if (this.timer) {
395
347
  clearTimeout(this.timer as ReturnType<typeof setTimeout>);
@@ -500,6 +452,28 @@ export class HeartbeatService {
500
452
  }
501
453
  }
502
454
 
455
+ // Cap consecutive auto-runs without a guardian message so the assistant
456
+ // stops burning LLM tokens when the user is away. Force runs (manual
457
+ // operator action) bypass the cap and do not increment the counter.
458
+ if (
459
+ !force &&
460
+ config.maxConsecutiveRuns != null &&
461
+ this._consecutiveRuns >= config.maxConsecutiveRuns
462
+ ) {
463
+ log.debug(
464
+ {
465
+ consecutiveRuns: this._consecutiveRuns,
466
+ maxConsecutiveRuns: config.maxConsecutiveRuns,
467
+ },
468
+ "Max consecutive runs reached, skipping",
469
+ );
470
+ if (runId) skipHeartbeatRun(runId, "max_consecutive_runs");
471
+ if (!this.cronMode) {
472
+ this.scheduleNextRun(config.intervalMs);
473
+ }
474
+ return false;
475
+ }
476
+
503
477
  // Overlap prevention
504
478
  if (this.activeRun) {
505
479
  log.debug("Previous heartbeat run still active, skipping");
@@ -516,6 +490,11 @@ export class HeartbeatService {
516
490
  }
517
491
  const run = this.executeRun(runId, scheduledFor);
518
492
  this.activeRun = run;
493
+ // Snapshot the reset generation so we can detect whether a reset (guardian
494
+ // message, reconfigure, stop) happened while this run was in flight. If it
495
+ // did, the counter was already zeroed and we must not undo that reset by
496
+ // incrementing in `finally`.
497
+ const startGeneration = this._resetGeneration;
519
498
  try {
520
499
  await run;
521
500
  } catch (err) {
@@ -525,6 +504,9 @@ export class HeartbeatService {
525
504
  this.activeRun = null;
526
505
  }
527
506
  this._lastRunAt = Date.now();
507
+ if (!force && this._resetGeneration === startGeneration) {
508
+ this._consecutiveRuns++;
509
+ }
528
510
  if (!this.cronMode) {
529
511
  this.scheduleNextRun(getConfig().heartbeat.intervalMs);
530
512
  }
@@ -648,6 +630,7 @@ export class HeartbeatService {
648
630
  conversationMetadata: {
649
631
  source: "heartbeat",
650
632
  groupId: "system:background",
633
+ conversationType: "background",
651
634
  },
652
635
  });
653
636
  } catch (err) {
@@ -659,65 +642,6 @@ export class HeartbeatService {
659
642
  }
660
643
  }
661
644
 
662
- private getLatestAssistantMessage(
663
- conversationId: string,
664
- ): { id: string; text: string } | null {
665
- try {
666
- const messages = getMessages(conversationId);
667
- for (let i = messages.length - 1; i >= 0; i--) {
668
- const message = messages[i]!;
669
- if (message.role !== "assistant") continue;
670
- return {
671
- id: message.id,
672
- text: extractVisibleTextFromStoredMessageContent(message.content),
673
- };
674
- }
675
- } catch (err) {
676
- log.warn(
677
- { err, conversationId },
678
- "Failed to read heartbeat assistant message",
679
- );
680
- }
681
- return null;
682
- }
683
-
684
- private async emitHeartbeatAlertNotification(params: {
685
- runId: string;
686
- conversationId: string;
687
- messageId?: string;
688
- conversationTitle: string;
689
- summary: string;
690
- }): Promise<void> {
691
- const { emitNotificationSignal } =
692
- await import("../notifications/emit-signal.js");
693
-
694
- await emitNotificationSignal({
695
- sourceEventName: "heartbeat.alert",
696
- sourceChannel: "watcher",
697
- sourceContextId: params.runId,
698
- dedupeKey: `heartbeat:alert:${params.runId}`,
699
- attentionHints: {
700
- requiresAction: true,
701
- urgency: "medium",
702
- isAsyncBackground: true,
703
- visibleInSourceNow: false,
704
- },
705
- contextPayload: {
706
- title: "Heartbeat Alert",
707
- summary: params.summary,
708
- conversationTitle: params.conversationTitle,
709
- conversationId: params.conversationId,
710
- messageId: params.messageId,
711
- },
712
- routingIntent: "single_channel",
713
- conversationAffinityHint: { vellum: params.conversationId },
714
- conversationMetadata: {
715
- source: "heartbeat",
716
- groupId: "system:background",
717
- },
718
- });
719
- }
720
-
721
645
  private async executeRun(runId: string, scheduledFor: number): Promise<void> {
722
646
  log.info("Running heartbeat");
723
647
 
@@ -746,9 +670,9 @@ export class HeartbeatService {
746
670
  // The runner fires `onConversationCreated` synchronously after
747
671
  // bootstrap so the macOS sidebar gets the new conversation
748
672
  // immediately rather than waiting up to HEARTBEAT_TIMEOUT_MS for
749
- // the LLM turn to finish. We forward to `deps.onConversationCreated`
750
- // for every run; "silent OK" is enforced by NOT emitting any
751
- // notification signal further down, not by hiding the conversation.
673
+ // the LLM turn to finish. If the model judges the run worth
674
+ // surfacing to the guardian, it calls the `notifications` skill
675
+ // directly no in-band marker.
752
676
  let conversationId: string | undefined;
753
677
  const result = await runBackgroundJob({
754
678
  jobName: "heartbeat",
@@ -780,62 +704,26 @@ export class HeartbeatService {
780
704
  "Heartbeat completed",
781
705
  );
782
706
 
783
- // Mark the run record as ok and surface any disposition-driven
784
- // alert the assistant decided to raise. The runner owns failure
785
- // emission via `activity.failed`; success-side surfacing (alerts,
786
- // late warnings) lives here so it can read the actual conversation
787
- // contents.
707
+ // Mark the run record as ok. The runner owns failure emission via
708
+ // `activity.failed`; any user-facing alert the model decided to
709
+ // raise was emitted in-band via the `notifications` skill during
710
+ // the turn itself.
788
711
  const transitioned = completeHeartbeatRun(runId, {
789
712
  status: "ok",
790
713
  conversationId: result.conversationId,
791
714
  });
792
715
 
793
- if (transitioned) {
794
- let title = "Heartbeat";
795
- try {
796
- const row = getConversation(result.conversationId);
797
- if (row?.title && row.title !== GENERATING_TITLE) {
798
- title = row.title;
799
- }
800
- } catch {
801
- // Best-effort; fall back to generic title.
802
- }
803
-
804
- const assistantMessage = this.getLatestAssistantMessage(
805
- result.conversationId,
806
- );
807
- const disposition = parseHeartbeatDisposition(
808
- assistantMessage?.text ?? null,
809
- );
810
- if (disposition === "alert") {
811
- // Conversation was already surfaced via the runner's bootstrap
812
- // callback above; alert just needs to emit the notification.
813
- void this.emitHeartbeatAlertNotification({
716
+ if (transitioned && latenessMs > LATE_THRESHOLD_MS) {
717
+ const lateMinutes = Math.round(latenessMs / 60_000);
718
+ log.warn(
719
+ {
720
+ latenessMs,
721
+ lateMinutes,
722
+ scheduledFor,
814
723
  runId,
815
- conversationId: result.conversationId,
816
- messageId: assistantMessage?.id,
817
- conversationTitle: title,
818
- summary: buildHeartbeatAlertSummary(assistantMessage?.text ?? null),
819
- }).catch((err) => {
820
- log.warn(
821
- { err, conversationId: result.conversationId },
822
- "Failed to emit heartbeat alert notification",
823
- );
824
- });
825
- }
826
-
827
- if (latenessMs > LATE_THRESHOLD_MS) {
828
- const lateMinutes = Math.round(latenessMs / 60_000);
829
- log.warn(
830
- {
831
- latenessMs,
832
- lateMinutes,
833
- scheduledFor,
834
- runId,
835
- },
836
- "Heartbeat ran late",
837
- );
838
- }
724
+ },
725
+ "Heartbeat ran late",
726
+ );
839
727
  }
840
728
  return;
841
729
  }
@@ -900,18 +788,14 @@ Do NOT attempt to use tools for these providers — they will fail. Skip any che
900
788
  </credential-status>`;
901
789
  }
902
790
 
903
- prompt += `\n\n<heartbeat-disposition>
904
- This heartbeat runs frequently. Do not manufacture a report just because it ran.
905
- If there is nothing genuinely useful, actionable, or interesting to surface, keep the response brief and end with HEARTBEAT_OK.
906
- If there is something worth interrupting the guardian for, write a concise guardian-facing note first: what happened, why it matters, and the recommended next step. Address the guardian directly as "you"; do not write instructions to yourself or another intermediary. Then end with HEARTBEAT_ALERT. That note may be used as notification copy.
907
- After completing your review, end your response with one of:
908
- - HEARTBEAT_OK — if everything looks good, no action needed
909
- - HEARTBEAT_ALERT — if you found issues that need attention (describe them before this marker)
910
- </heartbeat-disposition>`;
791
+ const disposition = getConfig().heartbeat.disposition;
792
+ if (disposition) {
793
+ prompt += `\n\n<heartbeat-disposition>\n${disposition}\n</heartbeat-disposition>`;
794
+ }
911
795
 
912
796
  if (completedRunCount < EARLY_HEARTBEAT_THRESHOLD) {
913
797
  prompt += `\n\n<early-heartbeat>
914
- This is one of your first heartbeats. Your user hasn't heard from you yet and may not know you're here. Find something genuinely useful to share — a follow-up from a recent conversation, something you noticed, or a quick check-in. Lean toward HEARTBEAT_ALERT this time. First impressions matter.
798
+ This is one of your first heartbeats. Your user hasn't heard from you yet and may not know you're here. Find something genuinely useful to share — a follow-up from a recent conversation, something you noticed, or a quick check-in. Lean toward surfacing it via the notifications skill this time. First impressions matter.
915
799
  </early-heartbeat>`;
916
800
  }
917
801
 
@@ -82,6 +82,62 @@ describe("feedItemSchema — valid minimal items", () => {
82
82
  expect(parsed.expiresAt).toBe("2026-04-15T00:00:00.000Z");
83
83
  expect(parsed.detailPanel?.kind).toBe("emailDraft");
84
84
  });
85
+
86
+ test("category field passes through when present", () => {
87
+ for (const cat of [
88
+ "security",
89
+ "scheduling",
90
+ "background",
91
+ "email",
92
+ "system",
93
+ ] as const) {
94
+ const parsed = feedItemSchema.parse({
95
+ ...minimalNotification(),
96
+ category: cat,
97
+ });
98
+ expect(parsed.category).toBe(cat);
99
+ }
100
+ });
101
+
102
+ test("metadata field passes through when present", () => {
103
+ const parsed = feedItemSchema.parse({
104
+ ...minimalNotification(),
105
+ metadata: { subject: "Hello", count: 3, nested: { ok: true } },
106
+ });
107
+ expect(parsed.metadata).toEqual({
108
+ subject: "Hello",
109
+ count: 3,
110
+ nested: { ok: true },
111
+ });
112
+ });
113
+
114
+ test("items without category or metadata still parse (backward compat)", () => {
115
+ const parsed = feedItemSchema.parse(minimalNotification());
116
+ expect(parsed.category).toBeUndefined();
117
+ expect(parsed.metadata).toBeUndefined();
118
+ });
119
+
120
+ test("noteworthy field passes through when present", () => {
121
+ const parsed = feedItemSchema.parse({
122
+ ...minimalNotification(),
123
+ noteworthy: true,
124
+ });
125
+ expect(parsed.noteworthy).toBe(true);
126
+ });
127
+
128
+ test("items without noteworthy field still parse (backward compat)", () => {
129
+ const parsed = feedItemSchema.parse(minimalNotification());
130
+ expect(parsed.noteworthy).toBeUndefined();
131
+ });
132
+
133
+ test("title is optional and may be omitted", () => {
134
+ const { title: _omitted, ...rest } = minimalNotification();
135
+ const parsed = feedItemSchema.parse(rest);
136
+ expect(parsed.title).toBeUndefined();
137
+ expect(parsed.summary).toBe(
138
+ "You mentioned wanting to review the onboarding designs.",
139
+ );
140
+ });
85
141
  });
86
142
 
87
143
  // ---------------------------------------------------------------------------
@@ -142,6 +198,12 @@ describe("feedItemSchema — enum validation", () => {
142
198
  }
143
199
  });
144
200
 
201
+ test("rejects unknown `category`", () => {
202
+ expect(() =>
203
+ feedItemSchema.parse({ ...minimalNotification(), category: "weather" }),
204
+ ).toThrow();
205
+ });
206
+
145
207
  test("rejects unknown `status`", () => {
146
208
  expect(() =>
147
209
  feedItemSchema.parse({ ...minimalNotification(), status: "archived" }),
@@ -204,4 +266,22 @@ describe("parseFeedFile", () => {
204
266
  }),
205
267
  ).toThrow();
206
268
  });
269
+
270
+ test("accepts a file with a noteworthy item", () => {
271
+ const parsed = parseFeedFile({
272
+ version: 2,
273
+ items: [{ ...minimalNotification(), noteworthy: true }],
274
+ updatedAt: NOW_ISO,
275
+ });
276
+ expect(parsed.items[0]?.noteworthy).toBe(true);
277
+ });
278
+
279
+ test("accepts a file whose items omit noteworthy (backward compat)", () => {
280
+ const parsed = parseFeedFile({
281
+ version: 2,
282
+ items: [minimalNotification()],
283
+ updatedAt: NOW_ISO,
284
+ });
285
+ expect(parsed.items[0]?.noteworthy).toBeUndefined();
286
+ });
207
287
  });