@vellumai/assistant 0.11.1 → 0.11.2-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 (318) hide show
  1. package/Dockerfile +1 -3
  2. package/README.md +1 -1
  3. package/eslint.config.mjs +28 -8
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  8. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  10. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  11. package/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  12. package/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  13. package/node_modules/@vellumai/slack-text/src/index.test.ts +48 -0
  14. package/node_modules/@vellumai/slack-text/src/index.ts +25 -6
  15. package/openapi.yaml +292 -5
  16. package/package.json +1 -1
  17. package/src/__tests__/agent-loop-resume-interrupted.test.ts +223 -0
  18. package/src/__tests__/byok-default-profile-ensure.test.ts +43 -0
  19. package/src/__tests__/cli-logger-boundary-guard.test.ts +87 -0
  20. package/src/__tests__/compaction-events.test.ts +139 -2
  21. package/src/__tests__/config-loader-backfill.test.ts +35 -3
  22. package/src/__tests__/config-schema.test.ts +1 -0
  23. package/src/__tests__/conversation-agent-loop-fatal-cleanup.test.ts +30 -0
  24. package/src/__tests__/conversation-agent-loop.test.ts +180 -7
  25. package/src/__tests__/conversation-error.test.ts +211 -4
  26. package/src/__tests__/conversation-options-turn-scoped-transport.test.ts +64 -0
  27. package/src/__tests__/conversation-queue.test.ts +126 -0
  28. package/src/__tests__/conversation-retry-route.test.ts +46 -7
  29. package/src/__tests__/conversation-slash.test.ts +4 -2
  30. package/src/__tests__/conversation-summarize-route.test.ts +7 -1
  31. package/src/__tests__/conversation-summarize-up-to.test.ts +71 -5
  32. package/src/__tests__/document-create-dedupe.test.ts +2 -1
  33. package/src/__tests__/document-find-replace.test.ts +2 -1
  34. package/src/__tests__/document-tool-security.test.ts +2 -1
  35. package/src/__tests__/document-update-default-surface.test.ts +2 -1
  36. package/src/__tests__/document-workspace-file.test.ts +467 -0
  37. package/src/__tests__/emit-signal-routing-intent.test.ts +290 -49
  38. package/src/__tests__/guardian-card-withdrawal.test.ts +91 -3
  39. package/src/__tests__/http-user-message-parity.test.ts +66 -0
  40. package/src/__tests__/list-messages-attachments.test.ts +87 -0
  41. package/src/__tests__/list-messages-provider-error.test.ts +143 -0
  42. package/src/__tests__/list-messages-system-card.test.ts +100 -0
  43. package/src/__tests__/llm-resolver.test.ts +19 -9
  44. package/src/__tests__/managed-profile-guard.test.ts +23 -0
  45. package/src/__tests__/notification-platform-adapter.test.ts +130 -2
  46. package/src/__tests__/notification-telegram-adapter.test.ts +6 -0
  47. package/src/__tests__/notification-vellum-adapter.test.ts +45 -0
  48. package/src/__tests__/plugin-api-model-profiles.test.ts +10 -1
  49. package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
  50. package/src/__tests__/provider-error-scenarios.test.ts +140 -0
  51. package/src/__tests__/provider-send-message-override-profile.test.ts +95 -0
  52. package/src/__tests__/run-conversation-turn-persistence.test.ts +66 -3
  53. package/src/__tests__/scripted-turn-metadata-persistence.test.ts +209 -0
  54. package/src/__tests__/skills.test.ts +27 -0
  55. package/src/__tests__/slack-channels-routes.test.ts +0 -2
  56. package/src/__tests__/slack-share-routes.test.ts +0 -3
  57. package/src/__tests__/slack-users-routes.test.ts +0 -2
  58. package/src/__tests__/subagent-tools.test.ts +11 -0
  59. package/src/__tests__/tool-preview-lifecycle.test.ts +58 -0
  60. package/src/__tests__/tool-result-spool.test.ts +5 -4
  61. package/src/__tests__/turn-boundary-resolution.test.ts +53 -0
  62. package/src/__tests__/turn-events-store.test.ts +26 -0
  63. package/src/__tests__/ui-visual-surface.test.ts +695 -0
  64. package/src/__tests__/unified-turn-context-visible-app.test.ts +99 -0
  65. package/src/__tests__/visible-app-context.test.ts +189 -0
  66. package/src/__tests__/workspace-git-service.test.ts +173 -33
  67. package/src/__tests__/workspace-migration-137-repair-retired-fireworks-minimax-model-id.test.ts +157 -0
  68. package/src/__tests__/workspace-migration-138-backfill-home-feed-titles.test.ts +373 -0
  69. package/src/__tests__/workspace-migration-139-clear-renamed-cost-profile-label.test.ts +137 -0
  70. package/src/agent/loop.ts +36 -7
  71. package/src/api/events/context-window-usage.ts +31 -0
  72. package/src/api/events/notification-intent.ts +8 -0
  73. package/src/api/events/ui-surface-pending.ts +35 -0
  74. package/src/api/index.ts +14 -0
  75. package/src/api/responses/conversation-message.ts +25 -4
  76. package/src/api/surfaces.ts +90 -1
  77. package/src/approvals/guardian-card-withdrawal.ts +66 -31
  78. package/src/approvals/guardian-decision-primitive.ts +4 -0
  79. package/src/calls/__tests__/call-setup-router.test.ts +156 -26
  80. package/src/calls/__tests__/voice-session-bridge.test.ts +201 -12
  81. package/src/calls/call-setup-router.ts +80 -36
  82. package/src/calls/voice-session-bridge.ts +173 -26
  83. package/src/cli/commands/inference.help.ts +3 -3
  84. package/src/cli/commands/notifications.help.ts +3 -2
  85. package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +42 -128
  86. package/src/cli/commands/platform/__tests__/credits.test.ts +9 -78
  87. package/src/cli/commands/platform/__tests__/helpers.ts +90 -0
  88. package/src/cli/commands/platform/__tests__/invoices.test.ts +238 -0
  89. package/src/cli/commands/platform/__tests__/plans.test.ts +9 -88
  90. package/src/cli/commands/platform/__tests__/status.test.ts +12 -87
  91. package/src/cli/commands/platform/__tests__/subscription.test.ts +9 -86
  92. package/src/cli/commands/platform/index.help.ts +92 -0
  93. package/src/cli/commands/platform/index.ts +7 -0
  94. package/src/cli/commands/platform/invoices.ts +132 -0
  95. package/src/cli/commands/usage.help.ts +1 -1
  96. package/src/cli/lib/list-installed-plugins.ts +2 -1
  97. package/src/config/__tests__/default-profile-catalog.test.ts +7 -10
  98. package/src/config/__tests__/deployment-context-defaults.test.ts +27 -5
  99. package/src/config/assistant-feature-flags.ts +7 -2
  100. package/src/config/bundled-skills/app-builder/SKILL.md +3 -2
  101. package/src/config/bundled-skills/subagent/SKILL.md +3 -1
  102. package/src/config/bundled-skills/subagent/TOOLS.json +3 -3
  103. package/src/config/bundled-skills/visualize/SKILL.md +163 -0
  104. package/src/config/call-site-defaults.ts +3 -2
  105. package/src/config/default-profile-catalog.ts +75 -70
  106. package/src/config/default-profile-names.ts +13 -28
  107. package/src/config/env-registry.ts +1 -0
  108. package/src/config/feature-flag-registry.json +16 -0
  109. package/src/config/llm-resolver.ts +21 -5
  110. package/src/config/loader.ts +18 -11
  111. package/src/config/schemas/llm.ts +1 -9
  112. package/src/config/schemas/memory-retrospective.ts +9 -0
  113. package/src/config/schemas/monitoring.ts +28 -2
  114. package/src/config/schemas/workspace-git.ts +15 -0
  115. package/src/config/seed-inference-profiles.ts +19 -5
  116. package/src/context/post-turn-tool-result-truncation.ts +2 -2
  117. package/src/conversations/__tests__/message-consolidation.test.ts +54 -0
  118. package/src/conversations/message-consolidation.ts +17 -15
  119. package/src/daemon/__tests__/turn-tail-assistant-reply-notify.test.ts +182 -0
  120. package/src/daemon/__tests__/turn-tail-deleted-conversation.test.ts +181 -0
  121. package/src/daemon/conversation-agent-loop-handlers.ts +90 -7
  122. package/src/daemon/conversation-agent-loop.ts +107 -22
  123. package/src/daemon/conversation-error.ts +169 -48
  124. package/src/daemon/conversation-messaging.ts +87 -4
  125. package/src/daemon/conversation-process.ts +57 -45
  126. package/src/daemon/conversation-runtime-assembly.ts +57 -0
  127. package/src/daemon/conversation-store.ts +36 -1
  128. package/src/daemon/conversation-surfaces.ts +68 -6
  129. package/src/daemon/conversation-turn-finalize.ts +76 -18
  130. package/src/daemon/conversation.ts +122 -27
  131. package/src/daemon/lifecycle.ts +9 -0
  132. package/src/daemon/message-types/conversations.ts +8 -0
  133. package/src/daemon/message-types/surfaces.ts +3 -0
  134. package/src/documents/document-store.ts +247 -10
  135. package/src/home/__tests__/feed-types.test.ts +43 -8
  136. package/src/home/__tests__/feed-writer.test.ts +59 -0
  137. package/src/home/feed-types.ts +34 -7
  138. package/src/home/feed-writer.ts +15 -6
  139. package/src/live-voice/__tests__/activity-label.test.ts +95 -0
  140. package/src/live-voice/__tests__/live-activity-reporter.test.ts +86 -5
  141. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +310 -25
  142. package/src/live-voice/__tests__/live-voice-events.test.ts +4 -4
  143. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +8 -4
  144. package/src/live-voice/__tests__/live-voice-vad.test.ts +9 -1
  145. package/src/live-voice/activity-label.ts +169 -0
  146. package/src/live-voice/live-activity-reporter.ts +35 -6
  147. package/src/live-voice/live-voice-session.ts +282 -41
  148. package/src/live-voice/protocol.ts +35 -0
  149. package/src/messaging/providers/slack/__tests__/adapter-mention-rendering.test.ts +84 -2
  150. package/src/messaging/providers/slack/adapter.ts +86 -19
  151. package/src/messaging/providers/slack/api.test.ts +85 -1
  152. package/src/messaging/providers/slack/api.ts +121 -288
  153. package/src/messaging/providers/slack/client.ts +20 -251
  154. package/src/messaging/providers/slack/send.test.ts +4 -9
  155. package/src/messaging/providers/slack/send.ts +1 -1
  156. package/src/messaging/providers/slack/types.ts +5 -0
  157. package/src/messaging/providers/slack/web-api-transport.test.ts +181 -0
  158. package/src/messaging/providers/slack/web-api-transport.ts +367 -0
  159. package/src/messaging/providers/slack/withdraw.ts +6 -14
  160. package/src/messaging/providers/telegram-bot/send.test.ts +31 -1
  161. package/src/messaging/providers/telegram-bot/send.ts +23 -2
  162. package/src/messaging/providers/telegram-bot/withdraw.test.ts +152 -0
  163. package/src/messaging/providers/telegram-bot/withdraw.ts +166 -0
  164. package/src/monitoring/__tests__/db-integrity-sample.test.ts +18 -4
  165. package/src/monitoring/__tests__/file-descriptors.test.ts +144 -0
  166. package/src/monitoring/file-descriptors.ts +262 -0
  167. package/src/monitoring/process-memory.ts +4 -11
  168. package/src/monitoring/resource-sampler.ts +5 -0
  169. package/src/monitoring/worker.ts +12 -0
  170. package/src/notifications/__tests__/assistant-reply-producer.test.ts +740 -0
  171. package/src/notifications/__tests__/broadcaster.test.ts +347 -7
  172. package/src/notifications/__tests__/copy-composer.test.ts +53 -3
  173. package/src/notifications/__tests__/decision-engine.test.ts +349 -29
  174. package/src/notifications/__tests__/deterministic-checks.test.ts +21 -0
  175. package/src/notifications/__tests__/edit-notification.test.ts +330 -0
  176. package/src/notifications/__tests__/guardian-delivery-recorder.test.ts +71 -0
  177. package/src/notifications/__tests__/home-feed-side-effect.test.ts +170 -12
  178. package/src/notifications/adapters/macos.ts +3 -0
  179. package/src/notifications/adapters/platform.ts +90 -14
  180. package/src/notifications/adapters/telegram.ts +6 -4
  181. package/src/notifications/assistant-reply-producer.ts +219 -0
  182. package/src/notifications/broadcaster.ts +523 -317
  183. package/src/notifications/copy-composer.ts +27 -6
  184. package/src/notifications/decision-engine.ts +154 -77
  185. package/src/notifications/deterministic-checks.ts +6 -7
  186. package/src/notifications/edit-notification.ts +8 -4
  187. package/src/notifications/emit-signal.ts +49 -16
  188. package/src/notifications/guardian-delivery-recorder.ts +8 -2
  189. package/src/notifications/home-feed-side-effect.ts +43 -6
  190. package/src/notifications/notification-utils.ts +39 -4
  191. package/src/notifications/signal.ts +10 -0
  192. package/src/notifications/types.ts +17 -0
  193. package/src/persistence/bookmark-crud.ts +18 -9
  194. package/src/persistence/conversation-attention-store.ts +26 -0
  195. package/src/persistence/conversation-crud.ts +79 -32
  196. package/src/persistence/conversation-queries.ts +30 -15
  197. package/src/persistence/conversation-title-service.ts +5 -170
  198. package/src/persistence/conversation-types.ts +192 -1
  199. package/src/persistence/db-init.ts +14 -1
  200. package/src/persistence/db-maintenance.ts +5 -4
  201. package/src/persistence/embeddings/__tests__/plugin-index-qdrant-init.test.ts +219 -0
  202. package/src/persistence/embeddings/__tests__/plugin-index.test.ts +22 -0
  203. package/src/persistence/embeddings/__tests__/worker-script-version.test.ts +76 -0
  204. package/src/persistence/embeddings/embedding-local.ts +2 -1
  205. package/src/persistence/embeddings/embedding-runtime-manager.ts +29 -5
  206. package/src/persistence/embeddings/plugin-index.ts +84 -12
  207. package/src/persistence/migrations/360-add-document-workspace-path.test.ts +110 -0
  208. package/src/persistence/migrations/360-add-document-workspace-path.ts +39 -0
  209. package/src/persistence/planner-statistics.ts +14 -0
  210. package/src/persistence/schema/documents.ts +26 -11
  211. package/src/persistence/steps.ts +2 -0
  212. package/src/platform/client.test.ts +172 -2
  213. package/src/platform/client.ts +178 -55
  214. package/src/plugin-api/conversation-turn.ts +13 -0
  215. package/src/plugin-api/model-profiles.test.ts +6 -2
  216. package/src/plugins/defaults/memory/__tests__/bookmark-crud.test.ts +34 -0
  217. package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +22 -0
  218. package/src/plugins/defaults/memory/__tests__/jobs-store-enqueue-gate.test.ts +11 -0
  219. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +199 -0
  220. package/src/plugins/defaults/memory/__tests__/memory-retrospective-enqueue.test.ts +98 -1
  221. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +105 -1
  222. package/src/plugins/defaults/memory/__tests__/memory-retrospective-sweep.test.ts +24 -2
  223. package/src/plugins/defaults/memory/__tests__/memory-retrospective-wake-chain.test.ts +524 -0
  224. package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +1 -1
  225. package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +9 -4
  226. package/src/plugins/defaults/memory/host-utils.ts +5 -0
  227. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +105 -1
  228. package/src/plugins/defaults/memory/memory-retrospective-enqueue.ts +64 -5
  229. package/src/plugins/defaults/memory/memory-retrospective-job.ts +36 -1
  230. package/src/plugins/defaults/memory/memory-retrospective-sweep.ts +12 -3
  231. package/src/plugins/defaults/memory/src/__tests__/memory-v2-simulate-route.test.ts +1 -0
  232. package/src/plugins/defaults/memory/substrate/__tests__/skill-store.test.ts +195 -1
  233. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +1 -1
  234. package/src/plugins/defaults/memory/substrate/skill-store.ts +167 -29
  235. package/src/plugins/defaults/memory/v2/__tests__/injection.test.ts +131 -0
  236. package/src/plugins/defaults/memory/v2/__tests__/router.test.ts +1 -0
  237. package/src/plugins/defaults/memory/v2/activation-log-store.ts +4 -0
  238. package/src/plugins/defaults/memory/v2/injection.ts +48 -3
  239. package/src/plugins/defaults/memory/v2/rerank-local.ts +6 -2
  240. package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +1 -1
  241. package/src/plugins/defaults/platform-hosted/routes/reengage.ts +1 -1
  242. package/src/plugins/defaults/turn-context/injectors.ts +1 -0
  243. package/src/plugins/defaults/turn-context/unified-turn-context.ts +26 -0
  244. package/src/plugins/types.ts +17 -0
  245. package/src/prompts/templates/system-sections.ts +2 -2
  246. package/src/providers/__tests__/dispatch-connection-routing.test.ts +43 -2
  247. package/src/providers/__tests__/vellum-mismatch-routing.test.ts +8 -0
  248. package/src/providers/call-site-routing.ts +77 -14
  249. package/src/providers/connection-resolution.ts +42 -2
  250. package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +127 -1
  251. package/src/providers/inference/adapter-factory.ts +70 -12
  252. package/src/providers/model-catalog.ts +0 -11
  253. package/src/providers/model-intents.ts +11 -0
  254. package/src/providers/registry.ts +8 -0
  255. package/src/providers/retry.ts +85 -17
  256. package/src/providers/types.ts +8 -1
  257. package/src/runtime/__tests__/agent-wake.test.ts +181 -0
  258. package/src/runtime/agent-wake.ts +132 -9
  259. package/src/runtime/channel-approval-types.ts +25 -0
  260. package/src/runtime/routes/__tests__/connection-routes-vs-cli-parity.test.ts +2 -2
  261. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +11 -0
  262. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +121 -9
  263. package/src/runtime/routes/__tests__/platform-invoice-routes.test.ts +392 -0
  264. package/src/runtime/routes/__tests__/slack-channel-routes.test.ts +7 -0
  265. package/src/runtime/routes/__tests__/workspace-commit-routes.test.ts +63 -0
  266. package/src/runtime/routes/consolidation-routes.ts +1 -1
  267. package/src/runtime/routes/conversation-management-routes.ts +18 -6
  268. package/src/runtime/routes/conversation-query-routes.ts +14 -23
  269. package/src/runtime/routes/conversation-routes.ts +113 -8
  270. package/src/runtime/routes/credential-routes.ts +1 -5
  271. package/src/runtime/routes/documents-routes.ts +207 -12
  272. package/src/runtime/routes/identity-routes.ts +3 -93
  273. package/src/runtime/routes/inbound-stages/admission-policy.test.ts +31 -1
  274. package/src/runtime/routes/inbound-stages/admission-policy.ts +18 -0
  275. package/src/runtime/routes/inference-provider-connection-routes.ts +52 -3
  276. package/src/runtime/routes/notification-routes.ts +4 -1
  277. package/src/runtime/routes/platform-routes.ts +238 -3
  278. package/src/runtime/routes/playground/guard.ts +1 -2
  279. package/src/runtime/routes/slack-channel-routes.ts +2 -4
  280. package/src/runtime/routes/workspace-commit-routes.ts +49 -2
  281. package/src/runtime/routes/workspace-routes.ts +11 -12
  282. package/src/runtime/routes/workspace-utils.ts +49 -2
  283. package/src/runtime/services/conversation-serializer.ts +2 -4
  284. package/src/subagent/__tests__/consult-context-gating.test.ts +98 -0
  285. package/src/subagent/__tests__/consult-context-skills.test.ts +82 -0
  286. package/src/subagent/__tests__/consult-context.test.ts +84 -0
  287. package/src/subagent/__tests__/consult-prompt.test.ts +36 -0
  288. package/src/subagent/consult-context.ts +410 -0
  289. package/src/subagent/consult-prompt.ts +38 -5
  290. package/src/subagent/manager.ts +9 -4
  291. package/src/subagent/types.ts +8 -0
  292. package/src/telemetry/telemetry-event-sources.ts +7 -0
  293. package/src/telemetry/telemetry-wire-source.json +1 -1
  294. package/src/telemetry/telemetry-wire.generated.ts +1 -0
  295. package/src/telemetry/turn-events-store.ts +30 -1
  296. package/src/telemetry/types.ts +24 -0
  297. package/src/telemetry/usage-telemetry-reporter.test.ts +48 -0
  298. package/src/tools/browser/pinned-tabs.ts +3 -1
  299. package/src/tools/subagent/spawn.ts +23 -1
  300. package/src/tools/terminal/safe-env.ts +1 -0
  301. package/src/tools/ui-surface/definitions.ts +39 -1
  302. package/src/tools/ui-surface/surface-shape-docs.ts +38 -4
  303. package/src/tools/ui-surface/visual-validation.ts +787 -0
  304. package/src/tools/workflows/run-workflow.test.ts +1 -0
  305. package/src/util/__tests__/short-title.test.ts +229 -0
  306. package/src/util/__tests__/worker-compute.test.ts +65 -0
  307. package/src/util/cgroup-cpu.ts +93 -0
  308. package/src/util/errors.ts +27 -0
  309. package/src/util/process-tree.ts +19 -0
  310. package/src/util/short-title.ts +189 -0
  311. package/src/util/worker-compute.ts +83 -0
  312. package/src/workspace/byok-default-profile-ensure.ts +25 -15
  313. package/src/workspace/git-service.ts +79 -30
  314. package/src/workspace/migrations/137-repair-retired-fireworks-minimax-model-id.ts +131 -0
  315. package/src/workspace/migrations/138-backfill-home-feed-titles.ts +179 -0
  316. package/src/workspace/migrations/139-clear-renamed-cost-profile-label.ts +92 -0
  317. package/src/workspace/migrations/registry.ts +6 -0
  318. package/src/plugins/defaults/memory/substrate/constants.ts +0 -8
