@vellumai/assistant 0.8.10 → 0.8.11-staging.1

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 (400) hide show
  1. package/bun.lock +62 -1
  2. package/docs/workspace-tools.md +196 -0
  3. package/examples/plugins/echo/README.md +3 -3
  4. package/knip.json +1 -0
  5. package/openapi.yaml +460 -128
  6. package/package.json +2 -1
  7. package/scripts/build-plugin-api.ts +299 -0
  8. package/src/__tests__/agent-loop-callsite-precedence.test.ts +7 -0
  9. package/src/__tests__/agent-loop-compaction-events.test.ts +197 -0
  10. package/src/__tests__/agent-loop-exit-reason.test.ts +93 -96
  11. package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +2 -0
  12. package/src/__tests__/agent-loop-output-hooks.test.ts +274 -1
  13. package/src/__tests__/agent-loop-override-profile.test.ts +3 -0
  14. package/src/__tests__/agent-loop-provider-error-recording.test.ts +4 -0
  15. package/src/__tests__/agent-loop-thinking.test.ts +4 -0
  16. package/src/__tests__/agent-loop.test.ts +578 -5
  17. package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +0 -1
  18. package/src/__tests__/approval-cascade.test.ts +1 -0
  19. package/src/__tests__/background-workers-disk-pressure.test.ts +0 -2
  20. package/src/__tests__/btw-routes.test.ts +0 -1
  21. package/src/__tests__/build-persisted-content.test.ts +75 -1
  22. package/src/__tests__/catalog-install-normalize.test.ts +141 -0
  23. package/src/__tests__/ces-startup-timeout.test.ts +60 -0
  24. package/src/__tests__/compaction-events.test.ts +1 -0
  25. package/src/__tests__/config-managed-gemini-defaults.test.ts +2 -46
  26. package/src/__tests__/context-overflow-reducer.test.ts +264 -124
  27. package/src/__tests__/context-window-manager-overflow-rung.test.ts +351 -0
  28. package/src/__tests__/conversation-abort-tool-results.test.ts +1 -1
  29. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +13 -5
  30. package/src/__tests__/conversation-agent-loop-overflow.test.ts +284 -455
  31. package/src/__tests__/conversation-agent-loop.test.ts +131 -551
  32. package/src/__tests__/conversation-app-control-instantiation.test.ts +13 -0
  33. package/src/__tests__/conversation-confirmation-signals.test.ts +1 -0
  34. package/src/__tests__/conversation-fork-crud.test.ts +259 -0
  35. package/src/__tests__/conversation-history-web-search.test.ts +1 -1
  36. package/src/__tests__/conversation-lifecycle.test.ts +257 -1
  37. package/src/__tests__/conversation-process-callsite.test.ts +1 -0
  38. package/src/__tests__/conversation-provider-retry-repair.test.ts +38 -377
  39. package/src/__tests__/conversation-queue.test.ts +1 -39
  40. package/src/__tests__/conversation-runtime-assembly.test.ts +119 -8
  41. package/src/__tests__/conversation-skill-tools.test.ts +491 -5
  42. package/src/__tests__/conversation-slash-queue.test.ts +1 -1
  43. package/src/__tests__/conversation-slash-unknown.test.ts +1 -0
  44. package/src/__tests__/conversation-speed-override.test.ts +1 -0
  45. package/src/__tests__/conversation-store.test.ts +74 -0
  46. package/src/__tests__/conversation-surfaces-app-control.test.ts +4 -1
  47. package/src/__tests__/conversation-tool-setup-attribution.test.ts +323 -0
  48. package/src/__tests__/conversation-tool-setup-tools-disabled.test.ts +34 -0
  49. package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -0
  50. package/src/__tests__/conversation-workspace-injection.test.ts +1 -1
  51. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -0
  52. package/src/__tests__/corrected-target.test.ts +93 -0
  53. package/src/__tests__/credential-execution-feature-gates.test.ts +3 -5
  54. package/src/__tests__/credential-execution-tools.test.ts +23 -11
  55. package/src/__tests__/credential-security-invariants.test.ts +6 -1
  56. package/src/__tests__/db-schedule-syntax-migration.test.ts +80 -0
  57. package/src/__tests__/device-id.test.ts +70 -1
  58. package/src/__tests__/embedding-managed-proxy-selection.test.ts +6 -40
  59. package/src/__tests__/empty-response-hook.test.ts +242 -66
  60. package/src/__tests__/external-plugin-loader.test.ts +0 -31
  61. package/src/__tests__/get-skill-detail-audit.test.ts +43 -1
  62. package/src/__tests__/guardian-routing-invariants.test.ts +91 -0
  63. package/src/__tests__/history-repair-hook.test.ts +228 -3
  64. package/src/__tests__/host-app-control-proxy.test.ts +45 -0
  65. package/src/__tests__/host-browser-proxy.test.ts +254 -9
  66. package/src/__tests__/identity-routes.test.ts +1 -0
  67. package/src/__tests__/image-recovery-hook.test.ts +387 -0
  68. package/src/__tests__/injector-chain.test.ts +5 -4
  69. package/src/__tests__/injector-v3-suppression.test.ts +373 -47
  70. package/src/__tests__/intent-routing.test.ts +7 -0
  71. package/src/__tests__/memory-retrieval-hook.test.ts +117 -15
  72. package/src/__tests__/notification-decision-strategy.test.ts +3 -3
  73. package/src/__tests__/oauth-store.test.ts +0 -85
  74. package/src/__tests__/{context-overflow-policy.test.ts → overflow-policy.test.ts} +1 -1
  75. package/src/__tests__/parallel-tool.benchmark.test.ts +4 -0
  76. package/src/__tests__/persist-unsendable-image-downscale.test.ts +29 -9
  77. package/src/__tests__/persist-unsendable-image.test.ts +4 -4
  78. package/src/__tests__/persistence-secret-redaction.test.ts +78 -0
  79. package/src/__tests__/plugin-bootstrap.test.ts +82 -73
  80. package/src/__tests__/plugin-tool-contribution.test.ts +7 -4
  81. package/src/__tests__/plugin-types.test.ts +0 -8
  82. package/src/__tests__/provider-catalog-visibility.test.ts +1 -9
  83. package/src/__tests__/prune-old-conversations-job.test.ts +99 -0
  84. package/src/__tests__/registry.test.ts +240 -1
  85. package/src/__tests__/require-fresh-approval.test.ts +3 -0
  86. package/src/__tests__/schedule-routes.test.ts +116 -1
  87. package/src/__tests__/schedule-store.test.ts +28 -0
  88. package/src/__tests__/schedule-tools.test.ts +94 -1
  89. package/src/__tests__/server-history-render.test.ts +39 -0
  90. package/src/__tests__/skill-projection-feature-flag.test.ts +13 -0
  91. package/src/__tests__/skill-projection.benchmark.test.ts +25 -7
  92. package/src/__tests__/skills.test.ts +202 -0
  93. package/src/__tests__/slim-skill-category.test.ts +195 -0
  94. package/src/__tests__/strip-memory-injections.test.ts +33 -39
  95. package/src/__tests__/test-support/tool-invocation-seed.ts +79 -0
  96. package/src/__tests__/title-generate-hook.test.ts +9 -7
  97. package/src/__tests__/tool-audit-listener.test.ts +264 -1
  98. package/src/__tests__/tool-error-hook.test.ts +4 -3
  99. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +1 -0
  100. package/src/__tests__/tool-executor-lifecycle-events.test.ts +273 -0
  101. package/src/__tests__/tool-result-truncate-hook.test.ts +1 -0
  102. package/src/__tests__/tool-start-timestamp.test.ts +218 -0
  103. package/src/__tests__/tools-get-route.test.ts +202 -0
  104. package/src/__tests__/workspace-tool-loader.test.ts +319 -0
  105. package/src/__tests__/workspace-tools-watcher-flag.test.ts +70 -0
  106. package/src/agent/loop.ts +569 -319
  107. package/src/api/events/tool-result.ts +9 -0
  108. package/src/api/events/tool-use-start.ts +7 -0
  109. package/src/api/index.ts +10 -0
  110. package/src/api/responses/conversation-message.ts +135 -27
  111. package/src/api/responses/memory-v3-selection-log.ts +4 -4
  112. package/src/approvals/guardian-request-resolvers.ts +26 -0
  113. package/src/browser-session/backends/host-bridge.ts +29 -0
  114. package/src/browser-session/index.ts +1 -0
  115. package/src/browser-session/types.ts +5 -1
  116. package/src/cli/commands/__tests__/schedules.test.ts +62 -4
  117. package/src/cli/commands/__tests__/skills.test.ts +53 -0
  118. package/src/cli/commands/channel-verification-sessions.ts +6 -6
  119. package/src/cli/commands/inference-providers.ts +0 -8
  120. package/src/cli/commands/plugins.ts +2 -2
  121. package/src/cli/commands/schedules.ts +27 -4
  122. package/src/cli/commands/skills.ts +187 -146
  123. package/src/cli/commands/tools.ts +106 -0
  124. package/src/cli/lib/__tests__/install-from-github.test.ts +256 -328
  125. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +6 -2
  126. package/src/cli/lib/__tests__/plugin-details.test.ts +10 -16
  127. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +2 -2
  128. package/src/cli/lib/__tests__/search-plugins.test.ts +145 -240
  129. package/src/cli/lib/install-from-github.ts +187 -117
  130. package/src/cli/lib/plugin-catalog-cache.ts +9 -9
  131. package/src/cli/lib/plugin-details.ts +38 -68
  132. package/src/cli/lib/plugin-marketplace.ts +42 -14
  133. package/src/cli/lib/search-plugins.ts +29 -129
  134. package/src/cli/program.ts +2 -0
  135. package/src/config/bundled-skills/acp/SKILL.md +1 -0
  136. package/src/config/bundled-skills/app-builder/SKILL.md +1 -0
  137. package/src/config/bundled-skills/app-control/SKILL.md +1 -0
  138. package/src/config/bundled-skills/computer-use/SKILL.md +1 -0
  139. package/src/config/bundled-skills/contacts/SKILL.md +1 -0
  140. package/src/config/bundled-skills/document-editor/SKILL.md +1 -0
  141. package/src/config/bundled-skills/followups/SKILL.md +1 -0
  142. package/src/config/bundled-skills/image-studio/SKILL.md +1 -0
  143. package/src/config/bundled-skills/media-processing/SKILL.md +1 -0
  144. package/src/config/bundled-skills/messaging/SKILL.md +1 -0
  145. package/src/config/bundled-skills/phone-calls/SKILL.md +1 -0
  146. package/src/config/bundled-skills/playbooks/SKILL.md +1 -0
  147. package/src/config/bundled-skills/schedule/SKILL.md +1 -0
  148. package/src/config/bundled-skills/schedule/TOOLS.json +11 -3
  149. package/src/config/bundled-skills/sequences/SKILL.md +1 -0
  150. package/src/config/bundled-skills/settings/SKILL.md +1 -0
  151. package/src/config/bundled-skills/skill-management/SKILL.md +101 -1
  152. package/src/config/bundled-skills/subagent/SKILL.md +1 -0
  153. package/src/config/bundled-skills/transcribe/SKILL.md +1 -0
  154. package/src/config/env-registry.ts +23 -0
  155. package/src/config/feature-flag-registry.json +17 -81
  156. package/src/config/loader.ts +5 -22
  157. package/src/config/schema.ts +2 -0
  158. package/src/config/schemas/__tests__/compaction-logs.test.ts +56 -0
  159. package/src/config/schemas/__tests__/memory-v2.test.ts +0 -1
  160. package/src/config/schemas/__tests__/memory-v3.test.ts +61 -1
  161. package/src/config/schemas/compaction-logs.ts +79 -0
  162. package/src/config/schemas/memory-v2.ts +0 -8
  163. package/src/config/schemas/memory-v3.ts +104 -33
  164. package/src/config/seed-inference-profiles.ts +1 -1
  165. package/src/config/skills.ts +117 -47
  166. package/src/context/compactor.ts +11 -0
  167. package/src/context/strip-injections.ts +38 -4
  168. package/src/credential-execution/feature-gates.ts +0 -21
  169. package/src/credential-execution/startup-timeout.ts +32 -4
  170. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +18 -0
  171. package/src/daemon/conversation-agent-loop-handlers.ts +140 -91
  172. package/src/daemon/conversation-agent-loop.ts +123 -658
  173. package/src/daemon/conversation-error.ts +6 -33
  174. package/src/daemon/conversation-lifecycle.ts +1 -1
  175. package/src/daemon/conversation-runtime-assembly.ts +183 -22
  176. package/src/daemon/conversation-skill-tools.ts +137 -8
  177. package/src/daemon/conversation-slash.ts +0 -14
  178. package/src/daemon/conversation-store.ts +2 -19
  179. package/src/daemon/conversation-tool-setup.ts +87 -1
  180. package/src/daemon/conversation.ts +119 -50
  181. package/src/daemon/external-plugins-bootstrap.ts +36 -95
  182. package/src/daemon/handlers/config-channels.ts +11 -2
  183. package/src/daemon/handlers/shared.ts +9 -1
  184. package/src/daemon/handlers/skills.ts +10 -4
  185. package/src/daemon/host-app-control-proxy.ts +72 -57
  186. package/src/daemon/host-browser-proxy.ts +117 -22
  187. package/src/daemon/lifecycle.ts +34 -5
  188. package/src/daemon/message-protocol.ts +0 -7
  189. package/src/daemon/message-types/schedules.ts +1 -0
  190. package/src/daemon/message-types/skills.ts +17 -0
  191. package/src/daemon/providers-setup.ts +3 -0
  192. package/src/daemon/server.ts +3 -3
  193. package/src/daemon/tool-setup-types.ts +9 -3
  194. package/src/daemon/trust-context.ts +23 -0
  195. package/src/daemon/workspace-tools-watcher.ts +324 -0
  196. package/src/events/tool-audit-listener.ts +78 -16
  197. package/src/events/tool-metrics-listener.ts +2 -5
  198. package/src/memory/__tests__/compaction-log-writer-clickhouse.test.ts +227 -0
  199. package/src/memory/__tests__/conversation-queries.test.ts +176 -0
  200. package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +20 -32
  201. package/src/memory/compaction-log-writer-clickhouse.ts +418 -0
  202. package/src/memory/conversation-crud.ts +134 -3
  203. package/src/memory/conversation-queries.ts +64 -7
  204. package/src/memory/db-init.ts +14 -0
  205. package/src/memory/embedding-backend.test.ts +130 -1
  206. package/src/memory/embedding-backend.ts +79 -106
  207. package/src/memory/embedding-gemini.ts +5 -0
  208. package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +12 -0
  209. package/src/memory/graph/__tests__/handle-remember-v2.test.ts +19 -0
  210. package/src/memory/graph/conversation-graph-memory.ts +36 -25
  211. package/src/memory/graph/tool-handlers.ts +3 -0
  212. package/src/memory/job-handlers/cleanup.ts +3 -1
  213. package/src/memory/jobs-store.ts +0 -28
  214. package/src/memory/jobs-worker.ts +10 -22
  215. package/src/memory/memory-marker.ts +29 -0
  216. package/src/memory/memory-retrospective-startup-cleanup.ts +1 -1
  217. package/src/memory/migrations/268-add-memory-v3-selections.ts +6 -0
  218. package/src/memory/migrations/270-schedule-description.ts +36 -0
  219. package/src/memory/migrations/275-tool-invocations-add-skill-id.test.ts +81 -0
  220. package/src/memory/migrations/275-tool-invocations-add-skill-id.ts +20 -0
  221. package/src/memory/migrations/276-tool-invocations-created-at-id-index.test.ts +68 -0
  222. package/src/memory/migrations/276-tool-invocations-created-at-id-index.ts +20 -0
  223. package/src/memory/migrations/277-add-memory-v3-ever-injected.ts +29 -0
  224. package/src/memory/migrations/278-tool-invocations-telemetry-columns.test.ts +96 -0
  225. package/src/memory/migrations/278-tool-invocations-telemetry-columns.ts +39 -0
  226. package/src/memory/migrations/279-create-skill-loaded-events.test.ts +84 -0
  227. package/src/memory/migrations/279-create-skill-loaded-events.ts +26 -0
  228. package/src/memory/migrations/280-conversations-surfaced-at.test.ts +88 -0
  229. package/src/memory/migrations/280-conversations-surfaced-at.ts +24 -0
  230. package/src/memory/migrations/index.ts +10 -0
  231. package/src/memory/migrations/registry.ts +8 -0
  232. package/src/memory/schema/conversations.ts +16 -0
  233. package/src/memory/schema/infrastructure.ts +26 -0
  234. package/src/memory/skill-loaded-events-store.test.ts +160 -0
  235. package/src/memory/skill-loaded-events-store.ts +95 -0
  236. package/src/memory/tool-executed-events-store.test.ts +219 -0
  237. package/src/memory/tool-executed-events-store.ts +102 -0
  238. package/src/memory/tool-usage-store.ts +15 -3
  239. package/src/memory/v2/__tests__/consolidation-job.test.ts +117 -12
  240. package/src/memory/v2/__tests__/consolidation-prompt-flag-gating-guard.test.ts +189 -0
  241. package/src/memory/v2/__tests__/injected-block-slugs.test.ts +90 -0
  242. package/src/memory/v2/__tests__/page-store.test.ts +33 -0
  243. package/src/memory/v2/__tests__/prompts-consolidation.test.ts +88 -15
  244. package/src/memory/v2/activation-store.ts +50 -1
  245. package/src/memory/v2/consolidation-job.ts +113 -29
  246. package/src/memory/v2/injected-block-slugs.ts +79 -0
  247. package/src/memory/v2/injection.ts +6 -1
  248. package/src/memory/v2/prompts/consolidation.ts +414 -13
  249. package/src/memory/v2/router.ts +2 -28
  250. package/src/memory/v2/static-context.ts +1 -1
  251. package/src/memory/v2/types.ts +16 -0
  252. package/src/notifications/__tests__/copy-composer.test.ts +244 -0
  253. package/src/notifications/access-request-copy.ts +298 -0
  254. package/src/notifications/adapters/slack.ts +3 -3
  255. package/src/notifications/adapters/telegram.ts +2 -1
  256. package/src/notifications/copy-composer.ts +49 -267
  257. package/src/notifications/decision-engine.ts +16 -35
  258. package/src/notifications/home-feed-side-effect.ts +1 -6
  259. package/src/oauth/oauth-store.ts +0 -9
  260. package/src/permissions/checker.test.ts +83 -1
  261. package/src/permissions/checker.ts +25 -2
  262. package/src/permissions/gateway-threshold-reader.test.ts +182 -0
  263. package/src/permissions/gateway-threshold-reader.ts +80 -0
  264. package/src/platform/client.ts +1 -3
  265. package/src/platform/feature-gate.ts +3 -12
  266. package/src/plugin-api/constants.ts +4 -2
  267. package/src/plugin-api/index.ts +63 -11
  268. package/src/plugin-api/types.ts +236 -71
  269. package/src/plugins/defaults/compaction/compact.ts +66 -2
  270. package/src/plugins/defaults/compaction/context-overflow-reducer.ts +240 -32
  271. package/src/plugins/defaults/compaction/corrected-target.ts +53 -0
  272. package/src/{daemon/context-overflow-policy.ts → plugins/defaults/compaction/overflow-policy.ts} +1 -1
  273. package/src/plugins/defaults/compaction/window-manager.ts +303 -1
  274. package/src/plugins/defaults/empty-response/hooks/post-model-call.ts +173 -0
  275. package/src/plugins/defaults/empty-response/hooks/stop.ts +11 -115
  276. package/src/plugins/defaults/empty-response/nudge-state-store.ts +46 -0
  277. package/src/plugins/defaults/history-repair/hooks/post-model-call.ts +50 -0
  278. package/src/plugins/defaults/history-repair/hooks/stop.ts +22 -0
  279. package/src/plugins/defaults/history-repair/repair-state-store.ts +51 -0
  280. package/src/plugins/defaults/history-repair/terminal.ts +39 -2
  281. package/src/plugins/defaults/image-recovery/detect.ts +25 -0
  282. package/src/plugins/defaults/image-recovery/hooks/post-model-call.ts +73 -0
  283. package/src/plugins/defaults/image-recovery/hooks/stop.ts +22 -0
  284. package/src/plugins/defaults/image-recovery/image-recovery-state-store.ts +48 -0
  285. package/src/plugins/defaults/image-recovery/package.json +14 -0
  286. package/src/{daemon/persist-unsendable-image.ts → plugins/defaults/image-recovery/recover.ts} +67 -14
  287. package/src/plugins/defaults/index.ts +71 -5
  288. package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +76 -112
  289. package/src/plugins/defaults/memory-retrieval/hooks/{user-prompt-submit-temp.ts → user-prompt-submit.ts} +83 -74
  290. package/src/plugins/defaults/memory-retrieval/injector-chain.ts +14 -8
  291. package/src/plugins/defaults/memory-retrieval/injectors.ts +2 -18
  292. package/src/plugins/defaults/memory-retrieval/package.json +14 -0
  293. package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +1157 -0
  294. package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +683 -0
  295. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +161 -140
  296. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +160 -0
  297. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +335 -316
  298. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +145 -53
  299. package/src/plugins/defaults/memory-v3-shadow/__tests__/render-injection.test.ts +38 -1
  300. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +18 -8
  301. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +112 -71
  302. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +192 -51
  303. package/src/plugins/defaults/memory-v3-shadow/__tests__/types.test.ts +4 -16
  304. package/src/plugins/defaults/memory-v3-shadow/card.test.ts +173 -0
  305. package/src/plugins/defaults/memory-v3-shadow/card.ts +116 -0
  306. package/src/plugins/defaults/memory-v3-shadow/core-set.test.ts +104 -0
  307. package/src/plugins/defaults/memory-v3-shadow/core-set.ts +59 -0
  308. package/src/plugins/defaults/memory-v3-shadow/ever-injected-store.test.ts +305 -0
  309. package/src/plugins/defaults/memory-v3-shadow/ever-injected-store.ts +278 -0
  310. package/src/plugins/defaults/memory-v3-shadow/hot-set.test.ts +138 -0
  311. package/src/plugins/defaults/memory-v3-shadow/hot-set.ts +85 -0
  312. package/src/plugins/defaults/memory-v3-shadow/injector.ts +331 -24
  313. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +119 -13
  314. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +169 -114
  315. package/src/plugins/defaults/memory-v3-shadow/page-content.ts +47 -16
  316. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +144 -66
  317. package/src/plugins/defaults/memory-v3-shadow/prune.test.ts +758 -0
  318. package/src/plugins/defaults/memory-v3-shadow/prune.ts +471 -0
  319. package/src/plugins/defaults/memory-v3-shadow/render-injection.ts +68 -16
  320. package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +19 -12
  321. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +96 -43
  322. package/src/plugins/defaults/memory-v3-shadow/types.ts +34 -17
  323. package/src/plugins/defaults/title-generate/hooks/stop.ts +9 -11
  324. package/src/plugins/pipeline.ts +8 -5
  325. package/src/plugins/types.ts +5 -51
  326. package/src/providers/cache-control.ts +26 -0
  327. package/src/providers/inference/__tests__/base-url-route-validation.test.ts +1 -2
  328. package/src/providers/model-catalog.ts +13 -1
  329. package/src/providers/openai/__tests__/tool-choice-mapping.test.ts +147 -0
  330. package/src/providers/openai/chat-completions-provider.ts +46 -0
  331. package/src/providers/openai/responses-provider.ts +45 -0
  332. package/src/providers/registry.ts +0 -8
  333. package/src/runtime/__tests__/agent-wake.test.ts +0 -1
  334. package/src/runtime/agent-wake.ts +13 -0
  335. package/src/runtime/routes/__tests__/acp-routes.test.ts +151 -0
  336. package/src/runtime/routes/__tests__/consolidation-routes.test.ts +12 -50
  337. package/src/runtime/routes/__tests__/conversation-surface-routes.test.ts +322 -0
  338. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +0 -62
  339. package/src/runtime/routes/__tests__/plugins-routes.test.ts +31 -11
  340. package/src/runtime/routes/acp-routes.test.ts +106 -0
  341. package/src/runtime/routes/acp-routes.ts +248 -2
  342. package/src/runtime/routes/browser-tabs-routes.ts +1 -1
  343. package/src/runtime/routes/channel-verification-routes.ts +14 -5
  344. package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +0 -14
  345. package/src/runtime/routes/consolidation-routes.ts +6 -82
  346. package/src/runtime/routes/conversation-list-routes.ts +6 -0
  347. package/src/runtime/routes/conversation-management-routes.ts +71 -0
  348. package/src/runtime/routes/identity-routes.ts +8 -0
  349. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +33 -34
  350. package/src/runtime/routes/inference-provider-connection-routes.ts +0 -45
  351. package/src/runtime/routes/plugins-routes.ts +34 -45
  352. package/src/runtime/routes/schedule-routes.ts +43 -5
  353. package/src/runtime/routes/settings-routes.ts +140 -15
  354. package/src/runtime/routes/skills-routes.ts +18 -6
  355. package/src/runtime/services/__tests__/conversation-serializer.test.ts +140 -0
  356. package/src/runtime/services/conversation-serializer.ts +38 -1
  357. package/src/runtime/verification-outbound-actions.ts +147 -2
  358. package/src/runtime/verification-templates.ts +29 -3
  359. package/src/schedule/schedule-store.ts +19 -0
  360. package/src/skills/catalog-install.ts +77 -13
  361. package/src/tasks/task-scheduler.ts +1 -0
  362. package/src/telemetry/types.ts +66 -1
  363. package/src/telemetry/usage-telemetry-reporter.test.ts +542 -13
  364. package/src/telemetry/usage-telemetry-reporter.ts +213 -20
  365. package/src/tools/browser/__tests__/browser-execution-acquire.test.ts +49 -2
  366. package/src/tools/browser/__tests__/browser-status.test.ts +29 -5
  367. package/src/tools/browser/browser-execution.ts +27 -9
  368. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +380 -4
  369. package/src/tools/browser/cdp-client/__tests__/host-bridge-cdp-client.test.ts +107 -0
  370. package/src/tools/browser/cdp-client/__tests__/types.test.ts +6 -1
  371. package/src/tools/browser/cdp-client/factory.ts +217 -17
  372. package/src/tools/browser/cdp-client/host-bridge-cdp-client.ts +67 -0
  373. package/src/tools/browser/cdp-client/types.ts +22 -2
  374. package/src/tools/credential-execution/make-authenticated-request.ts +2 -1
  375. package/src/tools/credential-execution/manage-secure-command-tool.ts +169 -164
  376. package/src/tools/credential-execution/run-authenticated-command.ts +2 -1
  377. package/src/tools/executor.ts +39 -7
  378. package/src/tools/registry.ts +387 -5
  379. package/src/tools/schedule/create.ts +16 -0
  380. package/src/tools/schedule/list.ts +12 -4
  381. package/src/tools/schedule/update.ts +12 -0
  382. package/src/tools/skills/load.ts +11 -6
  383. package/src/tools/terminal/safe-env.ts +2 -0
  384. package/src/tools/types.ts +65 -9
  385. package/src/tools/workspace-tools/loader.ts +673 -0
  386. package/src/usage/attribution.ts +28 -0
  387. package/src/util/device-id.ts +17 -3
  388. package/src/util/platform.ts +16 -0
  389. package/tsconfig.plugin-api.json +13 -0
  390. package/src/__tests__/plugin-external-api.test.ts +0 -68
  391. package/src/__tests__/plugin-skill-contribution.test.ts +0 -355
  392. package/src/daemon/message-types/browser.ts +0 -10
  393. package/src/notifications/__tests__/emit-signal-home-feed.test.ts +0 -187
  394. package/src/plugins/defaults/memory-v3-shadow/__tests__/fixtures/eval-turns.json +0 -36
  395. package/src/plugins/defaults/memory-v3-shadow/__tests__/fixtures/live-turns.json +0 -37
  396. package/src/plugins/defaults/memory-v3-shadow/__tests__/working-set-eviction.test.ts +0 -106
  397. package/src/plugins/defaults/memory-v3-shadow/__tests__/working-set-skeleton.test.ts +0 -44
  398. package/src/plugins/defaults/memory-v3-shadow/working-set.ts +0 -91
  399. package/src/plugins/external-api.ts +0 -114
  400. package/src/plugins/plugin-skill-contributions.ts +0 -292
