@vellumai/assistant 0.8.8 → 0.8.9-staging.2

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 (360) hide show
  1. package/ARCHITECTURE.md +6 -6
  2. package/bun.lock +2 -2
  3. package/examples/plugins/echo/README.md +61 -60
  4. package/examples/plugins/echo/hooks/post-tool-use.ts +18 -0
  5. package/examples/plugins/echo/hooks/stop.ts +16 -0
  6. package/examples/plugins/echo/hooks/user-prompt-submit.ts +18 -0
  7. package/examples/plugins/echo/package.json +1 -2
  8. package/examples/plugins/echo/src/emit.ts +19 -0
  9. package/node_modules/@vellumai/skill-host-contracts/src/skill-host.ts +7 -6
  10. package/openapi.yaml +235 -6
  11. package/package.json +2 -2
  12. package/src/__tests__/agent-loop-callsite-precedence.test.ts +69 -14
  13. package/src/__tests__/agent-loop-exit-reason.test.ts +204 -144
  14. package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +50 -35
  15. package/src/__tests__/agent-loop-output-hooks.test.ts +357 -0
  16. package/src/__tests__/agent-loop-override-profile.test.ts +25 -6
  17. package/src/__tests__/agent-loop-provider-error-recording.test.ts +41 -21
  18. package/src/__tests__/agent-loop-thinking.test.ts +36 -20
  19. package/src/__tests__/agent-loop.test.ts +441 -96
  20. package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +14 -14
  21. package/src/__tests__/agent-wake-override-profile.test.ts +17 -21
  22. package/src/__tests__/anthropic-provider.test.ts +1 -1
  23. package/src/__tests__/app-builder-tool-scripts.test.ts +21 -0
  24. package/src/__tests__/app-control-flow.test.ts +1 -1
  25. package/src/__tests__/app-dir-path-guard.test.ts +1 -0
  26. package/src/__tests__/app-executors.test.ts +132 -0
  27. package/src/__tests__/approval-cascade.test.ts +5 -4
  28. package/src/__tests__/approval-routes-http.test.ts +4 -1
  29. package/src/__tests__/background-workers-disk-pressure.test.ts +1 -1
  30. package/src/__tests__/channel-approval-routes.test.ts +1 -1
  31. package/src/__tests__/channel-approvals.test.ts +1 -1
  32. package/src/__tests__/compaction-circuit.test.ts +258 -0
  33. package/src/__tests__/compaction-direct.test.ts +132 -0
  34. package/src/__tests__/compaction-events.test.ts +5 -5
  35. package/src/__tests__/compactor-web-search-strip.test.ts +213 -0
  36. package/src/__tests__/context-overflow-reducer.test.ts +1 -1
  37. package/src/__tests__/conversation-abort-tool-results.test.ts +6 -4
  38. package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +7 -10
  39. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +78 -119
  40. package/src/__tests__/conversation-agent-loop-overflow.test.ts +142 -218
  41. package/src/__tests__/conversation-agent-loop.test.ts +297 -586
  42. package/src/__tests__/conversation-clean-command.test.ts +5 -2
  43. package/src/__tests__/conversation-confirmation-signals.test.ts +5 -4
  44. package/src/__tests__/conversation-crud-inference-profile.test.ts +7 -9
  45. package/src/__tests__/conversation-history-web-search.test.ts +1 -1
  46. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +4 -4
  47. package/src/__tests__/conversation-process-callsite.test.ts +14 -14
  48. package/src/__tests__/conversation-provider-retry-repair.test.ts +70 -65
  49. package/src/__tests__/conversation-queue.test.ts +9 -9
  50. package/src/__tests__/conversation-runtime-assembly.test.ts +923 -231
  51. package/src/__tests__/conversation-runtime-workspace.test.ts +115 -20
  52. package/src/__tests__/conversation-slash-queue.test.ts +6 -4
  53. package/src/__tests__/conversation-slash-unknown.test.ts +6 -4
  54. package/src/__tests__/conversation-speed-override.test.ts +10 -9
  55. package/src/__tests__/conversation-starter-routes.test.ts +14 -6
  56. package/src/__tests__/conversation-workspace-cache-state.test.ts +23 -20
  57. package/src/__tests__/conversation-workspace-injection.test.ts +68 -6
  58. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +14 -11
  59. package/src/__tests__/conversations-import-system-filter.test.ts +101 -0
  60. package/src/__tests__/credential-security-invariants.test.ts +0 -1
  61. package/src/__tests__/db-acp-history.test.ts +101 -0
  62. package/src/__tests__/dynamic-page-surface.test.ts +31 -0
  63. package/src/__tests__/empty-response-hook.test.ts +1 -1
  64. package/src/__tests__/file-write-tool.test.ts +63 -0
  65. package/src/__tests__/gateway-only-guard.test.ts +12 -2
  66. package/src/__tests__/guardian-grant-minting.test.ts +1 -1
  67. package/src/__tests__/guardian-routing-invariants.test.ts +2 -4
  68. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +1 -1
  69. package/src/__tests__/heartbeat-disk-pressure.test.ts +1 -0
  70. package/src/__tests__/heartbeat-service.test.ts +1 -0
  71. package/src/__tests__/history-repair-hook.test.ts +1 -1
  72. package/src/__tests__/host-app-control-routes.test.ts +1 -1
  73. package/src/__tests__/host-cu-routes-targeted.test.ts +3 -3
  74. package/src/__tests__/inference-profile-reaper.test.ts +62 -0
  75. package/src/__tests__/inference-profile-session-handler.test.ts +86 -0
  76. package/src/__tests__/injector-background-turn.test.ts +13 -23
  77. package/src/__tests__/injector-chain.test.ts +268 -44
  78. package/src/__tests__/injector-disk-pressure.test.ts +210 -52
  79. package/src/__tests__/injector-document-comments.test.ts +97 -114
  80. package/src/__tests__/injector-pkb-v2-silenced.test.ts +2 -2
  81. package/src/__tests__/injector-v3-suppression.test.ts +4 -4
  82. package/src/__tests__/list-messages-client-message-id.test.ts +91 -0
  83. package/src/__tests__/list-messages-hidden-metadata.test.ts +38 -0
  84. package/src/__tests__/memory-retrieval-hook.test.ts +131 -26
  85. package/src/__tests__/memory-v2-static-injector.test.ts +86 -8
  86. package/src/__tests__/parallel-tool.benchmark.test.ts +35 -8
  87. package/src/__tests__/plugin-api-shim.test.ts +6 -9
  88. package/src/__tests__/plugin-bootstrap.test.ts +12 -23
  89. package/src/__tests__/plugin-registry.test.ts +3 -49
  90. package/src/__tests__/plugin-types.test.ts +0 -70
  91. package/src/__tests__/pre-model-call-sanitize.test.ts +109 -0
  92. package/src/__tests__/reaction-persistence.test.ts +1 -1
  93. package/src/__tests__/send-endpoint-busy.test.ts +4 -1
  94. package/src/__tests__/skill-feature-flags-integration.test.ts +33 -0
  95. package/src/__tests__/steer-tool-repair.test.ts +1 -1
  96. package/src/__tests__/subagent-call-site-routing.test.ts +1 -1
  97. package/src/__tests__/subagent-detail.test.ts +25 -7
  98. package/src/__tests__/subagent-fork-notifications.test.ts +1 -3
  99. package/src/__tests__/subagent-fork-spawn.test.ts +1 -1
  100. package/src/__tests__/subagent-manager-notify.test.ts +1 -3
  101. package/src/__tests__/subagent-notify-parent.test.ts +1 -3
  102. package/src/__tests__/subagent-spawn-tool-fork.test.ts +1 -1
  103. package/src/__tests__/title-generate-hook.test.ts +1 -1
  104. package/src/__tests__/tool-error-hook.test.ts +1 -1
  105. package/src/__tests__/tool-result-truncate-hook.test.ts +1 -1
  106. package/src/__tests__/user-plugin-loader.test.ts +54 -286
  107. package/src/acp/__tests__/agent-process.test.ts +161 -0
  108. package/src/acp/__tests__/client-handler.test.ts +40 -0
  109. package/src/acp/__tests__/helpers/acp-history-db.ts +82 -0
  110. package/src/acp/__tests__/helpers/exec-file-stub.ts +106 -0
  111. package/src/acp/__tests__/prepare-agent-env.test.ts +97 -0
  112. package/src/acp/__tests__/session-manager-persistence.test.ts +95 -28
  113. package/src/acp/__tests__/session-manager-resume.test.ts +888 -0
  114. package/src/acp/agent-process.ts +61 -1
  115. package/src/acp/auto-install.test.ts +280 -0
  116. package/src/acp/auto-install.ts +232 -0
  117. package/src/acp/client-handler.ts +31 -0
  118. package/src/acp/feature-gate.test.ts +48 -0
  119. package/src/acp/feature-gate.ts +34 -0
  120. package/src/acp/prepare-agent-env.ts +80 -27
  121. package/src/acp/resolve-agent.test.ts +225 -9
  122. package/src/acp/resolve-agent.ts +122 -17
  123. package/src/acp/resume-hint.ts +23 -0
  124. package/src/acp/session-manager.ts +507 -73
  125. package/src/agent/compaction-circuit.ts +60 -102
  126. package/src/agent/loop.ts +414 -248
  127. package/src/api/responses/conversation-message.ts +14 -1
  128. package/src/approvals/guardian-request-resolvers.ts +1 -1
  129. package/src/background-wake/next-wake.ts +1 -0
  130. package/src/cli/commands/db/__tests__/repair.test.ts +3 -1
  131. package/src/cli/commands/plugins.ts +43 -37
  132. package/src/cli/lib/__tests__/install-from-github.test.ts +429 -111
  133. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +196 -0
  134. package/src/cli/lib/__tests__/plugin-details.test.ts +372 -0
  135. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +220 -0
  136. package/src/cli/lib/__tests__/search-plugins.test.ts +226 -32
  137. package/src/cli/lib/install-from-github.ts +464 -55
  138. package/src/cli/lib/plugin-catalog-cache.ts +84 -0
  139. package/src/cli/lib/plugin-details.ts +409 -0
  140. package/src/cli/lib/plugin-marketplace.ts +197 -0
  141. package/src/cli/lib/search-plugins.ts +195 -29
  142. package/src/config/__tests__/feature-flag-registry-guard.test.ts +2 -2
  143. package/src/config/acp-defaults.test.ts +10 -0
  144. package/src/config/acp-defaults.ts +6 -0
  145. package/src/config/bundled-skills/acp/SKILL.md +85 -33
  146. package/src/config/bundled-skills/acp/TOOLS.json +4 -4
  147. package/src/config/bundled-skills/app-builder/SKILL.md +224 -381
  148. package/src/config/bundled-skills/app-builder/TOOLS.json +72 -2
  149. package/src/config/bundled-skills/app-builder/references/DESIGN_SYSTEM.md +48 -0
  150. package/src/config/bundled-skills/app-builder/references/RESPONSIVE.md +57 -0
  151. package/src/config/bundled-skills/app-builder/references/SLIDES.md +38 -0
  152. package/src/config/bundled-skills/app-builder/tools/app-list.ts +62 -0
  153. package/src/config/bundled-skills/app-builder/tools/app-update.ts +18 -0
  154. package/src/config/bundled-skills/document-editor/SKILL.md +28 -23
  155. package/src/config/bundled-skills/document-editor/TOOLS.json +1 -1
  156. package/src/config/bundled-tool-registry.ts +4 -0
  157. package/src/config/call-site-defaults.ts +0 -2
  158. package/src/config/feature-flag-registry.json +15 -6
  159. package/src/config/schemas/call-site-catalog.ts +0 -14
  160. package/src/config/schemas/heartbeat.ts +9 -0
  161. package/src/config/schemas/llm.ts +0 -2
  162. package/src/context/compactor.ts +22 -4
  163. package/src/context/strip-injections.ts +8 -2
  164. package/src/context/window-manager.ts +27 -13
  165. package/src/daemon/conversation-agent-loop-handlers.ts +10 -35
  166. package/src/daemon/conversation-agent-loop.ts +175 -1055
  167. package/src/daemon/conversation-lifecycle.ts +11 -255
  168. package/src/daemon/conversation-process.ts +8 -136
  169. package/src/daemon/conversation-registry.ts +159 -0
  170. package/src/daemon/conversation-runtime-assembly.ts +293 -392
  171. package/src/daemon/conversation-store.ts +9 -90
  172. package/src/daemon/conversation-surfaces.ts +24 -8
  173. package/src/daemon/conversation-workspace.ts +17 -0
  174. package/src/daemon/conversation.ts +404 -57
  175. package/src/daemon/disk-pressure-policy.ts +0 -1
  176. package/src/daemon/external-plugins-bootstrap.ts +14 -19
  177. package/src/daemon/handlers/conversations.ts +3 -1
  178. package/src/daemon/handlers/skills.ts +4 -1
  179. package/src/daemon/host-proxy-preactivation.ts +1 -3
  180. package/src/daemon/lifecycle.ts +21 -7
  181. package/src/daemon/server.ts +2 -0
  182. package/src/daemon/wake-conversation-ops.ts +269 -0
  183. package/src/embedded/plugin-api.ts +2 -2
  184. package/src/export/__tests__/transcript-formatter.test.ts +5 -0
  185. package/src/heartbeat/__tests__/heartbeat-service.test.ts +3 -0
  186. package/src/heartbeat/heartbeat-run-store.ts +23 -1
  187. package/src/heartbeat/heartbeat-service.ts +26 -0
  188. package/src/ipc/__tests__/browser-ipc.test.ts +1 -1
  189. package/src/ipc/__tests__/ui-request-route.test.ts +3 -3
  190. package/src/ipc/skill-routes/__tests__/memory.test.ts +15 -0
  191. package/src/ipc/skill-routes/memory.ts +4 -2
  192. package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +87 -0
  193. package/src/memory/conversation-crud.ts +29 -19
  194. package/src/memory/conversation-starter-checkpoints.ts +1 -0
  195. package/src/memory/db-init.ts +2 -0
  196. package/src/memory/job-handlers/conversation-starters.ts +13 -2
  197. package/src/memory/jobs/__tests__/embed-concept-page.test.ts +5 -4
  198. package/src/memory/jobs-worker.ts +25 -1
  199. package/src/memory/migrations/272-acp-session-history-cwd.ts +36 -0
  200. package/src/memory/migrations/index.ts +1 -0
  201. package/src/memory/schema/acp.ts +4 -0
  202. package/src/memory/v2/__tests__/consolidation-job.test.ts +3 -3
  203. package/src/memory/v2/consolidation-job.ts +13 -4
  204. package/src/plugin-api/constants.ts +4 -0
  205. package/src/plugin-api/index.ts +6 -5
  206. package/src/plugin-api/types.ts +75 -0
  207. package/src/plugins/defaults/compaction/compact.ts +59 -0
  208. package/src/{daemon → plugins/defaults/compaction}/context-overflow-reducer.ts +7 -7
  209. package/src/plugins/defaults/compaction/manager-store.ts +57 -0
  210. package/src/plugins/defaults/compaction/package.json +1 -2
  211. package/src/plugins/defaults/empty-response/package.json +0 -1
  212. package/src/plugins/defaults/history-repair/package.json +0 -1
  213. package/src/plugins/defaults/index.ts +135 -26
  214. package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +100 -52
  215. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +184 -74
  216. package/src/plugins/defaults/memory-retrieval/injector-chain.ts +2 -2
  217. package/src/plugins/defaults/{injectors/register.ts → memory-retrieval/injectors.ts} +148 -73
  218. package/src/plugins/defaults/memory-retrieval/unified-turn-context.ts +223 -0
  219. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/assign.test.ts +4 -4
  220. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/live-integration.test.ts +9 -6
  221. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/maintain-job.test.ts +5 -5
  222. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/orchestrate.test.ts +8 -5
  223. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/reconcile.test.ts +2 -2
  224. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/render-injection.test.ts +1 -1
  225. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/router.test.ts +10 -5
  226. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/selection-log-store.test.ts +8 -8
  227. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/selector.test.ts +5 -5
  228. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/shadow-plugin.test.ts +16 -17
  229. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/types.test.ts +2 -2
  230. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/assign.ts +9 -5
  231. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/capabilities.ts +5 -2
  232. package/src/plugins/defaults/memory-v3-shadow/hooks/post-compact.ts +14 -0
  233. package/src/plugins/defaults/memory-v3-shadow/hooks/user-prompt-submit.ts +19 -0
  234. package/src/plugins/defaults/memory-v3-shadow/injector.ts +75 -0
  235. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/maintain-job.ts +15 -8
  236. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/orchestrate.ts +2 -2
  237. package/src/plugins/defaults/memory-v3-shadow/package.json +14 -0
  238. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/page-content.ts +2 -2
  239. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/provider-blocks.ts +1 -1
  240. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/reconcile.ts +7 -3
  241. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/render-injection.ts +1 -1
  242. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/router.ts +5 -5
  243. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/selection-log-store.ts +4 -4
  244. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/selector.ts +7 -7
  245. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/shadow-plugin.ts +32 -94
  246. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/tree.ts +1 -1
  247. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/types.ts +1 -1
  248. package/src/plugins/defaults/title-generate/package.json +0 -1
  249. package/src/plugins/defaults/tool-error/package.json +0 -1
  250. package/src/plugins/defaults/tool-result-truncate/package.json +0 -1
  251. package/src/plugins/pipeline.ts +6 -293
  252. package/src/plugins/registry.ts +9 -37
  253. package/src/plugins/types.ts +76 -381
  254. package/src/plugins/user-loader.ts +30 -127
  255. package/src/prompts/__tests__/system-prompt.test.ts +6 -0
  256. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +35 -3
  257. package/src/runtime/__tests__/agent-wake.test.ts +555 -691
  258. package/src/runtime/__tests__/interactive-ui.test.ts +1 -1
  259. package/src/runtime/agent-wake.ts +108 -209
  260. package/src/runtime/assistant-event-hub.ts +1 -1
  261. package/src/runtime/channel-approvals.ts +1 -1
  262. package/src/runtime/interactive-ui.ts +1 -1
  263. package/src/runtime/routes/__tests__/acp-routes.test.ts +315 -55
  264. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +1 -1
  265. package/src/runtime/routes/__tests__/plugins-routes.test.ts +423 -73
  266. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +5 -4
  267. package/src/runtime/routes/__tests__/surface-content-routes.test.ts +4 -1
  268. package/src/runtime/routes/acp-routes.test.ts +89 -25
  269. package/src/runtime/routes/acp-routes.ts +81 -29
  270. package/src/runtime/routes/approval-routes.ts +1 -1
  271. package/src/runtime/routes/browser-routes.ts +1 -1
  272. package/src/runtime/routes/browser-tabs-routes.ts +6 -10
  273. package/src/runtime/routes/conversation-cli-routes.ts +1 -1
  274. package/src/runtime/routes/conversation-list-routes.ts +1 -1
  275. package/src/runtime/routes/conversation-query-routes.ts +1 -1
  276. package/src/runtime/routes/conversation-routes.ts +28 -2
  277. package/src/runtime/routes/conversation-starter-routes.ts +13 -7
  278. package/src/runtime/routes/conversations-import-routes.ts +24 -7
  279. package/src/runtime/routes/host-app-control-routes.ts +1 -1
  280. package/src/runtime/routes/host-cu-routes.ts +1 -1
  281. package/src/runtime/routes/identity-routes.ts +18 -3
  282. package/src/runtime/routes/inbound-message-handler.ts +1 -1
  283. package/src/runtime/routes/inference-profile-session-handler.ts +11 -0
  284. package/src/runtime/routes/inference-profile-session-reaper.ts +6 -0
  285. package/src/runtime/routes/memory-v3-routes.ts +16 -6
  286. package/src/runtime/routes/playground/helpers.ts +1 -1
  287. package/src/runtime/routes/plugins-routes.ts +337 -35
  288. package/src/runtime/routes/surface-conversation-resolver.ts +4 -3
  289. package/src/runtime/routes/work-items-routes.ts +2 -4
  290. package/src/runtime/services/conversation-serializer.ts +1 -1
  291. package/src/signals/cancel.ts +2 -4
  292. package/src/subagent/manager.ts +21 -5
  293. package/src/tools/acp/context.ts +20 -0
  294. package/src/tools/acp/list-agents.test.ts +8 -2
  295. package/src/tools/acp/spawn.test.ts +176 -195
  296. package/src/tools/acp/spawn.ts +37 -172
  297. package/src/tools/acp/steer.test.ts +105 -8
  298. package/src/tools/acp/steer.ts +48 -17
  299. package/src/tools/apps/executors.ts +166 -50
  300. package/src/tools/filesystem/write.ts +34 -0
  301. package/src/tools/subagent/spawn.ts +2 -4
  302. package/src/tools/ui-surface/definitions.ts +25 -5
  303. package/src/workspace/migrations/051-seed-conversation-summarization-callsite.ts +4 -5
  304. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +69 -45
  305. package/docs/plugins.md +0 -832
  306. package/examples/plugins/echo/register.ts +0 -143
  307. package/src/__tests__/circuit-breaker-pipeline.test.ts +0 -405
  308. package/src/__tests__/compaction-pipeline.test.ts +0 -210
  309. package/src/__tests__/compaction-timeout-recovery.test.ts +0 -251
  310. package/src/__tests__/overflow-reduce-pipeline.test.ts +0 -667
  311. package/src/__tests__/pipeline-runner.test.ts +0 -554
  312. package/src/__tests__/plugin-external-api.test.ts +0 -68
  313. package/src/daemon/wake-target-adapter.ts +0 -253
  314. package/src/plugins/defaults/circuit-breaker/middlewares/circuitBreaker.ts +0 -93
  315. package/src/plugins/defaults/circuit-breaker/package.json +0 -15
  316. package/src/plugins/defaults/circuit-breaker/register.ts +0 -39
  317. package/src/plugins/defaults/compaction/middlewares/compaction.ts +0 -25
  318. package/src/plugins/defaults/compaction/register.ts +0 -35
  319. package/src/plugins/defaults/compaction/terminal.ts +0 -73
  320. package/src/plugins/defaults/empty-response/register.ts +0 -23
  321. package/src/plugins/defaults/history-repair/register.ts +0 -24
  322. package/src/plugins/defaults/overflow-reduce/middlewares/overflowReduce.ts +0 -126
  323. package/src/plugins/defaults/overflow-reduce/package.json +0 -15
  324. package/src/plugins/defaults/overflow-reduce/register.ts +0 -42
  325. package/src/plugins/defaults/title-generate/register.ts +0 -35
  326. package/src/plugins/defaults/tool-error/register.ts +0 -23
  327. package/src/plugins/defaults/tool-result-truncate/register.ts +0 -24
  328. package/src/plugins/external-api.ts +0 -104
  329. package/src/proactive-artifact/aux-message-injector.ts +0 -97
  330. package/src/proactive-artifact/decision.test.ts +0 -226
  331. package/src/proactive-artifact/decision.ts +0 -165
  332. package/src/proactive-artifact/index.ts +0 -7
  333. package/src/proactive-artifact/job.test.ts +0 -962
  334. package/src/proactive-artifact/job.ts +0 -372
  335. package/src/proactive-artifact/message-copy.ts +0 -58
  336. package/src/proactive-artifact/trigger-state.test.ts +0 -286
  337. package/src/proactive-artifact/trigger-state.ts +0 -123
  338. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/capabilities.test.ts +0 -0
  339. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/core.test.ts +0 -0
  340. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/fixtures/eval-turns.json +0 -0
  341. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/fixtures/live-turns.json +0 -0
  342. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/health.test.ts +0 -0
  343. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/needle.test.ts +0 -0
  344. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/provider-blocks.test.ts +0 -0
  345. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/snapshot.test.ts +0 -0
  346. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/tree.test.ts +0 -0
  347. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/working-set-eviction.test.ts +0 -0
  348. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/working-set-skeleton.test.ts +0 -0
  349. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/core.ts +0 -0
  350. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/README.md +0 -0
  351. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/assignments.json +0 -0
  352. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/core.json +0 -0
  353. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-a/topic-x.md +0 -0
  354. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-a/topic-y.md +0 -0
  355. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-b/topic-z.md +0 -0
  356. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/health.ts +0 -0
  357. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/llm-retry.ts +0 -0
  358. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/needle.ts +0 -0
  359. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/snapshot.ts +0 -0
  360. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/working-set.ts +0 -0