@@ -24,6 +24,12 @@
24
24
  // case only the dense Qdrant write is deferred so the cache (and the v3 needle
25
25
  // lane it feeds) still reflects the current skills. An unexpected error in
26
26
  // another step leaves the prior cache intact (skills are best-effort).
27
+ //
28
+ // Always-candidate skills are pinned into the selector pool by catalog
29
+ // declaration rather than by similarity, so their membership and their cards
30
+ // are served from the local catalog before the first seed run completes —
31
+ // `listAlwaysCandidateSkillSlugs` primes that fallback, and the seeded snapshot
32
+ // replaces it as soon as it lands.
27
33
 
28
34
  import { listCatalogSkills, listInstalledSkills } from "@vellumai/plugin-api";
29
35
 
@@ -79,6 +85,30 @@ export function skillSlugFor(id: string): string {
79
85
  * successful re-seed so callers always see a consistent snapshot.
80
86
  */
81
87
  let entries: Map<string, SkillEntry> | null = null;
88
+
89
+ /**
90
+ * Ids of the enabled skills that declared `always-candidate` in their
91
+ * frontmatter, captured from the same enumeration that builds {@link entries}
92
+ * and replaced with it. Reading it here rather than re-enumerating the catalog
93
+ * keeps the injection engines off the per-turn filesystem scan
94
+ * `listInstalledSkills` performs.
95
+ */
96
+ let alwaysCandidateIds: ReadonlySet<string> = new Set<string>();
97
+
98
+ /**
99
+ * Pre-seed view of the same enabled-skill cards, read straight from the local
100
+ * catalog by {@link ensureCatalogFallback}.
101
+ *
102
+ * Always-candidate membership is a static catalog fact, so the injection
103
+ * engines must not have to wait on Qdrant plus a configured embedding backend
104
+ * to learn it: on a freshly hatched assistant the first turn arrives before the
105
+ * boot seed finishes, and an empty pin set means the model never sees a pinned
106
+ * skill card. The fallback is built at most once and is dropped the moment a
107
+ * seed run installs the authoritative {@link entries} snapshot.
108
+ */
109
+ let fallbackEntries: Map<string, SkillEntry> | null = null;
110
+ let fallbackAlwaysCandidateIds: ReadonlySet<string> = new Set<string>();
111
+ let fallbackLoad: Promise<void> | null = null;
82
112
  let requestedSeedGeneration = 0;
83
113
  let processedSeedGeneration = 0;
84
114
  let activeSeedDrain: Promise<void> | null = null;
@@ -105,11 +135,93 @@ const seedWaiters: Array<{ generation: number; resolve: () => void }> = [];
105
135
  */
106
136
  let legacyKindBackfillDone = false;
107
137
 
138
+ interface InstalledSkillCards {
139
+ /** Every locally installed skill id, whatever its resolved state. */
140
+ installedIds: Set<string>;
141
+ /** Rendered capability cards for the enabled skills, in catalog order. */
142
+ cards: SkillEntry[];
143
+ /** The subset of {@link cards} whose skill declared `always-candidate`. */
144
+ alwaysCandidateIds: Set<string>;
145
+ }
146
+
147
+ /**
148
+ * Enumerate the installed skill catalog and render one capability card per
149
+ * enabled skill. Feature-flag gating is resolved host-side: gated skills arrive
150
+ * as `state: "unavailable"` and are dropped by the enabled filter, as are
151
+ * skills the user disabled.
152
+ *
153
+ * The single definition of "which skills are enabled and what does each card
154
+ * say": the seed run embeds these cards and caches them in {@link entries},
155
+ * and {@link ensureCatalogFallback} serves the same cards from the catalog
156
+ * before the first seed completes.
157
+ */
158
+ async function buildInstalledSkillCards(): Promise<InstalledSkillCards> {
159
+ const installed = await listInstalledSkills();
160
+ const installedIds = new Set<string>(installed.map((s) => s.id));
161
+ const cards: SkillEntry[] = [];
162
+ const alwaysCandidateIds = new Set<string>();
163
+ for (const skill of installed) {
164
+ if (skill.state !== "enabled") {
165
+ continue;
166
+ }
167
+ const augmented = augmentMcpSetupDescription(skill);
168
+ // Always-candidate skills are pinned into the selector pool every turn, so
169
+ // they get a larger budget for a fuller, multi-mode capability statement.
170
+ const content = buildSkillContent(
171
+ augmented,
172
+ skill.alwaysCandidate ? ALWAYS_CANDIDATE_CARD_CHARS : undefined,
173
+ );
174
+ if (skill.alwaysCandidate) {
175
+ alwaysCandidateIds.add(skill.id);
176
+ }
177
+ cards.push({ id: skill.id, content });
178
+ }
179
+ return { installedIds, cards, alwaysCandidateIds };
180
+ }
181
+
182
+ /**
183
+ * Populate {@link fallbackEntries} from the local catalog when no seed run has
184
+ * installed {@link entries} yet. A no-op once either cache is present, so the
185
+ * per-turn cost after the first call is a resolved promise rather than the
186
+ * filesystem scan `listInstalledSkills` performs. Concurrent callers share one
187
+ * in-flight build; a failed build is not latched, so the next turn retries.
188
+ */
189
+ async function ensureCatalogFallback(): Promise<void> {
190
+ if (entries || fallbackEntries) {
191
+ return;
192
+ }
193
+ const pending = (fallbackLoad ??= buildInstalledSkillCards()
194
+ .then(({ cards, alwaysCandidateIds: ids }) => {
195
+ // A seed that landed while this build was in flight is authoritative.
196
+ if (entries) {
197
+ return;
198
+ }
199
+ fallbackEntries = new Map(cards.map((card) => [card.id, card]));
200
+ fallbackAlwaysCandidateIds = ids;
201
+ })
202
+ .catch((err: unknown) => {
203
+ log.warn(
204
+ { err },
205
+ "Failed to enumerate the skill catalog for the pre-seed card fallback",
206
+ );
207
+ })
208
+ .finally(() => {
209
+ fallbackLoad = null;
210
+ }));
211
+ await pending;
212
+ }
213
+
214
+ /** The authoritative seeded snapshot, or the pre-seed catalog fallback. */
215
+ function readableEntries(): Map<string, SkillEntry> | null {
216
+ return entries ?? fallbackEntries;
217
+ }
218
+
108
219
  /**
109
220
  * Steps (per run):
110
221
  * 1. Enumerate the installed skill catalog with resolved states
111
- * (`listInstalledSkills`). Feature-flag gating is resolved host-side:
112
- * gated skills arrive as `state: "unavailable"` and are not seeded.
222
+ * (`buildInstalledSkillCards`, over `listInstalledSkills`). Feature-flag
223
+ * gating is resolved host-side: gated skills arrive as
224
+ * `state: "unavailable"` and are not seeded.
113
225
  * 2. Build a `SkillEntry` per enabled skill, applying the mcp-setup
114
226
  * augmentation and the prose-style content render (`buildSkillContent`,
115
227
  * capped at 500 chars).
@@ -176,29 +288,17 @@ function resolveSeedWaiters(): void {
176
288
  async function runSeedV2SkillEntries(generation: number): Promise<void> {
177
289
  try {
178
290
  const config = getConfig();
179
- const installed = await listInstalledSkills();
180
- const enabled = installed.filter((s) => s.state === "enabled");
181
-
182
- // Track every locally-installed skill id (regardless of enabled/disabled/
183
- // unavailable state) so the catalog-seeding loop below treats them all as
184
- // "installed" and never re-seeds a disabled skill from the remote catalog
185
- // as if it were uninstalled.
186
- const installedIds = new Set<string>(installed.map((s) => s.id));
187
-
188
- // Build the input list, applying the mcp-setup description augmentation.
189
- // Flag-gated skills arrive as `state: "unavailable"` and are excluded by
190
- // the enabled filter.
191
- const seeds: SkillEntry[] = [];
192
- for (const skill of enabled) {
193
- const augmented = augmentMcpSetupDescription(skill);
194
- // Always-candidate skills are pinned into the selector pool every turn, so
195
- // they get a larger budget for a fuller, multi-mode capability statement.
196
- const content = buildSkillContent(
197
- augmented,
198
- skill.alwaysCandidate ? ALWAYS_CANDIDATE_CARD_CHARS : undefined,
199
- );
200
- seeds.push({ id: skill.id, content });
201
- }
291
+
292
+ // `installedIds` tracks every locally-installed skill id (regardless of
293
+ // enabled/disabled/unavailable state) so the catalog-seeding loop below
294
+ // treats them all as "installed" and never re-seeds a disabled skill from
295
+ // the remote catalog as if it were uninstalled.
296
+ const {
297
+ installedIds,
298
+ cards,
299
+ alwaysCandidateIds: nextAlwaysCandidateIds,
300
+ } = await buildInstalledSkillCards();
301
+ const seeds: SkillEntry[] = [...cards];
202
302
 
203
303
  // Seed uninstalled catalog skills so their activation hints are
204
304
  // discoverable by intent. Track whether the catalog was available so we
@@ -366,6 +466,11 @@ async function runSeedV2SkillEntries(generation: number): Promise<void> {
366
466
  // observes the new skill set (skill entries share the unified concept-page
367
467
  // collection and surface in the same index).
368
468
  entries = nextEntries;
469
+ alwaysCandidateIds = nextAlwaysCandidateIds;
470
+ // The seeded snapshot supersedes the pre-seed catalog fallback, which also
471
+ // omits the uninstalled catalog skills this run embedded.
472
+ fallbackEntries = null;
473
+ fallbackAlwaysCandidateIds = new Set<string>();
369
474
  invalidatePageIndex();
370
475
 
371
476
  // Surface a dense-embed failure to `throwOnError` callers (the managed-
@@ -380,9 +485,11 @@ async function runSeedV2SkillEntries(generation: number): Promise<void> {
380
485
  }
381
486
 
382
487
  /**
383
- * Synchronous lookup of a previously-seeded `SkillEntry` by skill id. Returns
384
- * `null` when the cache has not yet been populated, when the id is unknown,
385
- * or when a prior seed run dropped the id (e.g. the skill was disabled).
488
+ * Synchronous lookup of a rendered `SkillEntry` by skill id, served from the
489
+ * seeded cache or before the first seed completes from the pre-seed
490
+ * catalog fallback {@link listAlwaysCandidateSkillSlugs} primes. Returns `null`
491
+ * when neither is populated, when the id is unknown, or when a prior seed run
492
+ * dropped the id (e.g. the skill was disabled).
386
493
  *
387
494
  * Accepts either a bare skill id (`example-skill`) or its unified-collection
388
495
  * slug (`skills/example-skill`) so render-side callers can pass through what
@@ -395,7 +502,7 @@ export function getSkillCapability(idOrSlug: string): SkillEntry | null {
395
502
  const id = idOrSlug.startsWith(SKILL_SLUG_PREFIX)
396
503
  ? idOrSlug.slice(SKILL_SLUG_PREFIX.length)
397
504
  : idOrSlug;
398
- const entry = entries?.get(id);
505
+ const entry = readableEntries()?.get(id);
399
506
  return entry ? Object.freeze({ ...entry }) : null;
400
507
  }
401
508
 
@@ -423,9 +530,40 @@ export function listSkillEntries(): SkillEntry[] {
423
530
  .map((entry) => Object.freeze({ ...entry }));
424
531
  }
425
532
 
533
+ /**
534
+ * Slugs of the enabled skills marked `always-candidate`, restricted to those
535
+ * whose card is renderable.
536
+ *
537
+ * Retrieval scores a skill by how closely the turn resembles its card, so a
538
+ * cross-cutting capability the user never names by hand never wins a candidate
539
+ * slot. These skills opt out of that: whether they apply is a judgment for the
540
+ * model, so the injection engines pin their cards instead of waiting for a
541
+ * similarity hit.
542
+ *
543
+ * Async because membership is a static catalog fact that must not depend on the
544
+ * embedding store: before the first seed run completes this reads the catalog
545
+ * directly (see {@link ensureCatalogFallback}), which also makes each pinned
546
+ * slug's card resolvable through {@link getSkillCapability} so the render path
547
+ * emits it instead of silently dropping the pin. Once a seed run lands, its
548
+ * snapshot is authoritative.
549
+ */
550
+ export async function listAlwaysCandidateSkillSlugs(): Promise<string[]> {
551
+ await ensureCatalogFallback();
552
+ const available = readableEntries();
553
+ const ids = entries ? alwaysCandidateIds : fallbackAlwaysCandidateIds;
554
+ return [...ids]
555
+ .filter((id) => available?.has(id) === true)
556
+ .sort()
557
+ .map((id) => skillSlugFor(id));
558
+ }
559
+
426
560
  /** @internal Test-only: clear the module-level cache. */
427
561
  export function _resetSkillStoreForTests(): void {
428
562
  entries = null;
563
+ alwaysCandidateIds = new Set<string>();
564
+ fallbackEntries = null;
565
+ fallbackAlwaysCandidateIds = new Set<string>();
566
+ fallbackLoad = null;
429
567
  requestedSeedGeneration = 0;
430
568
  processedSeedGeneration = 0;
431
569
  activeSeedDrain = null;
@@ -129,6 +129,8 @@ mock.module("@qdrant/js-client-rest", () => ({
129
129
  const skillState = {
130
130
  /** id → SkillEntry consulted by `getSkillCapability`. */
131
131
  entries: new Map<string, SkillEntry>(),
132
+ /** Skill ids the store reports as `always-candidate`. */
133
+ alwaysCandidateIds: new Set<string>(),
132
134
  };
133
135
 
134
136
  mock.module("../../substrate/skill-store.js", () => ({
@@ -146,6 +148,11 @@ mock.module("../../substrate/skill-store.js", () => ({
146
148
  // time. Tests stage skill content via `skillState.entries`; expose them
147
149
  // here so the page-index loader sees a consistent view.
148
150
  listSkillEntries: () => Array.from(skillState.entries.values()),
151
+ listAlwaysCandidateSkillSlugs: () =>
152
+ [...skillState.alwaysCandidateIds]
153
+ .filter((id) => skillState.entries.has(id))
154
+ .sort()
155
+ .map((id) => `skills/${id}`),
149
156
  }));
150
157
 
151
158
  // ---------------------------------------------------------------------------
@@ -527,6 +534,7 @@ function resetState(): void {
527
534
  state.queryResponses.dense.length = 0;
528
535
  state.queryResponses.sparse.length = 0;
529
536
  skillState.entries.clear();
537
+ skillState.alwaysCandidateIds.clear();
530
538
  cliCommandState.entries.clear();
531
539
  telemetryState.recordCalls.length = 0;
532
540
  telemetryState.recordShouldThrow = false;
@@ -547,6 +555,13 @@ function stageSkills(entries: SkillEntry[]): void {
547
555
  }
548
556
  }
549
557
 
558
+ /** Mark staged skill ids as `always-candidate` in the mocked store. */
559
+ function stageAlwaysCandidates(ids: string[]): void {
560
+ for (const id of ids) {
561
+ skillState.alwaysCandidateIds.add(id);
562
+ }
563
+ }
564
+
550
565
  /** Stage cli-command-store cache entries for the upcoming render. */
551
566
  function stageCliCommands(entries: CliCommandEntryStub[]): void {
552
567
  for (const entry of entries) {
@@ -980,6 +995,122 @@ describe("injectMemoryV2Block", () => {
980
995
  expect(skillIdx).toBeGreaterThan(headerIdx);
981
996
  });
982
997
 
998
+ // ---------------------------------------------------------------------------
999
+ // Always-candidate skills
1000
+ // ---------------------------------------------------------------------------
1001
+
1002
+ test("pins an always-candidate skill card that retrieval never surfaced", async () => {
1003
+ stageTurn([]);
1004
+ stageSkills([
1005
+ {
1006
+ id: "visualize",
1007
+ content:
1008
+ 'The "Visualize" skill (visualize) is available. Render a polished visual inline.',
1009
+ },
1010
+ ]);
1011
+ stageAlwaysCandidates(["visualize"]);
1012
+
1013
+ const result = await injectMemoryV2Block({
1014
+ conversationId: "conv-pin-1",
1015
+ currentTurn: 1,
1016
+ recentTurnPairs: [
1017
+ { assistantMessage: "", userMessage: "How does DNS work?" },
1018
+ ],
1019
+ nowText: "Now",
1020
+ messageId: "msg-1",
1021
+ config: makeConfig(),
1022
+ });
1023
+
1024
+ expect(result.toInject).toEqual(["skills/visualize"]);
1025
+ expect(result.block).toContain("### Skills You Can Use");
1026
+ expect(result.block).toContain(
1027
+ 'The "Visualize" skill (visualize) is available. Render a polished visual inline. → use skill_load to activate',
1028
+ );
1029
+ });
1030
+
1031
+ test("does not double-list a pinned skill retrieval also picked", async () => {
1032
+ stageTurn([{ slug: "skills/visualize", denseScore: 0.9 }]);
1033
+ stageSkills([
1034
+ {
1035
+ id: "visualize",
1036
+ content:
1037
+ 'The "Visualize" skill (visualize) is available. Render a polished visual inline.',
1038
+ },
1039
+ ]);
1040
+ stageAlwaysCandidates(["visualize"]);
1041
+
1042
+ const result = await injectMemoryV2Block({
1043
+ conversationId: "conv-pin-2",
1044
+ currentTurn: 1,
1045
+ recentTurnPairs: [
1046
+ { assistantMessage: "", userMessage: "Draw me a diagram" },
1047
+ ],
1048
+ nowText: "Now",
1049
+ messageId: "msg-1",
1050
+ config: makeConfig(),
1051
+ });
1052
+
1053
+ expect(result.toInject).toEqual(["skills/visualize"]);
1054
+ const first = result.block!.indexOf('The "Visualize" skill');
1055
+ expect(first).toBeGreaterThan(-1);
1056
+ expect(result.block!.indexOf('The "Visualize" skill', first + 1)).toBe(-1);
1057
+ });
1058
+
1059
+ test("re-attaches a pinned skill only once across turns", async () => {
1060
+ stageTurn([]);
1061
+ stageSkills([
1062
+ {
1063
+ id: "visualize",
1064
+ content:
1065
+ 'The "Visualize" skill (visualize) is available. Render a polished visual inline.',
1066
+ },
1067
+ ]);
1068
+ stageAlwaysCandidates(["visualize"]);
1069
+
1070
+ const params = {
1071
+ conversationId: "conv-pin-3",
1072
+ recentTurnPairs: [
1073
+ { assistantMessage: "", userMessage: "How does DNS work?" },
1074
+ ],
1075
+ nowText: "Now",
1076
+ config: makeConfig(),
1077
+ };
1078
+ const first = await injectMemoryV2Block({
1079
+ ...params,
1080
+ currentTurn: 1,
1081
+ messageId: "msg-1",
1082
+ });
1083
+ expect(first.toInject).toEqual(["skills/visualize"]);
1084
+
1085
+ stageTurn([]);
1086
+ const second = await injectMemoryV2Block({
1087
+ ...params,
1088
+ currentTurn: 2,
1089
+ messageId: "msg-2",
1090
+ });
1091
+ expect(second.toInject).toEqual([]);
1092
+ expect(second.block).toBeNull();
1093
+ });
1094
+
1095
+ test("does not pin an always-candidate skill missing from the store cache", async () => {
1096
+ stageTurn([]);
1097
+ stageAlwaysCandidates(["visualize"]);
1098
+
1099
+ const result = await injectMemoryV2Block({
1100
+ conversationId: "conv-pin-4",
1101
+ currentTurn: 1,
1102
+ recentTurnPairs: [
1103
+ { assistantMessage: "", userMessage: "How does DNS work?" },
1104
+ ],
1105
+ nowText: "Now",
1106
+ messageId: "msg-1",
1107
+ config: makeConfig(),
1108
+ });
1109
+
1110
+ expect(result.toInject).toEqual([]);
1111
+ expect(result.block).toBeNull();
1112
+ });
1113
+
983
1114
  test("renders concept-page sections before the skills subsection in mixed blocks", async () => {
984
1115
  // Concept page hit AND a skill — concept-page sections come first, then
985
1116
  // the skills subsection.
@@ -73,6 +73,7 @@ mock.module("../../../../../util/logger.js", () => ({
73
73
  mock.module("../../substrate/skill-store.js", () => ({
74
74
  SKILL_SLUG_PREFIX: "skills/",
75
75
  listSkillEntries: () => skillState.entries,
76
+ listAlwaysCandidateSkillSlugs: () => [],
76
77
  }));
77
78
 
78
79
  // Stub `computeInjectionScores` so tier-2 tests can dictate scores
@@ -57,6 +57,9 @@ export interface MemoryV2ConceptRowRecord {
57
57
  * A single-batch (no-tier carve-out) workspace produces `tier3:0`.
58
58
  * The bucket index lets inspector queries attribute selections to
59
59
  * specific hash-bucketed parallel calls.
60
+ * - `always_candidate` — a skill pinned by its `always-candidate`
61
+ * frontmatter rather than selected by activation or the router. Like
62
+ * the router-mode tags it carries zeroed activation values.
60
63
  * - `carry_over` — router-mode row representing a slug carried over
61
64
  * from `priorEverInjected` that the router did NOT re-pick on this
62
65
  * turn. The cached attachment from a prior turn is still present
@@ -72,6 +75,7 @@ export interface MemoryV2ConceptRowRecord {
72
75
  | "ann_top50"
73
76
  | "both"
74
77
  | "router"
78
+ | "always_candidate"
75
79
  | "carry_over"
76
80
  | "tier1"
77
81
  | "tier2"
@@ -33,7 +33,11 @@ import {
33
33
  import { getEdgeIndex } from "../substrate/edge-index.js";
34
34
  import { getPageIndex } from "../substrate/page-index.js";
35
35
  import { readPage, renderPageContent } from "../substrate/page-store.js";
36
- import { getSkillCapability, isSkillSlug } from "../substrate/skill-store.js";
36
+ import {
37
+ getSkillCapability,
38
+ isSkillSlug,
39
+ listAlwaysCandidateSkillSlugs,
40
+ } from "../substrate/skill-store.js";
37
41
  import { spreadActivation } from "../substrate/spread.js";
38
42
  import type { ActivationState, EverInjectedEntry } from "../substrate/types.js";
39
43
  import {
@@ -316,6 +320,28 @@ export async function injectMemoryV2Block(
316
320
  * which this helper overwrites. `nextStateMap` is the activation
317
321
  * pipeline's sparse next-state; router-mode callers pass an empty map.
318
322
  */
323
+ /**
324
+ * Telemetry row for a pinned always-candidate skill. Activation never scored
325
+ * it, so every score field is zero, matching the router-mode rows.
326
+ */
327
+ function makePinnedSkillRow(slug: string): MemoryV2ConceptRowRecord {
328
+ return {
329
+ slug,
330
+ finalActivation: 0,
331
+ ownActivation: 0,
332
+ priorActivation: 0,
333
+ simUser: 0,
334
+ simAssistant: 0,
335
+ simNow: 0,
336
+ simUserRerankBoost: 0,
337
+ simAssistantRerankBoost: 0,
338
+ inRerankPool: false,
339
+ spreadContribution: 0,
340
+ source: "always_candidate",
341
+ status: "not_injected",
342
+ };
343
+ }
344
+
319
345
  async function finalizeInjection(args: {
320
346
  workspaceDir: string;
321
347
  conversationId: string;
@@ -342,12 +368,32 @@ async function finalizeInjection(args: {
342
368
  currentTurn,
343
369
  messageId,
344
370
  priorEverInjected,
345
- slugsToRender,
371
+ slugsToRender: selectedSlugs,
346
372
  telemetryRows,
347
373
  config,
348
374
  nextStateMap,
349
375
  } = args;
350
376
 
377
+ const everInjectedSet = new Set(priorEverInjected.map((entry) => entry.slug));
378
+
379
+ // Skills marked `always-candidate` are pinned rather than retrieved: their
380
+ // relevance is a judgment the model makes each turn, not a similarity the
381
+ // embedding finds, so activation and the router both leave them out. Attach
382
+ // them alongside whatever this turn selected, skipping any already carried by
383
+ // the cached prefix — v2 is append-only, so a card attached once stays
384
+ // visible until compaction evicts the turn and re-opens the slug.
385
+ const selectedSet = new Set(selectedSlugs);
386
+ const pinnedSlugs = (await listAlwaysCandidateSkillSlugs()).filter(
387
+ (slug) => !selectedSet.has(slug) && !everInjectedSet.has(slug),
388
+ );
389
+ const slugsToRender = [...selectedSlugs, ...pinnedSlugs];
390
+ const telemetrySlugSet = new Set(telemetryRows.map((row) => row.slug));
391
+ for (const slug of pinnedSlugs) {
392
+ if (!telemetrySlugSet.has(slug)) {
393
+ telemetryRows.push(makePinnedSkillRow(slug));
394
+ }
395
+ }
396
+
351
397
  // `mode` is `let` because the trailing try/finally promotes it to "errored"
352
398
  // when the render/telemetry path throws — we still want a log row written
353
399
  // (with whatever rows we managed to build) so silent failures are
@@ -377,7 +423,6 @@ async function finalizeInjection(args: {
377
423
  (isCliCommandSlug(slug) && !getCliCommandCapability(slug)),
378
424
  ),
379
425
  );
380
- const everInjectedSet = new Set(priorEverInjected.map((entry) => entry.slug));
381
426
  const newlyInjected = slugsToRender.filter(
382
427
  (slug) => !everInjectedSet.has(slug) && !missingSyntheticSlugs.has(slug),
383
428
  );
@@ -3,7 +3,11 @@ import { existsSync } from "node:fs";
3
3
 
4
4
  import type { RerankDtype } from "../../../../config/schemas/memory-v2.js";
5
5
  import { EmbeddingRuntimeManager } from "../../../../persistence/embeddings/embedding-runtime-manager.js";
6
- import { PromiseGuard, workerMemoryEnv } from "../host-utils.js";
6
+ import {
7
+ PromiseGuard,
8
+ workerComputeEnv,
9
+ workerMemoryEnv,
10
+ } from "../host-utils.js";
7
11
  import { getLogger } from "../logging.js";
8
12
  import { getEmbeddingModelsDir } from "../paths.js";
9
13
 
@@ -160,7 +164,7 @@ export class LocalRerankBackend {
160
164
  modelCacheDir,
161
165
  this.dtype,
162
166
  ],
163
- env: workerMemoryEnv(),
167
+ env: { ...workerMemoryEnv(), ...workerComputeEnv() },
164
168
  stdin: "pipe",
165
169
  stdout: "pipe",
166
170
  stderr: "pipe",
@@ -418,7 +418,7 @@ describe("selectPool — infrastructure failures throw", () => {
418
418
  source: "memory_v3",
419
419
  code: "PROVIDER_BILLING",
420
420
  userMessage:
421
- "You've run out of credits. Add funds to continue using the assistant.",
421
+ "You're out of credits. Add credits in Settings Billing to continue.",
422
422
  errorCategory: "credits_exhausted",
423
423
  });
424
424
  });
@@ -182,7 +182,7 @@ export const POST = async (request: Request): Promise<Response> => {
182
182
  // Drafting a short email is latency-bound, not depth-bound, so run it on
183
183
  // the fast model rather than the balanced main-agent default. `inference`
184
184
  // is the general-purpose call site plugins use for exactly this — it
185
- // resolves to the Speed (cost-optimized) profile so we reuse it instead
185
+ // resolves to the Cost (cost-optimized) profile, so we reuse it instead
186
186
  // of teaching the daemon about a reengagement-specific call site.
187
187
  callSite: "inference",
188
188
  signal: request.signal,
@@ -35,6 +35,7 @@ const unifiedTurnContextInjector: Injector = {
35
35
  timestamp,
36
36
  interfaceName: ctx.interfaceName,
37
37
  clientOs: ctx.clientOs,
38
+ visibleApp: ctx.visibleApp,
38
39
  channelName: ctx.channelName,
39
40
  actorContext: ctx.actorContext,
40
41
  configuredUserTimezone: ctx.configuredUserTimezone,
@@ -19,6 +19,20 @@ export interface UnifiedTurnContextOptions {
19
19
  timestamp: string;
20
20
  interfaceName?: string;
21
21
  clientOs?: string;
22
+ /**
23
+ * App the user currently has open on screen. Rendered as the `visible_app:`
24
+ * line so unqualified references to "the app" resolve without a lookup.
25
+ */
26
+ visibleApp?: {
27
+ appId: string;
28
+ /** Opaque for workspace apps, so the readable handle travels alongside. */
29
+ name: string;
30
+ /** Directory stem: the handle a human would recognize the app by. */
31
+ slug: string;
32
+ sourceDir: string;
33
+ /** Set when the app is bundled by an installed plugin rather than built here. */
34
+ pluginName?: string;
35
+ } | null;
22
36
  channelName?: string;
23
37
  actorContext?: InboundActorContext | null;
24
38
  configuredUserTimezone?: string | null;
@@ -104,6 +118,18 @@ export function buildUnifiedTurnContextBlock(
104
118
  if (options.clientOs) {
105
119
  lines.push(`client_os: ${options.clientOs}`);
106
120
  }
121
+ if (options.visibleApp) {
122
+ const app = options.visibleApp;
123
+ // Plugin-bundled apps are owned by their plugin: their source is replaced
124
+ // on plugin update, so the model is told where it came from rather than
125
+ // treating it as an ordinary sandbox app to rewrite.
126
+ const provenance = app.pluginName
127
+ ? ` It is bundled by the "${sanitizeInlineContextValue(app.pluginName)}" plugin, not built in the sandbox.`
128
+ : "";
129
+ lines.push(
130
+ `visible_app: "${sanitizeInlineContextValue(app.name)}" (app_id: "${sanitizeInlineContextValue(app.appId)}", slug: "${sanitizeInlineContextValue(app.slug)}", source: "${sanitizeInlineContextValue(app.sourceDir)}"). The user has this app open on screen; unqualified references to "the app" mean this one. The app tools take the app_id.${provenance}`,
131
+ );
132
+ }
107
133
 
108
134
  // Actor identity and trust fields — only for non-guardian turns.
109
135
  if (options.actorContext) {
@@ -153,6 +153,23 @@ export interface TurnContext {
153
153
  * transport interface.
154
154
  */
155
155
  readonly clientOs?: string;
156
+ /**
157
+ * The app the client has open on screen, resolved from the id the client
158
+ * reports with each message. Rendered as the `visible_app:` line so the
159
+ * assistant can resolve "the app" to what the user is looking at instead of
160
+ * guessing or asking. Absent when no app is in view.
161
+ */
162
+ readonly visibleApp?: {
163
+ /** Canonical id the app tools key off (an opaque UUID for workspace apps). */
164
+ appId: string;
165
+ name: string;
166
+ /** Directory stem: the readable handle for an otherwise opaque id. */
167
+ slug: string;
168
+ /** Absolute path of the app's source directory. */
169
+ sourceDir: string;
170
+ /** Owning plugin, when the app is plugin-bundled rather than sandbox-built. */
171
+ pluginName?: string;
172
+ } | null;
156
173
  /** Channel label gating response-discretion guidance in `<turn_context>`. */
157
174
  readonly channelName?: string;
158
175
  /**
@@ -341,9 +341,9 @@ To share a workspace file, use a markdown link with the \`vellum://\` scheme:
341
341
 
342
342
  The path after \`workspace/\` is relative to your working directory. The file renders as a downloadable attachment. For host filesystem files, use \`vellum://host/absolute/path\`.
343
343
 
344
- Use the same link form when referencing a workspace file you are discussing in the app, clicking the link lets the user open the file in the workspace browser or download it.
344
+ Use the same link form to reference a file you are discussing: in the app, the link lets the user open or download it.
345
345
 
346
- Embed images/GIFs inline using standard markdown: \`![description](URL)\`.
346
+ Embed images, audio, and video inline with \`![description](URL or vellum://workspace/path)\`. Reference every other file type, including PDFs and documents, as a plain link: \`[name](vellum://workspace/path)\`.
347
347
  `,
348
348
  },
349
349
  {