@@ -0,0 +1,673 @@
1
+ /**
2
+ * Workspace tool loader — discovers and registers tool overrides from
3
+ * `<workspaceDir>/tools/<name>.{ts,js,json}`, plus `<name>.removed`
4
+ * sentinel files that strip a core tool from the registry without
5
+ * substituting a replacement.
6
+ *
7
+ * <workspaceDir>/
8
+ * tools/
9
+ * skill_load.ts ← name="skill_load" via .ts default export
10
+ * my_tool.js ← name="my_tool" via compiled .js (preferred over .ts)
11
+ * data_lookup.json ← name="data_lookup" via JSON spec (no execute)
12
+ * host_bash.removed ← name="host_bash" — strip from registry
13
+ *
14
+ * The filename stem is the registered tool name verbatim — there is no
15
+ * derivation, no basename transformation, no directory layer. This is the
16
+ * documented "directory name is the tool name verbatim" contract, just
17
+ * shifted one level up so each tool is a single file instead of a
18
+ * sub-directory.
19
+ *
20
+ * Override / strip semantics live in the registry
21
+ * ({@link registerWorkspaceTools}, {@link removeCoreToolViaWorkspace}):
22
+ * if a core tool already owns the name, the original is stashed; the
23
+ * workspace tool (or absence-of-tool, for `.removed`) takes its place.
24
+ * Plugins, skills, and MCP servers all refuse to register over a
25
+ * workspace-owned or workspace-stripped name.
26
+ *
27
+ * Lifecycle position:
28
+ *
29
+ * initializeTools()
30
+ * → loadWorkspaceTools() ← this module (first scan)
31
+ * → loadUserPlugins()
32
+ * → bootstrapPlugins()
33
+ * → start file watcher ← hot register/unregister (no restart)
34
+ *
35
+ * Plugins load *after* the initial workspace-tool scan so the registry
36
+ * hands them a stable view of which workspace tools exist before any
37
+ * plugin code runs. The file watcher then runs for the lifetime of the
38
+ * assistant, picking up add/change/delete events to keep the registry
39
+ * in sync with disk.
40
+ *
41
+ * Per-tool isolation:
42
+ *
43
+ * - A file with a name that fails {@link isProviderSafeToolName} is
44
+ * logged at error and skipped — we never silently rewrite the name
45
+ * via hashing at registration time (which would mask the operator's
46
+ * typo behind an unfindable hashed identifier).
47
+ * - A `.ts` whose module body throws during import is logged at error
48
+ * and skipped — one broken workspace tool must never block boot.
49
+ * - A `.json` that doesn't parse, or doesn't produce an object, is
50
+ * logged at error and skipped.
51
+ * - A hung dynamic `import()` is bounded by {@link IMPORT_TIMEOUT_MS}
52
+ * and the offending file is skipped. Same contract as the plugin
53
+ * loader: bun's `import()` cannot be cancelled, so an abandoned
54
+ * import keeps running in the background but the registry guards
55
+ * against late-arriving registrations because we already moved past it.
56
+ *
57
+ * The loader registers tools in a single {@link registerWorkspaceTools}
58
+ * call so the batch-level validation can catch duplicate names across
59
+ * the directory listing before any mutation lands.
60
+ */
61
+
62
+ import { existsSync, readdirSync, statSync } from "node:fs";
63
+ import { readFile } from "node:fs/promises";
64
+ import { extname, join } from "node:path";
65
+ import { pathToFileURL } from "node:url";
66
+
67
+ import { getLogger } from "../../util/logger.js";
68
+ import { getWorkspaceToolsDir } from "../../util/platform.js";
69
+ import { isProviderSafeToolName } from "../provider-tool-name.js";
70
+ import { registerWorkspaceTools, removeCoreToolViaWorkspace } from "../registry.js";
71
+ import { finalizeTool } from "../tool-defaults.js";
72
+ import type {
73
+ RiskLevel,
74
+ Tool,
75
+ ToolDefinition,
76
+ ToolExecutionResult,
77
+ } from "../types.js";
78
+
79
+ const log = getLogger("workspace-tool-loader");
80
+
81
+ /**
82
+ * Upper bound on how long a single workspace tool's dynamic `import()` may
83
+ * take. A tool with a hanging top-level `await` (or a never-resolving
84
+ * module evaluation) would otherwise block daemon startup indefinitely,
85
+ * since a raw `try/catch` only isolates thrown errors — not hung
86
+ * promises. Ten seconds matches the plugin-loader budget for the same
87
+ * isolation reason.
88
+ */
89
+ const IMPORT_TIMEOUT_MS = 10_000;
90
+
91
+ /**
92
+ * File-extension precedence for live tool files. When two files share
93
+ * the same stem (e.g. `tool.ts` + `tool.js`), the loader picks the
94
+ * earlier-listed extension and warns about the rest. Mirrors the
95
+ * external-plugin loader's compiled-binary semantics.
96
+ */
97
+ const LIVE_TOOL_EXTENSIONS = [".js", ".ts", ".json"] as const;
98
+ type LiveToolExtension = (typeof LIVE_TOOL_EXTENSIONS)[number];
99
+
100
+ /** Extension of the "strip core tool" sentinel. */
101
+ const REMOVED_EXTENSION = ".removed" as const;
102
+
103
+ /**
104
+ * Defaults applied when a workspace tool omits one of the normally-required
105
+ * fields. Workspace tools default `defaultRiskLevel` to `"high"` (vs
106
+ * `"medium"` for plugin tools) because they run arbitrary on-disk code
107
+ * under the operator's workspace, so the floor is higher than for
108
+ * in-tree-vetted plugin code.
109
+ *
110
+ * The default `execute` returns an error result so the model sees a clear
111
+ * signal that the tool isn't wired up. The tool still loads cleanly — a
112
+ * broken tool must never block daemon boot.
113
+ */
114
+ const WORKSPACE_TOOL_DEFAULTS = Object.freeze({
115
+ defaultRiskLevel: "high" as RiskLevel,
116
+ });
117
+
118
+ /**
119
+ * Validate that a filename stem is a usable tool name. Tool names must be
120
+ * provider-safe (`[a-zA-Z0-9_-]{1,64}`) so they can be sent verbatim to
121
+ * the LLM provider without the registry's hash-suffix rewrite kicking in
122
+ * and producing an unfindable name like `my tool__abc123def456`. Names
123
+ * starting with `.` are intentionally excluded so dotfiles (.DS_Store,
124
+ * .gitignore, etc.) cannot accidentally claim a tool.
125
+ */
126
+ function isValidToolFilenameStem(stem: string): boolean {
127
+ if (stem.length === 0) return false;
128
+ if (stem.startsWith(".")) return false;
129
+ return isProviderSafeToolName(stem);
130
+ }
131
+
132
+ /**
133
+ * Strip the recognized extension off a filename and return the stem, or
134
+ * `undefined` if the filename doesn't match any recognized extension.
135
+ *
136
+ * Distinguishes between live tool files (`.ts` / `.js` / `.json`) and the
137
+ * `.removed` sentinel so the caller can route each kind to the right
138
+ * code path.
139
+ */
140
+ function classifyEntry(
141
+ entry: string,
142
+ ): { kind: "live"; stem: string; ext: LiveToolExtension } | { kind: "removed"; stem: string } | undefined {
143
+ const ext = extname(entry);
144
+ if (ext === REMOVED_EXTENSION) {
145
+ return { kind: "removed", stem: entry.slice(0, -REMOVED_EXTENSION.length) };
146
+ }
147
+ for (const candidate of LIVE_TOOL_EXTENSIONS) {
148
+ if (ext === candidate) {
149
+ return { kind: "live", stem: entry.slice(0, -candidate.length), ext: candidate };
150
+ }
151
+ }
152
+ return undefined;
153
+ }
154
+
155
+ /**
156
+ * When multiple live files share the same stem (e.g. `foo.ts` + `foo.js`),
157
+ * pick the highest-precedence one per {@link LIVE_TOOL_EXTENSIONS} and
158
+ * return the dropped sibling paths so the caller can warn.
159
+ */
160
+ interface LiveSelection {
161
+ ext: LiveToolExtension;
162
+ shadowed: LiveToolExtension[];
163
+ }
164
+
165
+ function selectLiveExtension(extensions: Set<LiveToolExtension>): LiveSelection {
166
+ for (const candidate of LIVE_TOOL_EXTENSIONS) {
167
+ if (extensions.has(candidate)) {
168
+ const shadowed: LiveToolExtension[] = [];
169
+ for (const ext of LIVE_TOOL_EXTENSIONS) {
170
+ if (ext !== candidate && extensions.has(ext)) shadowed.push(ext);
171
+ }
172
+ return { ext: candidate, shadowed };
173
+ }
174
+ }
175
+ // Unreachable — caller guarantees `extensions` is non-empty.
176
+ throw new Error("selectLiveExtension called with empty set");
177
+ }
178
+
179
+ /**
180
+ * Apply workspace-specific defaults on top of the generic
181
+ * {@link finalizeTool} pipeline. The shared finalizer handles the bulk
182
+ * of the omitted-field filling; we layer two workspace-only overrides
183
+ * on top:
184
+ *
185
+ * 1. `defaultRiskLevel` defaults to `"high"` for workspace tools — they
186
+ * run with full user privilege from disk, so the safety floor is set
187
+ * higher than the generic `"medium"` baseline.
188
+ * 2. The default `execute` mentions "workspace tool" so the model gets
189
+ * a clearer "the workspace file at this name has no executor wired
190
+ * up" signal in the failure transcript.
191
+ *
192
+ * The tool still loads cleanly with these defaults — a broken tool must
193
+ * never block daemon boot. Always sets `category: "workspace"` so the
194
+ * registry can distinguish workspace overrides from other origins.
195
+ */
196
+ function applyWorkspaceToolDefaults(
197
+ tool: ToolDefinition,
198
+ name: string,
199
+ ): Tool {
200
+ const finalized = finalizeTool(
201
+ {
202
+ ...tool,
203
+ defaultRiskLevel:
204
+ tool.defaultRiskLevel ?? WORKSPACE_TOOL_DEFAULTS.defaultRiskLevel,
205
+ category: tool.category ?? "workspace",
206
+ execute:
207
+ typeof tool.execute === "function"
208
+ ? tool.execute
209
+ : async (): Promise<ToolExecutionResult> => ({
210
+ content: `workspace tool ${name} has no execute implementation`,
211
+ isError: true,
212
+ }),
213
+ },
214
+ name,
215
+ );
216
+ return finalized;
217
+ }
218
+
219
+ /**
220
+ * Dynamic-import `absolutePath` with a timeout. Resolves to the imported
221
+ * module's default export, or `undefined` if the import times out, has
222
+ * no default export, or throws.
223
+ *
224
+ * A cache-busting `?v=<counter>` query string is appended so the loader's
225
+ * later re-imports (driven by the file watcher) pick up disk changes
226
+ * instead of node's cached module. The counter is per-call, so every
227
+ * import gets a fresh module identity.
228
+ *
229
+ * All failure paths log with file attribution so operators can find the
230
+ * broken tool quickly.
231
+ */
232
+ let importCounter = 0;
233
+
234
+ async function importToolDefaultBounded(
235
+ entryPath: string,
236
+ timeoutMs: number,
237
+ ): Promise<unknown> {
238
+ const url = `${pathToFileURL(entryPath).href}?v=${++importCounter}`;
239
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
240
+ try {
241
+ const timeoutSentinel = Symbol("workspace-tool-import-timeout");
242
+ const importPromise = import(url) as Promise<{ default?: unknown }>;
243
+ const timeoutPromise = new Promise<typeof timeoutSentinel>((resolve) => {
244
+ timeoutHandle = setTimeout(() => resolve(timeoutSentinel), timeoutMs);
245
+ });
246
+ const result = await Promise.race([importPromise, timeoutPromise]);
247
+ if (result === timeoutSentinel) {
248
+ // Abandoned import — attach a terminal `.catch` so a late
249
+ // rejection cannot surface as an unhandled-rejection crash.
250
+ importPromise.catch(() => {
251
+ /* swallow — module is dead to the daemon */
252
+ });
253
+ log.warn(
254
+ { entryPath, timeoutMs },
255
+ `Timed out importing workspace tool from ${entryPath} after ${timeoutMs}ms — skipping`,
256
+ );
257
+ return undefined;
258
+ }
259
+ if (result.default === undefined) {
260
+ log.error(
261
+ { entryPath },
262
+ `Workspace tool at ${entryPath} has no default export — skipping`,
263
+ );
264
+ return undefined;
265
+ }
266
+ return result.default;
267
+ } catch (err) {
268
+ const message = err instanceof Error ? err.message : String(err);
269
+ log.error(
270
+ { err, entryPath },
271
+ `Failed to import workspace tool from ${entryPath}: ${message}`,
272
+ );
273
+ return undefined;
274
+ } finally {
275
+ if (timeoutHandle !== undefined) clearTimeout(timeoutHandle);
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Read a `.json` tool spec and return its parsed shape, or `undefined`
281
+ * if the file can't be read or doesn't parse to an object.
282
+ *
283
+ * JSON specs can't carry an `execute` function (JSON has no function
284
+ * type), so they always pick up the default error-result executor. They
285
+ * exist for declarative use cases — schema-only tool stubs, override
286
+ * placeholders, etc.
287
+ */
288
+ async function readJsonToolSpec(entryPath: string): Promise<ToolDefinition | undefined> {
289
+ let raw: string;
290
+ try {
291
+ raw = await readFile(entryPath, "utf8");
292
+ } catch (err) {
293
+ log.error(
294
+ { err, entryPath },
295
+ `Failed to read JSON workspace tool at ${entryPath} — skipping`,
296
+ );
297
+ return undefined;
298
+ }
299
+ let parsed: unknown;
300
+ try {
301
+ parsed = JSON.parse(raw);
302
+ } catch (err) {
303
+ const message = err instanceof Error ? err.message : String(err);
304
+ log.error(
305
+ { err, entryPath },
306
+ `Failed to parse JSON workspace tool at ${entryPath}: ${message} — skipping`,
307
+ );
308
+ return undefined;
309
+ }
310
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
311
+ log.error(
312
+ { entryPath, type: typeof parsed },
313
+ `JSON workspace tool at ${entryPath} must be an object — skipping`,
314
+ );
315
+ return undefined;
316
+ }
317
+ return parsed as ToolDefinition;
318
+ }
319
+
320
+ export interface LoadWorkspaceToolsOptions {
321
+ /**
322
+ * Maximum time to spend importing each tool file before bailing.
323
+ * Defaults to {@link IMPORT_TIMEOUT_MS}.
324
+ */
325
+ readonly importTimeoutMs?: number;
326
+ }
327
+
328
+ /**
329
+ * Result of a {@link loadWorkspaceTools} call — exposes which tool names
330
+ * were registered and which were stripped so callers (notably the file
331
+ * watcher) can compute deltas against subsequent re-scans.
332
+ */
333
+ export interface LoadWorkspaceToolsResult {
334
+ /** Tool names successfully registered as workspace tools. */
335
+ readonly registered: string[];
336
+ /** Tool names stripped from the registry via `<name>.removed` sentinels. */
337
+ readonly removed: string[];
338
+ }
339
+
340
+ /**
341
+ * Scan `<workspaceDir>/tools/` and register every well-formed
342
+ * `<name>.{ts,js,json}` as a workspace tool. Files matching
343
+ * `<name>.removed` strip the core tool of that name from the registry
344
+ * via {@link removeCoreToolViaWorkspace}.
345
+ *
346
+ * Invariants:
347
+ *
348
+ * - No-ops silently when the tools directory does not exist. A clean
349
+ * install with zero workspace tools must produce no log noise beyond
350
+ * the eventual "0 workspace tools registered" debug line.
351
+ * - Per-tool isolation: any single broken tool is logged and skipped
352
+ * without aborting the scan. The function resolves normally even when
353
+ * every candidate fails.
354
+ * - Idempotency is the registry's job: a second call without a
355
+ * preceding unregister will throw on the duplicate-workspace-tool
356
+ * check. Callers (daemon startup) are expected to call once; the file
357
+ * watcher uses the per-event entry points instead.
358
+ *
359
+ * Caller responsibilities:
360
+ *
361
+ * - Must be invoked between {@link initializeTools} and
362
+ * {@link loadUserPlugins}. Calling earlier risks racing core
363
+ * registrations; calling later means plugins see an incomplete
364
+ * registry and may register over a name a workspace tool will later
365
+ * try to own.
366
+ */
367
+ export async function loadWorkspaceTools(
368
+ options: LoadWorkspaceToolsOptions = {},
369
+ ): Promise<LoadWorkspaceToolsResult> {
370
+ const importTimeoutMs = options.importTimeoutMs ?? IMPORT_TIMEOUT_MS;
371
+ const toolsDir = getWorkspaceToolsDir();
372
+
373
+ if (!existsSync(toolsDir)) {
374
+ log.debug({ toolsDir }, "Workspace tools directory does not exist — skipping");
375
+ return { registered: [], removed: [] };
376
+ }
377
+
378
+ let entries: string[];
379
+ try {
380
+ entries = readdirSync(toolsDir);
381
+ } catch (err) {
382
+ log.warn(
383
+ { err, toolsDir },
384
+ "loadWorkspaceTools: failed to read tools directory — continuing with no workspace tools",
385
+ );
386
+ return { registered: [], removed: [] };
387
+ }
388
+
389
+ // Group entries by stem so we can detect multi-extension shadowing
390
+ // (e.g. `foo.ts` + `foo.js` claiming the same name) before we kick off
391
+ // any imports. Each stem maps to a Set of extensions; .removed sentinels
392
+ // are tracked in a separate set since they're mutually exclusive with
393
+ // live tool files (you don't strip AND register a name at once).
394
+ const liveByStem = new Map<string, Set<LiveToolExtension>>();
395
+ const removedStems = new Set<string>();
396
+
397
+ for (const entry of entries) {
398
+ const fullPath = join(toolsDir, entry);
399
+ let stats;
400
+ try {
401
+ stats = statSync(fullPath);
402
+ } catch (err) {
403
+ log.warn(
404
+ { err, fullPath },
405
+ "loadWorkspaceTools: failed to stat candidate — skipping",
406
+ );
407
+ continue;
408
+ }
409
+ if (!stats.isFile()) {
410
+ // Subdirectories, symlinks-to-dir, sockets, etc. are silently
411
+ // ignored. The convention is per-tool single files at the top
412
+ // of `<workspaceDir>/tools/`.
413
+ continue;
414
+ }
415
+
416
+ const classified = classifyEntry(entry);
417
+ if (!classified) continue;
418
+ if (!isValidToolFilenameStem(classified.stem)) {
419
+ log.error(
420
+ { entry, stem: classified.stem, toolsDir },
421
+ `loadWorkspaceTools: filename stem "${classified.stem}" is not a provider-safe tool name (must match /^[a-zA-Z0-9_-]{1,64}$/) — skipping`,
422
+ );
423
+ continue;
424
+ }
425
+
426
+ if (classified.kind === "removed") {
427
+ removedStems.add(classified.stem);
428
+ continue;
429
+ }
430
+ let extensions = liveByStem.get(classified.stem);
431
+ if (!extensions) {
432
+ extensions = new Set<LiveToolExtension>();
433
+ liveByStem.set(classified.stem, extensions);
434
+ }
435
+ extensions.add(classified.ext);
436
+ }
437
+
438
+ // A stem cannot both be live AND removed. Operator intent is ambiguous;
439
+ // log and skip both entries for that name so neither lands.
440
+ for (const stem of removedStems) {
441
+ if (liveByStem.has(stem)) {
442
+ log.error(
443
+ { stem, toolsDir },
444
+ `loadWorkspaceTools: "${stem}" has both a live tool file and a .removed sentinel — skipping both (ambiguous intent)`,
445
+ );
446
+ liveByStem.delete(stem);
447
+ removedStems.delete(stem);
448
+ }
449
+ }
450
+
451
+ // Apply removals before registrations so the batch validation in
452
+ // registerWorkspaceTools sees the post-removal registry state.
453
+ const removed: string[] = [];
454
+ for (const stem of removedStems) {
455
+ try {
456
+ removeCoreToolViaWorkspace(stem);
457
+ removed.push(stem);
458
+ } catch (err) {
459
+ const message = err instanceof Error ? err.message : String(err);
460
+ log.error(
461
+ { err, stem },
462
+ `loadWorkspaceTools: failed to strip core tool "${stem}": ${message}`,
463
+ );
464
+ }
465
+ }
466
+
467
+ // Resolve each live stem to its winning entry, import it, and add to
468
+ // the registration batch. Multi-extension shadowing warns once per
469
+ // ignored sibling so the operator can clean up the redundant file.
470
+ const batch: Array<{ tool: Tool; workspacePath: string }> = [];
471
+
472
+ for (const [stem, extensions] of liveByStem) {
473
+ const { ext: winningExt, shadowed } = selectLiveExtension(extensions);
474
+ if (shadowed.length > 0) {
475
+ log.warn(
476
+ { stem, winningExt, shadowed, toolsDir },
477
+ `loadWorkspaceTools: "${stem}" has multiple files (${[winningExt, ...shadowed].join(", ")}) — using ${winningExt} and ignoring the rest`,
478
+ );
479
+ }
480
+ const entryPath = join(toolsDir, `${stem}${winningExt}`);
481
+
482
+ let toolSpec: ToolDefinition | undefined;
483
+ if (winningExt === ".json") {
484
+ toolSpec = await readJsonToolSpec(entryPath);
485
+ } else {
486
+ const defaultExport = await importToolDefaultBounded(entryPath, importTimeoutMs);
487
+ if (defaultExport === undefined) continue; // Failure already logged.
488
+ if (defaultExport === null || typeof defaultExport !== "object") {
489
+ log.error(
490
+ { entryPath, type: typeof defaultExport },
491
+ `Workspace tool at ${entryPath} default export must be an object — skipping`,
492
+ );
493
+ continue;
494
+ }
495
+ toolSpec = defaultExport as ToolDefinition;
496
+ }
497
+ if (!toolSpec) continue;
498
+
499
+ const loaded = applyWorkspaceToolDefaults(toolSpec, stem);
500
+ batch.push({ tool: loaded, workspacePath: entryPath });
501
+ }
502
+
503
+ if (batch.length === 0) {
504
+ if (removed.length === 0) {
505
+ log.debug(
506
+ { toolsDir },
507
+ "loadWorkspaceTools: no workspace tools to register or strip",
508
+ );
509
+ } else {
510
+ log.info(
511
+ { toolsDir, removedCount: removed.length, removed },
512
+ `Stripped ${removed.length} core tool${removed.length === 1 ? "" : "s"} via workspace .removed sentinels`,
513
+ );
514
+ }
515
+ return { registered: [], removed };
516
+ }
517
+
518
+ try {
519
+ const accepted = registerWorkspaceTools(batch);
520
+ log.info(
521
+ { count: accepted.length, toolsDir, removedCount: removed.length },
522
+ `Registered ${accepted.length} workspace tool${accepted.length === 1 ? "" : "s"}${removed.length > 0 ? ` (and stripped ${removed.length} core tool${removed.length === 1 ? "" : "s"})` : ""}`,
523
+ );
524
+ return { registered: accepted.map((t) => t.name), removed };
525
+ } catch (err) {
526
+ // A throw from registerWorkspaceTools means a hard conflict (e.g.
527
+ // duplicate name in batch, lifecycle-order regression). The batch
528
+ // validation phase guarantees no partial application landed, so
529
+ // every workspace tool from this load attempt is absent from the
530
+ // registry. Surface the error loudly but do NOT rethrow — assistant
531
+ // startup must still complete.
532
+ const message = err instanceof Error ? err.message : String(err);
533
+ log.error(
534
+ { err, toolsDir, batchSize: batch.length },
535
+ `loadWorkspaceTools: registry rejected batch — ${message}`,
536
+ );
537
+ return { registered: [], removed };
538
+ }
539
+ }
540
+
541
+ // ─── Single-entry helpers for the file watcher ───────────────────────────────
542
+ //
543
+ // The watcher calls these on each fs event. The initial-scan path
544
+ // ({@link loadWorkspaceTools}) batches for transactional registration;
545
+ // the per-event path takes the simpler one-tool-at-a-time route since
546
+ // fs events arrive serially and the registry handles each as an
547
+ // atomic operation.
548
+
549
+ /**
550
+ * Load and register a single workspace tool file. Returns the registered
551
+ * tool name on success or `undefined` if the file failed to load (errors
552
+ * are logged with file attribution and never thrown to the caller).
553
+ *
554
+ * Used by the file watcher's `add` event. The caller is expected to
555
+ * have already unregistered any prior workspace tool for the same name.
556
+ */
557
+ export async function loadSingleWorkspaceTool(
558
+ entryPath: string,
559
+ options: LoadWorkspaceToolsOptions = {},
560
+ ): Promise<string | undefined> {
561
+ const importTimeoutMs = options.importTimeoutMs ?? IMPORT_TIMEOUT_MS;
562
+ const filename = entryPath.split("/").pop() ?? "";
563
+ const classified = classifyEntry(filename);
564
+ if (!classified || classified.kind !== "live") {
565
+ log.debug(
566
+ { entryPath },
567
+ "loadSingleWorkspaceTool: file is not a live tool entry — skipping",
568
+ );
569
+ return undefined;
570
+ }
571
+ if (!isValidToolFilenameStem(classified.stem)) {
572
+ log.error(
573
+ { entryPath, stem: classified.stem },
574
+ `loadSingleWorkspaceTool: filename stem "${classified.stem}" is not a provider-safe tool name — skipping`,
575
+ );
576
+ return undefined;
577
+ }
578
+
579
+ let toolSpec: ToolDefinition | undefined;
580
+ if (classified.ext === ".json") {
581
+ toolSpec = await readJsonToolSpec(entryPath);
582
+ } else {
583
+ const defaultExport = await importToolDefaultBounded(entryPath, importTimeoutMs);
584
+ if (defaultExport === undefined) return undefined;
585
+ if (defaultExport === null || typeof defaultExport !== "object") {
586
+ log.error(
587
+ { entryPath, type: typeof defaultExport },
588
+ `Workspace tool at ${entryPath} default export must be an object — skipping`,
589
+ );
590
+ return undefined;
591
+ }
592
+ toolSpec = defaultExport as ToolDefinition;
593
+ }
594
+ if (!toolSpec) return undefined;
595
+
596
+ const loaded = applyWorkspaceToolDefaults(toolSpec, classified.stem);
597
+ try {
598
+ registerWorkspaceTools([{ tool: loaded, workspacePath: entryPath }]);
599
+ return classified.stem;
600
+ } catch (err) {
601
+ const message = err instanceof Error ? err.message : String(err);
602
+ log.error(
603
+ { err, entryPath },
604
+ `loadSingleWorkspaceTool: registry rejected "${classified.stem}": ${message}`,
605
+ );
606
+ return undefined;
607
+ }
608
+ }
609
+
610
+ /**
611
+ * Classify a single filesystem entry. Exposed for the file watcher so
612
+ * it can route events without re-implementing the extension logic.
613
+ */
614
+ export function classifyWorkspaceToolEntry(
615
+ filename: string,
616
+ ):
617
+ | { kind: "live"; stem: string; ext: LiveToolExtension }
618
+ | { kind: "removed"; stem: string }
619
+ | undefined {
620
+ return classifyEntry(filename);
621
+ }
622
+
623
+ /**
624
+ * Scan `toolsDir` for all entries matching `stem` and return the winning
625
+ * live file's absolute path (if any) plus whether a `.removed` sentinel
626
+ * exists for the same stem.
627
+ *
628
+ * Multi-extension precedence: `.js` > `.ts` > `.json`. Shadowed siblings
629
+ * are not reported here — the caller decides whether to warn (the full
630
+ * scan path does; the per-stem watcher does not, because shadow events
631
+ * are noisy in editor save flows).
632
+ */
633
+ export function findWinningWorkspaceToolPath(
634
+ toolsDir: string,
635
+ stem: string,
636
+ ): { livePath: string | null; liveExt: LiveToolExtension | null; hasRemovedSentinel: boolean } {
637
+ if (!existsSync(toolsDir)) {
638
+ return { livePath: null, liveExt: null, hasRemovedSentinel: false };
639
+ }
640
+ let entries: string[];
641
+ try {
642
+ entries = readdirSync(toolsDir);
643
+ } catch (err) {
644
+ log.warn(
645
+ { err, toolsDir, stem },
646
+ "findWinningWorkspaceToolPath: failed to read tools directory",
647
+ );
648
+ return { livePath: null, liveExt: null, hasRemovedSentinel: false };
649
+ }
650
+
651
+ const liveExtensions = new Set<LiveToolExtension>();
652
+ let hasRemovedSentinel = false;
653
+
654
+ for (const entry of entries) {
655
+ const classified = classifyEntry(entry);
656
+ if (!classified || classified.stem !== stem) continue;
657
+ if (classified.kind === "removed") {
658
+ hasRemovedSentinel = true;
659
+ } else {
660
+ liveExtensions.add(classified.ext);
661
+ }
662
+ }
663
+
664
+ if (liveExtensions.size === 0) {
665
+ return { livePath: null, liveExt: null, hasRemovedSentinel };
666
+ }
667
+ const { ext } = selectLiveExtension(liveExtensions);
668
+ return {
669
+ livePath: join(toolsDir, `${stem}${ext}`),
670
+ liveExt: ext,
671
+ hasRemovedSentinel,
672
+ };
673
+ }
@@ -42,6 +42,34 @@ export interface UsageAttributionSnapshot {
42
42
  resolvedMixArm: string | null;
43
43
  }