@@ -1,9 +1,14 @@
1
1
  /**
2
2
  * Tests for {@link installPlugin}.
3
3
  *
4
- * Network is replaced with an in-memory fixture passed via the `fetch`
5
- * dependency no globals are monkey-patched and no `--test-hook` exports
6
- * leak into production code.
4
+ * Two materialization paths are exercised without touching the network or
5
+ * spawning real subprocesses:
6
+ * - First-party plugins are fetched from the GitHub Contents API, replaced
7
+ * by an in-memory fixture passed via the `fetch` dependency.
8
+ * - External (whitelisted) plugins are shallow-cloned with `git`, replaced
9
+ * by a fake {@link GitRunner} that materializes a tree into the clone dir.
10
+ * No globals are monkey-patched and no `--test-hook` exports leak into
11
+ * production code.
7
12
  */
8
13
 
9
14
  import {
@@ -16,92 +21,84 @@ import {
16
21
  writeFileSync,
17
22
  } from "node:fs";
18
23
  import { tmpdir } from "node:os";
19
- import { join } from "node:path";
24
+ import { dirname, join } from "node:path";
20
25
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
21
26
 
22
27
  import {
23
28
  type FetchLike,
29
+ type GitRunner,
24
30
  installPlugin,
25
31
  InvalidPluginNameError,
26
32
  PluginAlreadyInstalledError,
27
33
  PluginNotFoundError,
34
+ PluginSourceUnavailableError,
28
35
  sanitizePluginName,
29
36
  } from "../install-from-github.js";
30
37
 
38
+ const CANON_REPO = "vellum-ai/vellum-assistant";
39
+ /** Synthetic host the fixtures use for Contents API `download_url`s. */
40
+ const DOWNLOAD_HOST = "https://files.test/";
41
+
31
42
  /**
32
- * Build a GitHub Contents API fixture from an in-memory file tree.
43
+ * Build a `fetch` that serves the GitHub Contents API from an in-memory tree.
33
44
  *
34
- * `tree` maps a path under the canonical prefix (e.g. `simple-memory`,
35
- * `simple-memory/hooks/init.ts`) to either:
36
- * - a `Uint8Array`/`string` a file with that content
37
- * - `null` → a directory
45
+ * `tree` is keyed by full canonical-repo path (e.g.
46
+ * `experimental/plugins/simple-memory/package.json`) and maps each entry to a
47
+ * file's content (`string`/`Uint8Array`) or `null` for an explicit directory.
48
+ * Directory listings are derived from the key set; file `download_url`s point
49
+ * at {@link DOWNLOAD_HOST} and are served with the stored bytes.
38
50
  *
39
- * The fixture answers GET requests against
40
- * - `https://api.github.com/repos/vellum-ai/vellum-assistant/contents/...`
41
- * - any other URL we hand out as `download_url`
51
+ * The marketplace manifest lookup is answered with `manifest` (or a 404 when
52
+ * omitted, so resolution degrades to the first-party source).
42
53
  */
43
- function fixtureFetch(
44
- tree: Record<string, Uint8Array | string | null>,
45
- ): FetchLike {
46
- const PREFIX_API =
47
- "https://api.github.com/repos/vellum-ai/vellum-assistant/contents/experimental/plugins/";
48
- const PREFIX_RAW =
49
- "https://raw.githubusercontent.com/vellum-ai/vellum-assistant/main/experimental/plugins/";
50
-
51
- function listing(apiPath: string): unknown {
52
- const rel = apiPath.startsWith("experimental/plugins/")
53
- ? apiPath.slice("experimental/plugins/".length)
54
- : apiPath;
55
- const prefix = rel ? rel + "/" : "";
56
- const direct = new Map<string, "file" | "dir">();
54
+ function makeContentsFetch(opts: {
55
+ tree: Record<string, Uint8Array | string | null>;
56
+ manifest?: unknown;
57
+ }): FetchLike {
58
+ const MANIFEST_URL = `https://api.github.com/repos/${CANON_REPO}/contents/experimental/plugins/marketplace.json`;
59
+ const CONTENTS = `https://api.github.com/repos/${CANON_REPO}/contents/`;
60
+ const { tree } = opts;
61
+
62
+ function listing(apiPath: string):
63
+ | {
64
+ name: string;
65
+ path: string;
66
+ type: string;
67
+ download_url: string | null;
68
+ }[]
69
+ | null {
70
+ const prefix = apiPath ? `${apiPath}/` : "";
71
+ const direct = new Map<string, boolean>();
57
72
  for (const key of Object.keys(tree)) {
58
73
  if (!key.startsWith(prefix)) continue;
59
74
  const remainder = key.slice(prefix.length);
60
75
  if (!remainder) continue;
61
- const [head, ...rest] = remainder.split("/");
62
- if (rest.length === 0) {
63
- const isDir = tree[key] === null;
64
- if (!direct.has(head!)) direct.set(head!, isDir ? "dir" : "file");
65
- } else {
66
- if (!direct.has(head!)) direct.set(head!, "dir");
67
- }
76
+ const seg = remainder.split("/")[0]!;
77
+ const isDir = remainder.includes("/") || tree[`${prefix}${seg}`] === null;
78
+ direct.set(seg, (direct.get(seg) ?? false) || isDir);
68
79
  }
69
80
  if (direct.size === 0) return null;
70
- return Array.from(direct.entries()).map(([name, type]) => ({
71
- name,
72
- // GitHub returns `path` rooted at the repo, not relative to the queried
73
- // directory mirror that so the recursive copy hits the same fixture
74
- // handler on the way down.
75
- path: `experimental/plugins/${prefix}${name}`,
76
- type,
77
- size: type === "file" ? (tree[`${prefix}${name}`] as string).length : 0,
78
- download_url:
79
- type === "file" ? `${PREFIX_RAW}${prefix}${name}` : null,
80
- }));
81
+ return Array.from(direct.entries()).map(([name, isDir]) => {
82
+ const path = `${prefix}${name}`;
83
+ return isDir
84
+ ? { name, path, type: "dir", download_url: null }
85
+ : { name, path, type: "file", download_url: `${DOWNLOAD_HOST}${path}` };
86
+ });
81
87
  }
82
88
 
83
- return (async (input: RequestInfo | URL, _init?: RequestInit) => {
89
+ return (async (input: RequestInfo | URL) => {
84
90
  const url = typeof input === "string" ? input : input.toString();
85
91
 
86
- if (url.startsWith(PREFIX_API)) {
87
- const after = url.slice(PREFIX_API.length).split("?")[0]!;
88
- const apiPath = `experimental/plugins/${decodeURIComponent(after)}`;
89
- const body = listing(apiPath);
90
- if (body === null) {
92
+ if (url.startsWith(MANIFEST_URL)) {
93
+ if (opts.manifest === undefined) {
91
94
  return new Response("not found", { status: 404 });
92
95
  }
93
- return new Response(JSON.stringify(body), {
94
- status: 200,
95
- headers: { "content-type": "application/json" },
96
- });
96
+ return new Response(JSON.stringify(opts.manifest), { status: 200 });
97
97
  }
98
98
 
99
- if (url.startsWith(PREFIX_RAW)) {
100
- const key =
101
- "experimental/plugins/" +
102
- decodeURIComponent(url.slice(PREFIX_RAW.length));
103
- const rel = key.slice("experimental/plugins/".length);
104
- const file = tree[rel];
99
+ if (url.startsWith(DOWNLOAD_HOST)) {
100
+ const path = decodeURIComponent(url.slice(DOWNLOAD_HOST.length));
101
+ const file = tree[path];
105
102
  if (file === null || file === undefined) {
106
103
  return new Response("not found", { status: 404 });
107
104
  }
@@ -110,11 +107,82 @@ function fixtureFetch(
110
107
  return new Response(Buffer.from(bytes), { status: 200 });
111
108
  }
112
109
 
113
- return new Response("unexpected url: " + url, { status: 500 });
110
+ if (url.startsWith(CONTENTS)) {
111
+ const after = decodeURIComponent(
112
+ url.slice(CONTENTS.length).split("?")[0]!,
113
+ );
114
+ const body = listing(after);
115
+ if (body === null) {
116
+ return new Response("not found", { status: 404 });
117
+ }
118
+ return new Response(JSON.stringify(body), { status: 200 });
119
+ }
120
+
121
+ return new Response(`unexpected url: ${url}`, { status: 500 });
114
122
  }) as FetchLike;
115
123
  }
116
124
 
117
- describe("installPlugin", () => {
125
+ /** First-party fixture: keys are relative to `experimental/plugins/`. */
126
+ function fixtureFetch(
127
+ tree: Record<string, Uint8Array | string | null>,
128
+ ): FetchLike {
129
+ const full: Record<string, Uint8Array | string | null> = {};
130
+ for (const [key, value] of Object.entries(tree)) {
131
+ full[`experimental/plugins/${key}`] = value;
132
+ }
133
+ return makeContentsFetch({ tree: full });
134
+ }
135
+
136
+ /**
137
+ * Build a fake {@link GitRunner} that simulates a shallow clone.
138
+ *
139
+ * On `fetch`, the configured `tree` (repo-relative path → content, `null` =
140
+ * directory) plus a token `.git/config` are written into the clone `cwd`, so
141
+ * the subsequent copy can be asserted to include the tree and exclude `.git`.
142
+ * `rev-parse HEAD` returns `commit`. Optional hooks let a test record the git
143
+ * arguments or inject a fetch failure.
144
+ */
145
+ function fakeGitRunner(opts: {
146
+ tree: Record<string, string | null>;
147
+ commit?: string;
148
+ calls?: string[][];
149
+ fetchError?: Error;
150
+ }): GitRunner {
151
+ const commit = opts.commit ?? "abc123def4567890abc123def4567890abcdef12";
152
+ return async (args, { cwd }) => {
153
+ opts.calls?.push([...args]);
154
+ switch (args[0]) {
155
+ case "fetch": {
156
+ if (opts.fetchError) throw opts.fetchError;
157
+ mkdirSync(join(cwd, ".git"), { recursive: true });
158
+ writeFileSync(join(cwd, ".git", "config"), "[core]\n");
159
+ for (const [rel, content] of Object.entries(opts.tree)) {
160
+ if (content === null) {
161
+ mkdirSync(join(cwd, rel), { recursive: true });
162
+ continue;
163
+ }
164
+ const dest = join(cwd, rel);
165
+ mkdirSync(dirname(dest), { recursive: true });
166
+ writeFileSync(dest, content);
167
+ }
168
+ return { stdout: "" };
169
+ }
170
+ case "rev-parse":
171
+ return { stdout: `${commit}\n` };
172
+ default:
173
+ return { stdout: "" };
174
+ }
175
+ };
176
+ }
177
+
178
+ /** A git runner that fails the test if any git command is invoked. */
179
+ const unusedGitRunner: GitRunner = async (args) => {
180
+ throw new Error(
181
+ `git should not run for a first-party install: ${args.join(" ")}`,
182
+ );
183
+ };
184
+
185
+ describe("installPlugin — first-party", () => {
118
186
  let ws: string;
119
187
  let pluginsDir: string;
120
188
 
@@ -128,7 +196,7 @@ describe("installPlugin", () => {
128
196
  rmSync(ws, { recursive: true, force: true });
129
197
  });
130
198
 
131
- test("copies the GitHub tree into <workspacePluginsDir>/<name>", async () => {
199
+ test("copies a first-party plugin into <workspacePluginsDir>/<name>", async () => {
132
200
  const result = await installPlugin(
133
201
  { name: "simple-memory", force: false, ref: "main" },
134
202
  {
@@ -149,6 +217,8 @@ describe("installPlugin", () => {
149
217
  expect(result.target).toBe(target);
150
218
  expect(result.fileCount).toBe(4);
151
219
  expect(result.ref).toBe("main");
220
+ // First-party installs have no clone, so no commit is resolved.
221
+ expect(result.commit).toBeNull();
152
222
  expect(existsSync(join(target, "package.json"))).toBe(true);
153
223
  expect(existsSync(join(target, "README.md"))).toBe(true);
154
224
  expect(existsSync(join(target, "hooks", "init.ts"))).toBe(true);
@@ -158,6 +228,33 @@ describe("installPlugin", () => {
158
228
  );
159
229
  });
160
230
 
231
+ test("writes a provenance manifest recording the source and ref", async () => {
232
+ // GIVEN a first-party install
233
+ const target = join(pluginsDir, "simple-memory");
234
+
235
+ // WHEN it completes
236
+ await installPlugin(
237
+ { name: "simple-memory", force: false, ref: "main" },
238
+ {
239
+ fetch: fixtureFetch({
240
+ "simple-memory/package.json": '{"name":"simple-memory"}',
241
+ }),
242
+ workspacePluginsDir: pluginsDir,
243
+ },
244
+ );
245
+
246
+ // THEN a hidden manifest records the resolved coordinates (no commit for
247
+ // first-party) and is not counted as a plugin file
248
+ const manifest = JSON.parse(
249
+ readFileSync(join(target, ".vellum-plugin.json"), "utf-8"),
250
+ );
251
+ expect(manifest.name).toBe("simple-memory");
252
+ expect(manifest.source.kind).toBe("first-party");
253
+ expect(manifest.source.repo).toBe("vellum-assistant");
254
+ expect(manifest.source.ref).toBe("main");
255
+ expect(manifest.commit).toBeUndefined();
256
+ });
257
+
161
258
  test("refuses to overwrite an existing install without --force", async () => {
162
259
  const target = join(pluginsDir, "simple-memory");
163
260
  mkdirSync(target, { recursive: true });
@@ -168,7 +265,6 @@ describe("installPlugin", () => {
168
265
  { name: "simple-memory", force: false, ref: "main" },
169
266
  {
170
267
  fetch: fixtureFetch({
171
- "simple-memory": null,
172
268
  "simple-memory/package.json": "{}",
173
269
  }),
174
270
  workspacePluginsDir: pluginsDir,
@@ -189,7 +285,6 @@ describe("installPlugin", () => {
189
285
  { name: "simple-memory", force: true, ref: "main" },
190
286
  {
191
287
  fetch: fixtureFetch({
192
- "simple-memory": null,
193
288
  "simple-memory/package.json": '{"name":"simple-memory"}',
194
289
  }),
195
290
  workspacePluginsDir: pluginsDir,
@@ -201,9 +296,9 @@ describe("installPlugin", () => {
201
296
  });
202
297
 
203
298
  test("--force preserves the existing install when the fetch fails", async () => {
204
- // Codex P1 from PR-5 review: a transient 5xx during a forced re-install
205
- // must NOT delete the previously working plugin. The fetch error
206
- // surfaces, but the existing tree on disk is untouched.
299
+ // A transient 5xx during a forced re-install must NOT delete the
300
+ // previously working plugin. The fetch error surfaces, but the existing
301
+ // tree on disk is untouched (all writes go through the staging dir).
207
302
  const target = join(pluginsDir, "simple-memory");
208
303
  mkdirSync(target, { recursive: true });
209
304
  writeFileSync(join(target, "marker"), "pre-existing");
@@ -219,12 +314,9 @@ describe("installPlugin", () => {
219
314
  ),
220
315
  ).rejects.toThrow(/HTTP 503/);
221
316
 
222
- // Marker is still there because the failed install never touched the
223
- // target — the staging dir handles all writes until the swap.
224
317
  expect(readFileSync(join(target, "marker"), "utf-8")).toBe("pre-existing");
225
318
  // And no staging dir leaks into the plugins directory.
226
- const remaining = readdirSync(pluginsDir);
227
- expect(remaining).toEqual(["simple-memory"]);
319
+ expect(readdirSync(pluginsDir)).toEqual(["simple-memory"]);
228
320
  });
229
321
 
230
322
  test("404 on the canonical path is reported as not-found", async () => {
@@ -259,63 +351,109 @@ describe("installPlugin", () => {
259
351
  expect(readdirSync(pluginsDir)).toEqual([]);
260
352
  });
261
353
 
262
- test("respects ref by forwarding to GitHub", async () => {
263
- let seenRef: string | undefined;
354
+ test("a rate-limited contents listing surfaces a retryable PluginSourceUnavailableError", async () => {
355
+ // GIVEN GitHub's unauthenticated rate limit is exhausted: the contents
356
+ // listing 403s with the remaining-quota header at zero
357
+ const rateLimited: FetchLike = (async (input: RequestInfo | URL) => {
358
+ const url = typeof input === "string" ? input : input.toString();
359
+ if (url.includes("marketplace.json")) {
360
+ return new Response("not found", { status: 404 });
361
+ }
362
+ if (url.includes("/contents/")) {
363
+ return new Response("rate limited", {
364
+ status: 403,
365
+ headers: { "x-ratelimit-remaining": "0" },
366
+ });
367
+ }
368
+ return new Response("not found", { status: 404 });
369
+ }) as FetchLike;
370
+
371
+ // WHEN we install
372
+ // THEN the failure is classified as transient (retryable), not a hard
373
+ // error, so the route can surface a 503 instead of a 500
374
+ await expect(
375
+ installPlugin(
376
+ { name: "demo", force: false, ref: "main" },
377
+ { fetch: rateLimited, workspacePluginsDir: pluginsDir },
378
+ ),
379
+ ).rejects.toBeInstanceOf(PluginSourceUnavailableError);
380
+
381
+ // AND no staging dir leaks behind on the transient failure.
382
+ expect(readdirSync(pluginsDir)).toEqual([]);
383
+ });
384
+
385
+ test("a forbidden contents listing with quota remaining stays a hard error", async () => {
386
+ // GIVEN a 403 that is NOT a rate-limit (quota header present and nonzero):
387
+ // a genuine authorization failure, not a transient one
388
+ const forbidden: FetchLike = (async (input: RequestInfo | URL) => {
389
+ const url = typeof input === "string" ? input : input.toString();
390
+ if (url.includes("marketplace.json")) {
391
+ return new Response("not found", { status: 404 });
392
+ }
393
+ if (url.includes("/contents/")) {
394
+ return new Response("forbidden", {
395
+ status: 403,
396
+ headers: { "x-ratelimit-remaining": "57" },
397
+ });
398
+ }
399
+ return new Response("not found", { status: 404 });
400
+ }) as FetchLike;
401
+
402
+ // WHEN we install
403
+ // THEN it surfaces as a hard error — NOT the retryable variant — so the
404
+ // route maps it to 500 rather than inviting an endless retry loop
405
+ await expect(
406
+ installPlugin(
407
+ { name: "demo", force: false, ref: "main" },
408
+ { fetch: forbidden, workspacePluginsDir: pluginsDir },
409
+ ),
410
+ ).rejects.not.toBeInstanceOf(PluginSourceUnavailableError);
411
+ expect(readdirSync(pluginsDir)).toEqual([]);
412
+ });
413
+
414
+ test("respects ref by forwarding it to the contents listing", async () => {
415
+ // The requested ref must reach the first-party contents listing request.
416
+ let listRef: string | undefined;
417
+ const base = fixtureFetch({ "demo/package.json": "{}" });
418
+ const fetch: FetchLike = async (input, init) => {
419
+ const url = typeof input === "string" ? input : input.toString();
420
+ const match = /\/contents\/experimental\/plugins\/demo\?ref=([^&]+)/.exec(
421
+ url,
422
+ );
423
+ if (match) listRef = decodeURIComponent(match[1]!);
424
+ return base(url, init);
425
+ };
426
+
264
427
  await installPlugin(
265
428
  { name: "demo", force: false, ref: "feat-branch" },
266
- {
267
- fetch: (async (input: RequestInfo | URL) => {
268
- const url = typeof input === "string" ? input : input.toString();
269
- if (url.includes("api.github.com")) {
270
- const m = /[?&]ref=([^&]+)/.exec(url);
271
- seenRef = m ? decodeURIComponent(m[1]!) : undefined;
272
- return new Response(
273
- JSON.stringify([
274
- {
275
- name: "package.json",
276
- path: "experimental/plugins/demo/package.json",
277
- type: "file",
278
- size: 2,
279
- download_url:
280
- "https://raw.githubusercontent.com/vellum-ai/vellum-assistant/feat-branch/experimental/plugins/demo/package.json",
281
- },
282
- ]),
283
- {
284
- status: 200,
285
- headers: { "content-type": "application/json" },
286
- },
287
- );
288
- }
289
- return new Response("{}", { status: 200 });
290
- }) as FetchLike,
291
- workspacePluginsDir: pluginsDir,
292
- },
429
+ { fetch, workspacePluginsDir: pluginsDir },
293
430
  );
294
431
 
295
- expect(seenRef).toBe("feat-branch");
432
+ expect(listRef).toBe("feat-branch");
296
433
  expect(existsSync(join(pluginsDir, "demo", "package.json"))).toBe(true);
297
434
  });
298
435
 
299
436
  test("rejects untrusted entry names from the GitHub response", async () => {
300
- // Devin P2 from PR-5 review: even though GitHub returns trustworthy data,
301
- // defense-in-depth requires us to validate `entry.name` before any
302
- // filesystem write. A malicious or buggy upstream that hands us
303
- // `../escape` must not be able to write outside the target.
437
+ // Even though GitHub returns trustworthy data, defense-in-depth requires
438
+ // us to validate every entry name before any filesystem write. A malicious
439
+ // or buggy upstream that hands us `../escape` must not write outside the
440
+ // target.
304
441
  const badFetch: FetchLike = (async (input: RequestInfo | URL) => {
305
442
  const url = typeof input === "string" ? input : input.toString();
306
- if (url.includes("api.github.com")) {
443
+ if (url.includes("marketplace.json")) {
444
+ return new Response("not found", { status: 404 });
445
+ }
446
+ if (url.includes("/contents/")) {
307
447
  return new Response(
308
448
  JSON.stringify([
309
449
  {
310
450
  name: "../escape",
311
451
  path: "experimental/plugins/demo/../escape",
312
452
  type: "file",
313
- size: 1,
314
- download_url:
315
- "https://raw.githubusercontent.com/vellum-ai/vellum-assistant/main/experimental/plugins/demo/escape",
453
+ download_url: `${DOWNLOAD_HOST}escape`,
316
454
  },
317
455
  ]),
318
- { status: 200, headers: { "content-type": "application/json" } },
456
+ { status: 200 },
319
457
  );
320
458
  }
321
459
  return new Response("x", { status: 200 });
@@ -335,6 +473,186 @@ describe("installPlugin", () => {
335
473
  });
336
474
  });
337
475
 
476
+ describe("installPlugin — marketplace resolution", () => {
477
+ let ws: string;
478
+ let pluginsDir: string;
479
+
480
+ beforeEach(() => {
481
+ ws = mkdtempSync(join(tmpdir(), "vellum-plugins-mkt-"));
482
+ pluginsDir = join(ws, "plugins");
483
+ mkdirSync(pluginsDir, { recursive: true });
484
+ });
485
+
486
+ afterEach(() => {
487
+ rmSync(ws, { recursive: true, force: true });
488
+ });
489
+
490
+ const CAVEMAN_MANIFEST = {
491
+ name: "vellum-assistant",
492
+ plugins: [
493
+ {
494
+ name: "caveman",
495
+ source: {
496
+ source: "github",
497
+ repo: "JuliusBrussee/caveman",
498
+ ref: "v1.8.2",
499
+ },
500
+ description: "Ultra-compressed communication mode.",
501
+ },
502
+ ],
503
+ };
504
+
505
+ test("installs a whitelisted plugin by shallow-cloning its pinned repo + ref", async () => {
506
+ // GIVEN a marketplace whitelisting caveman at its repo root, pinned to a tag
507
+ const fetch = makeContentsFetch({ tree: {}, manifest: CAVEMAN_MANIFEST });
508
+ const calls: string[][] = [];
509
+ const runGit = fakeGitRunner({
510
+ tree: {
511
+ "package.json": '{"name":"caveman"}',
512
+ "README.md": "# caveman",
513
+ ".claude-plugin": null,
514
+ ".claude-plugin/plugin.json": "{}",
515
+ },
516
+ commit: "1111111222222233333334444444555555666666",
517
+ calls,
518
+ });
519
+
520
+ // WHEN we install by name (the install ref is ignored in favor of the
521
+ // manifest's pinned ref)
522
+ const result = await installPlugin(
523
+ { name: "caveman", ref: "main" },
524
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
525
+ );
526
+
527
+ // THEN the external tree is materialized under <pluginsDir>/caveman, the
528
+ // result reports the pinned ref and resolved commit, and `.git` is dropped
529
+ const target = join(pluginsDir, "caveman");
530
+ expect(result.target).toBe(target);
531
+ expect(result.ref).toBe("v1.8.2");
532
+ expect(result.commit).toBe("1111111222222233333334444444555555666666");
533
+ expect(result.fileCount).toBe(3);
534
+ expect(readFileSync(join(target, "package.json"), "utf-8")).toBe(
535
+ '{"name":"caveman"}',
536
+ );
537
+ expect(existsSync(join(target, ".claude-plugin", "plugin.json"))).toBe(
538
+ true,
539
+ );
540
+ expect(existsSync(join(target, ".git"))).toBe(false);
541
+
542
+ // AND the clone fetched the pinned ref from the pinned repo URL.
543
+ const fetchCall = calls.find((c) => c[0] === "fetch");
544
+ expect(fetchCall).toContain("v1.8.2");
545
+ const remoteCall = calls.find((c) => c[0] === "remote");
546
+ expect(remoteCall?.at(-1)).toBe(
547
+ "https://github.com/JuliusBrussee/caveman.git",
548
+ );
549
+
550
+ // AND a provenance manifest records the external source + commit.
551
+ const manifest = JSON.parse(
552
+ readFileSync(join(target, ".vellum-plugin.json"), "utf-8"),
553
+ );
554
+ expect(manifest.source.kind).toBe("external");
555
+ expect(manifest.source.owner).toBe("JuliusBrussee");
556
+ expect(manifest.source.ref).toBe("v1.8.2");
557
+ expect(manifest.commit).toBe("1111111222222233333334444444555555666666");
558
+ });
559
+
560
+ test("a missing remote ref surfaces a clean not-found", async () => {
561
+ // GIVEN a clone whose fetch fails because the ref doesn't exist
562
+ const fetch = makeContentsFetch({ tree: {}, manifest: CAVEMAN_MANIFEST });
563
+ const runGit = fakeGitRunner({
564
+ tree: {},
565
+ fetchError: Object.assign(new Error("git fetch failed"), {
566
+ stderr: "fatal: couldn't find remote ref v1.8.2",
567
+ }),
568
+ });
569
+
570
+ // WHEN we install
571
+ // THEN it is a hard not-found, not a retryable error
572
+ await expect(
573
+ installPlugin(
574
+ { name: "caveman", ref: "main" },
575
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
576
+ ),
577
+ ).rejects.toBeInstanceOf(PluginNotFoundError);
578
+ expect(readdirSync(pluginsDir)).toEqual([]);
579
+ });
580
+
581
+ test("a network failure during clone surfaces a retryable error", async () => {
582
+ // GIVEN a clone whose fetch fails with a transient network error
583
+ const fetch = makeContentsFetch({ tree: {}, manifest: CAVEMAN_MANIFEST });
584
+ const runGit = fakeGitRunner({
585
+ tree: {},
586
+ fetchError: Object.assign(new Error("git fetch failed"), {
587
+ stderr: "fatal: unable to access: Could not resolve host: github.com",
588
+ }),
589
+ });
590
+
591
+ // WHEN we install
592
+ // THEN it surfaces as the retryable variant so the route maps it to 503
593
+ await expect(
594
+ installPlugin(
595
+ { name: "caveman", ref: "main" },
596
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
597
+ ),
598
+ ).rejects.toBeInstanceOf(PluginSourceUnavailableError);
599
+ expect(readdirSync(pluginsDir)).toEqual([]);
600
+ });
601
+
602
+ test("a name absent from the manifest falls back to the first-party source", async () => {
603
+ // GIVEN a manifest that does NOT whitelist "simple-memory", and no such
604
+ // first-party tree in the canonical repo
605
+ const fetch = makeContentsFetch({ tree: {}, manifest: CAVEMAN_MANIFEST });
606
+
607
+ // WHEN we install a first-party name
608
+ // THEN resolution falls back to the first-party path and surfaces a clean
609
+ // not-found pointing at the first-party source (git is never invoked)
610
+ await expect(
611
+ installPlugin(
612
+ { name: "simple-memory", ref: "main" },
613
+ { fetch, runGit: unusedGitRunner, workspacePluginsDir: pluginsDir },
614
+ ),
615
+ ).rejects.toMatchObject({
616
+ constructor: PluginNotFoundError,
617
+ message: expect.stringContaining("vellum-ai/vellum-assistant"),
618
+ });
619
+ });
620
+
621
+ test("first-party plugin wins a name collision with the marketplace", async () => {
622
+ // GIVEN a manifest whitelisting "caveman" externally
623
+ // AND an in-repo first-party plugin that also claims the name "caveman"
624
+ const fetch = makeContentsFetch({
625
+ tree: {
626
+ "experimental/plugins/caveman/package.json":
627
+ '{"name":"@vellumai/caveman"}',
628
+ "experimental/plugins/caveman/hooks": null,
629
+ "experimental/plugins/caveman/hooks/init.ts": "// first-party",
630
+ },
631
+ manifest: CAVEMAN_MANIFEST,
632
+ });
633
+
634
+ // WHEN we install by the colliding name (git must NOT run — the in-repo
635
+ // plugin is served entirely from the Contents API)
636
+ const result = await installPlugin(
637
+ { name: "caveman", ref: "main" },
638
+ { fetch, runGit: unusedGitRunner, workspacePluginsDir: pluginsDir },
639
+ );
640
+
641
+ // THEN the in-repo plugin is installed (matching what the search catalog
642
+ // advertises for the name), not the external repo — the marketplace is
643
+ // additive and never overrides a first-party plugin
644
+ const target = join(pluginsDir, "caveman");
645
+ expect(result.ref).toBe("main");
646
+ expect(result.commit).toBeNull();
647
+ expect(readFileSync(join(target, "package.json"), "utf-8")).toBe(
648
+ '{"name":"@vellumai/caveman"}',
649
+ );
650
+ expect(readFileSync(join(target, "hooks", "init.ts"), "utf-8")).toBe(
651
+ "// first-party",
652
+ );
653
+ });
654
+ });
655
+
338
656
  describe("sanitizePluginName", () => {
339
657
  test.each([
340
658
  ["../escape"],