44
44
 
45
+ /**
46
+ * The four nullable attribution columns shared by telemetry event rows
47
+ * (`tool_invocations`, `skill_loaded_events`).
48
+ */
49
+ export interface UsageAttributionColumns {
50
+ provider: string | null;
51
+ model: string | null;
52
+ inferenceProfile: string | null;
53
+ inferenceProfileSource: string | null;
54
+ }
55
+
56
+ /**
57
+ * Maps an attribution snapshot to the shared telemetry columns — the same
58
+ * mapping `llm_usage` reporting uses (`appliedProfile` → inference_profile,
59
+ * `profileSource` → inference_profile_source). Accepts a missing snapshot so
60
+ * producers that resolve attribution best-effort can pass it through as-is.
61
+ */
62
+ export function toAttributionColumns(
63
+ snapshot: UsageAttributionSnapshot | null | undefined,
64
+ ): UsageAttributionColumns {
65
+ return {
66
+ provider: snapshot?.resolvedProvider ?? null,
67
+ model: snapshot?.resolvedModel ?? null,
68
+ inferenceProfile: snapshot?.appliedProfile ?? null,
69
+ inferenceProfileSource: snapshot?.profileSource ?? null,
70
+ };
71
+ }
72
+
45
73
  /**
46
74
  * Sanitizes values before they are copied into external metadata surfaces.
47
75
  * Empty strings and control-character-bearing strings are dropped, and long