@vellumai/assistant 0.8.1 → 0.8.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 (506) hide show
  1. package/ARCHITECTURE.md +2 -7
  2. package/Dockerfile +75 -1
  3. package/bun.lock +11 -1
  4. package/docker-entrypoint.sh +5 -0
  5. package/docker-init-apt-root.sh +94 -0
  6. package/docker-kata-apt-env.sh +39 -0
  7. package/docs/plugins.md +88 -47
  8. package/docs/skills.md +9 -7
  9. package/examples/plugins/echo/README.md +27 -27
  10. package/examples/plugins/echo/package.json +3 -0
  11. package/examples/plugins/echo/register.ts +31 -31
  12. package/node_modules/@vellumai/slack-text/src/index.test.ts +114 -14
  13. package/node_modules/@vellumai/slack-text/src/index.ts +82 -18
  14. package/openapi.yaml +325 -3
  15. package/package.json +3 -1
  16. package/scripts/generate-openapi.ts +83 -10
  17. package/scripts/sync-llm-catalog.ts +2 -2
  18. package/scripts/sync-web-search-catalog.ts +47 -25
  19. package/src/__tests__/agent-image-optimize.test.ts +11 -3
  20. package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +131 -0
  21. package/src/__tests__/anthropic-provider.test.ts +45 -0
  22. package/src/__tests__/app-builder-tool-scripts.test.ts +9 -3
  23. package/src/__tests__/app-executors.test.ts +220 -4
  24. package/src/__tests__/auto-analysis-end-to-end.test.ts +35 -0
  25. package/src/__tests__/bundled-asset.test.ts +6 -6
  26. package/src/__tests__/channel-availability-routes.test.ts +206 -0
  27. package/src/__tests__/channel-delivery-store.test.ts +289 -1
  28. package/src/__tests__/circuit-breaker-pipeline.test.ts +0 -1
  29. package/src/__tests__/clawhub.test.ts +75 -16
  30. package/src/__tests__/compactor-tail-resolution.test.ts +41 -0
  31. package/src/__tests__/config-schema.test.ts +21 -0
  32. package/src/__tests__/config-set-route.test.ts +80 -0
  33. package/src/__tests__/config-sounds-sync.test.ts +97 -0
  34. package/src/__tests__/config-watcher-skill-reseed.test.ts +453 -0
  35. package/src/__tests__/context-search-conversations-source.test.ts +117 -2
  36. package/src/__tests__/context-search-memory-v2-source.test.ts +0 -1
  37. package/src/__tests__/context-search-workspace-source.test.ts +7 -0
  38. package/src/__tests__/context-token-estimator.test.ts +1 -0
  39. package/src/__tests__/conversation-abort-tool-results.test.ts +4 -1
  40. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -0
  41. package/src/__tests__/conversation-agent-loop-overflow.test.ts +92 -92
  42. package/src/__tests__/conversation-agent-loop.test.ts +2 -0
  43. package/src/__tests__/conversation-error.test.ts +42 -3
  44. package/src/__tests__/conversation-fork-crud.test.ts +82 -0
  45. package/src/__tests__/conversation-inference-profile-route.test.ts +40 -4
  46. package/src/__tests__/conversation-lifecycle.test.ts +173 -0
  47. package/src/__tests__/conversation-message-sync-tags.test.ts +97 -0
  48. package/src/__tests__/conversation-pairing.test.ts +54 -0
  49. package/src/__tests__/conversation-process-callsite.test.ts +4 -1
  50. package/src/__tests__/conversation-provider-retry-repair.test.ts +5 -1
  51. package/src/__tests__/conversation-queue.test.ts +4 -1
  52. package/src/__tests__/conversation-runtime-assembly.test.ts +76 -9
  53. package/src/__tests__/conversation-slash-queue.test.ts +59 -1
  54. package/src/__tests__/conversation-slash-unknown.test.ts +4 -1
  55. package/src/__tests__/conversation-surfaces-table-action.test.ts +360 -0
  56. package/src/__tests__/conversation-sync-tags.test.ts +235 -0
  57. package/src/__tests__/conversation-workspace-injection.test.ts +5 -1
  58. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +5 -1
  59. package/src/__tests__/credential-security-invariants.test.ts +3 -2
  60. package/src/__tests__/db-slack-external-content-normalization.test.ts +301 -0
  61. package/src/__tests__/delete-managed-skill-tool.test.ts +55 -13
  62. package/src/__tests__/disk-pressure-tools.test.ts +1 -0
  63. package/src/__tests__/dm-backfill.test.ts +121 -10
  64. package/src/__tests__/document-tool-security.test.ts +258 -0
  65. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +0 -1
  66. package/src/__tests__/edit-propagation.test.ts +33 -0
  67. package/src/__tests__/empty-response-pipeline.test.ts +0 -4
  68. package/src/__tests__/external-plugin-loader.test.ts +60 -36
  69. package/src/__tests__/filing-service.test.ts +140 -0
  70. package/src/__tests__/get-skill-detail-audit.test.ts +0 -4
  71. package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +43 -62
  72. package/src/__tests__/helpers/tar-fixtures.ts +39 -0
  73. package/src/__tests__/helpers/wait-for.ts +21 -0
  74. package/src/__tests__/history-repair-pipeline.test.ts +0 -3
  75. package/src/__tests__/history-repair.test.ts +73 -0
  76. package/src/__tests__/host-app-control-proxy.test.ts +266 -10
  77. package/src/__tests__/image-credentials.test.ts +1 -1
  78. package/src/__tests__/inbound-slack-persistence.test.ts +2 -0
  79. package/src/__tests__/inference-no-mode-boot-e2e.test.ts +1 -1
  80. package/src/__tests__/inference-profile-reaper.test.ts +4 -2
  81. package/src/__tests__/inference-profile-session-handler.test.ts +18 -6
  82. package/src/__tests__/inference-profile-session-ipc.test.ts +17 -5
  83. package/src/__tests__/injector-chain.test.ts +10 -8
  84. package/src/__tests__/install-skill-routing.test.ts +155 -37
  85. package/src/__tests__/lifecycle-memory-v2-seed.test.ts +92 -3
  86. package/src/__tests__/list-messages-page-latest.test.ts +55 -0
  87. package/src/__tests__/llm-call-pipeline.test.ts +0 -3
  88. package/src/__tests__/llm-catalog-parity.test.ts +55 -13
  89. package/src/__tests__/llm-request-log-source-clickhouse.test.ts +34 -0
  90. package/src/__tests__/llm-request-log-source-factory.test.ts +29 -53
  91. package/src/__tests__/llm-usage-store.test.ts +114 -0
  92. package/src/__tests__/managed-profile-guard.test.ts +31 -29
  93. package/src/__tests__/managed-skill-lifecycle.test.ts +109 -18
  94. package/src/__tests__/managed-store.test.ts +84 -192
  95. package/src/__tests__/media-generate-image.test.ts +1 -1
  96. package/src/__tests__/memory-retrieval-pipeline.test.ts +0 -2
  97. package/src/__tests__/messages-after-tiebreaker.test.ts +122 -0
  98. package/src/__tests__/oauth-commands-routes.test.ts +168 -16
  99. package/src/__tests__/oauth-provider-profiles.test.ts +9 -0
  100. package/src/__tests__/openai-provider.test.ts +24 -0
  101. package/src/__tests__/openai-responses-cutover-guard.test.ts +17 -9
  102. package/src/__tests__/overflow-reduce-pipeline.test.ts +0 -2
  103. package/src/__tests__/persistence-pipeline.test.ts +0 -2
  104. package/src/__tests__/{managed-proxy-context.test.ts → platform-proxy-context.test.ts} +1 -1
  105. package/src/__tests__/platform.test.ts +2 -0
  106. package/src/__tests__/plugin-api-shim.test.ts +125 -0
  107. package/src/__tests__/plugin-bootstrap.test.ts +10 -36
  108. package/src/__tests__/plugin-external-api.test.ts +68 -0
  109. package/src/__tests__/plugin-registry.test.ts +0 -77
  110. package/src/__tests__/plugin-route-contribution.test.ts +0 -1
  111. package/src/__tests__/plugin-skill-contribution.test.ts +0 -2
  112. package/src/__tests__/plugin-tool-contribution.test.ts +16 -15
  113. package/src/__tests__/plugin-types.test.ts +3 -13
  114. package/src/__tests__/process-message-background-slack.test.ts +8 -1
  115. package/src/__tests__/process-message-display-content.test.ts +421 -0
  116. package/src/__tests__/provider-catalog-visibility.test.ts +142 -0
  117. package/src/__tests__/provider-error-scenarios.test.ts +111 -0
  118. package/src/__tests__/{provider-managed-proxy-integration.test.ts → provider-platform-proxy-integration.test.ts} +8 -8
  119. package/src/__tests__/scaffold-managed-skill-tool.test.ts +65 -13
  120. package/src/__tests__/schedule-routes.test.ts +50 -3
  121. package/src/__tests__/schedule-store.test.ts +94 -0
  122. package/src/__tests__/scheduler-reuse-conversation.test.ts +54 -7
  123. package/src/__tests__/schema-transforms.test.ts +20 -0
  124. package/src/__tests__/search-skills-unified.test.ts +0 -5
  125. package/src/__tests__/server-history-render.test.ts +43 -0
  126. package/src/__tests__/skill-load-feature-flag.test.ts +0 -12
  127. package/src/__tests__/skill-load-tool.test.ts +27 -89
  128. package/src/__tests__/skill-memory.test.ts +23 -3
  129. package/src/__tests__/skills-file-content-endpoint.test.ts +9 -38
  130. package/src/__tests__/skills-files-catalog-fallback.test.ts +0 -3
  131. package/src/__tests__/skills-install-extract.test.ts +49 -38
  132. package/src/__tests__/skills-install-staging.test.ts +159 -0
  133. package/src/__tests__/skills-uninstall.test.ts +9 -41
  134. package/src/__tests__/skills.test.ts +51 -58
  135. package/src/__tests__/slack-channel-config.test.ts +9 -0
  136. package/src/__tests__/subagent-tool-filtering.test.ts +50 -0
  137. package/src/__tests__/system-prompt.test.ts +737 -63
  138. package/src/__tests__/terminal-tools.test.ts +28 -1
  139. package/src/__tests__/thread-backfill.test.ts +557 -27
  140. package/src/__tests__/title-generate-pipeline.test.ts +0 -13
  141. package/src/__tests__/token-estimate-pipeline.test.ts +0 -3
  142. package/src/__tests__/tool-error-pipeline.test.ts +0 -3
  143. package/src/__tests__/tool-execute-pipeline.test.ts +0 -5
  144. package/src/__tests__/tool-executor-lifecycle-events.test.ts +1 -1
  145. package/src/__tests__/tool-executor.test.ts +16 -4
  146. package/src/__tests__/tool-result-truncate-pipeline.test.ts +0 -12
  147. package/src/__tests__/turn-events-store.test.ts +256 -0
  148. package/src/__tests__/twilio-routes.test.ts +4 -0
  149. package/src/__tests__/user-plugin-loader.test.ts +0 -7
  150. package/src/__tests__/voice-session-bridge.test.ts +198 -0
  151. package/src/__tests__/web-search-catalog-parity.test.ts +32 -10
  152. package/src/__tests__/workspace-migration-057-repair-stale-gemini-model-ids.test.ts +115 -3
  153. package/src/__tests__/workspace-migration-072-seed-reply-suggestion-callsite.test.ts +50 -0
  154. package/src/__tests__/workspace-migration-073-repair-recall-callsite-empty-profile.test.ts +153 -0
  155. package/src/__tests__/workspace-migration-085-memory-v2-bm25-b-reembed-disabled-v2-pages.test.ts +220 -0
  156. package/src/__tests__/workspace-migration-086-revert-stale-gemini-mis-rewrites.test.ts +269 -0
  157. package/src/__tests__/workspace-migration-remove-legacy-skills-index.test.ts +309 -0
  158. package/src/__tests__/workspace-migrations-runner.test.ts +111 -3
  159. package/src/acp/resolve-agent.ts +1 -1
  160. package/src/agent/image-optimize.ts +13 -5
  161. package/src/calls/voice-session-bridge.ts +61 -42
  162. package/src/channels/types.ts +108 -0
  163. package/src/cli/__tests__/unknown-command.test.ts +24 -0
  164. package/src/cli/commands/__tests__/changelog.test.ts +304 -319
  165. package/src/cli/commands/__tests__/schedules.test.ts +491 -0
  166. package/src/cli/commands/changelog.ts +106 -42
  167. package/src/cli/commands/conversations.ts +102 -17
  168. package/src/cli/commands/default-action.ts +10 -53
  169. package/src/cli/commands/notifications.ts +329 -317
  170. package/src/cli/commands/plugins.ts +185 -0
  171. package/src/cli/commands/schedules.ts +391 -0
  172. package/src/cli/commands/telemetry.ts +40 -0
  173. package/src/cli/lib/__tests__/cli-colors.test.ts +48 -0
  174. package/src/cli/lib/__tests__/confirm-prompt.test.ts +159 -0
  175. package/src/cli/lib/__tests__/install-from-github.test.ts +355 -0
  176. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +154 -0
  177. package/src/cli/lib/__tests__/uninstall-plugin.test.ts +124 -0
  178. package/src/cli/lib/__tests__/unknown-command.test.ts +106 -0
  179. package/src/cli/lib/cli-colors.ts +12 -0
  180. package/src/cli/lib/confirm-prompt.ts +79 -0
  181. package/src/cli/lib/install-from-github.ts +304 -0
  182. package/src/cli/lib/list-installed-plugins.ts +137 -0
  183. package/src/cli/lib/uninstall-plugin.ts +82 -0
  184. package/src/cli/lib/unknown-command.ts +111 -0
  185. package/src/cli/program.ts +38 -2
  186. package/src/config/bundled-skills/app-builder/SKILL.md +23 -21
  187. package/src/config/bundled-skills/app-builder/TOOLS.json +7 -0
  188. package/src/config/bundled-skills/computer-use/TOOLS.json +15 -52
  189. package/src/config/bundled-skills/document/SKILL.md +23 -3
  190. package/src/config/bundled-skills/document/TOOLS.json +53 -0
  191. package/src/config/bundled-skills/document/tools/document-delete.ts +12 -0
  192. package/src/config/bundled-skills/document/tools/document-list.ts +12 -0
  193. package/src/config/bundled-skills/document/tools/document-read.ts +12 -0
  194. package/src/config/bundled-skills/skill-management/SKILL.md +2 -2
  195. package/src/config/bundled-skills/skill-management/TOOLS.json +7 -7
  196. package/src/config/bundled-tool-registry.ts +6 -0
  197. package/src/config/feature-flag-registry.json +41 -1
  198. package/src/config/loader.ts +64 -38
  199. package/src/config/schema.ts +7 -10
  200. package/src/config/schemas/__tests__/llm-request-logs.test.ts +36 -0
  201. package/src/config/schemas/channels.ts +8 -0
  202. package/src/config/schemas/compaction.ts +28 -0
  203. package/src/config/schemas/heartbeat.ts +9 -0
  204. package/src/config/schemas/llm-request-logs.ts +31 -7
  205. package/src/config/schemas/llm.ts +3 -0
  206. package/src/config/schemas/memory-retrieval.ts +18 -0
  207. package/src/config/schemas/tools.ts +14 -0
  208. package/src/config/skills.ts +3 -96
  209. package/src/context/compactor.ts +1047 -0
  210. package/src/context/token-estimator.ts +2 -2
  211. package/src/context/window-manager.ts +197 -1520
  212. package/src/credential-execution/managed-catalog.ts +37 -0
  213. package/src/credential-health/credential-health-service.ts +280 -19
  214. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +34 -0
  215. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +138 -0
  216. package/src/daemon/__tests__/conversation-tool-setup.test.ts +74 -0
  217. package/src/daemon/approval-generators.ts +8 -6
  218. package/src/daemon/config-watcher.ts +94 -31
  219. package/src/daemon/conversation-agent-loop.ts +169 -9
  220. package/src/daemon/conversation-error.ts +171 -37
  221. package/src/daemon/conversation-lifecycle.ts +53 -40
  222. package/src/daemon/conversation-messaging.ts +25 -6
  223. package/src/daemon/conversation-process.ts +49 -12
  224. package/src/daemon/conversation-runtime-assembly.ts +16 -1
  225. package/src/daemon/conversation-slash.ts +12 -5
  226. package/src/daemon/conversation-store.ts +11 -4
  227. package/src/daemon/conversation-tool-setup.ts +39 -7
  228. package/src/daemon/conversation.ts +33 -1
  229. package/src/daemon/external-plugins-bootstrap.ts +217 -181
  230. package/src/daemon/first-greeting.ts +22 -2
  231. package/src/daemon/handlers/config-model.ts +6 -5
  232. package/src/daemon/handlers/config-slack-channel.ts +15 -3
  233. package/src/daemon/handlers/shared.ts +14 -5
  234. package/src/daemon/handlers/skills.ts +111 -108
  235. package/src/daemon/history-repair.ts +28 -1
  236. package/src/daemon/host-app-control-proxy.ts +98 -23
  237. package/src/daemon/lifecycle.ts +45 -35
  238. package/src/daemon/meet-host-supervisor.ts +5 -4
  239. package/src/daemon/memory-v2-startup.ts +49 -0
  240. package/src/daemon/message-protocol.ts +1 -0
  241. package/src/daemon/message-types/conversations.ts +25 -0
  242. package/src/daemon/message-types/messages.ts +61 -0
  243. package/src/daemon/message-types/subagents.ts +1 -0
  244. package/src/daemon/message-types/sync.ts +1 -0
  245. package/src/daemon/pkb-reminder-builder.test.ts +1 -1
  246. package/src/daemon/pkb-reminder-builder.ts +1 -1
  247. package/src/daemon/plugin-source-watcher.ts +146 -0
  248. package/src/daemon/process-message.ts +21 -3
  249. package/src/daemon/server.ts +11 -2
  250. package/src/daemon/skill-memory-refresh.ts +29 -0
  251. package/src/documents/document-store.ts +221 -3
  252. package/src/embedded/plugin-api.ts +40 -0
  253. package/src/filing/filing-service.ts +39 -0
  254. package/src/heartbeat/__tests__/heartbeat-service.test.ts +91 -6
  255. package/src/heartbeat/heartbeat-run-store.ts +2 -1
  256. package/src/heartbeat/heartbeat-service.ts +41 -0
  257. package/src/home/__tests__/feed-types.test.ts +40 -0
  258. package/src/home/feed-types.ts +22 -0
  259. package/src/home/post-connect-feed.ts +1 -0
  260. package/src/index.ts +18 -1
  261. package/src/live-voice/__tests__/live-voice-stt.test.ts +57 -0
  262. package/src/mcp/client.ts +20 -4
  263. package/src/media/image-credentials.ts +3 -3
  264. package/src/memory/__tests__/bookmark-crud.test.ts +33 -27
  265. package/src/memory/__tests__/conversation-queries.test.ts +263 -0
  266. package/src/memory/__tests__/jobs-worker-v2-graph-trigger-embed.test.ts +113 -0
  267. package/src/memory/__tests__/memory-retrospective-startup-cleanup.test.ts +119 -14
  268. package/src/memory/__tests__/message-content.test.ts +35 -0
  269. package/src/memory/bookmark-crud.ts +42 -10
  270. package/src/memory/context-search/sources/conversations.ts +62 -2
  271. package/src/memory/context-search/sources/workspace.ts +4 -0
  272. package/src/memory/conversation-crud.ts +63 -19
  273. package/src/memory/conversation-queries.ts +110 -10
  274. package/src/memory/db-init.ts +6 -0
  275. package/src/memory/delivery-crud.ts +152 -5
  276. package/src/memory/embedding-backend.ts +4 -4
  277. package/src/memory/external-conversation-store.ts +66 -5
  278. package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +66 -9
  279. package/src/memory/graph/conversation-graph-memory.ts +31 -15
  280. package/src/memory/graph/tools.ts +3 -3
  281. package/src/memory/indexer.ts +34 -29
  282. package/src/memory/jobs/__tests__/embed-concept-page.test.ts +73 -0
  283. package/src/memory/jobs/embed-concept-page.ts +20 -11
  284. package/src/memory/jobs-worker.ts +6 -1
  285. package/src/memory/llm-request-log-source-clickhouse.ts +17 -10
  286. package/src/memory/llm-request-log-source.ts +19 -52
  287. package/src/memory/llm-usage-store.ts +125 -5
  288. package/src/memory/memory-retrospective-startup-cleanup.ts +72 -5
  289. package/src/memory/message-content.ts +1 -1
  290. package/src/memory/migrations/109-external-conversation-bindings.ts +15 -4
  291. package/src/memory/migrations/229-delete-private-conversations.test.ts +38 -1
  292. package/src/memory/migrations/229-delete-private-conversations.ts +7 -0
  293. package/src/memory/migrations/247-external-conversation-binding-thread-id.ts +78 -0
  294. package/src/memory/migrations/248-create-onboarding-events.ts +21 -0
  295. package/src/memory/migrations/249-normalize-slack-external-content.ts +240 -0
  296. package/src/memory/migrations/index.ts +6 -0
  297. package/src/memory/migrations/registry.ts +8 -0
  298. package/src/memory/onboarding-events-store.ts +106 -0
  299. package/src/memory/schema/bookmarks.ts +0 -2
  300. package/src/memory/schema/calls.ts +1 -0
  301. package/src/memory/schema/inference.ts +1 -3
  302. package/src/memory/schema/infrastructure.ts +12 -0
  303. package/src/memory/turn-events-store.ts +127 -2
  304. package/src/memory/v2/__tests__/activation.test.ts +0 -8
  305. package/src/memory/v2/__tests__/injection.test.ts +98 -8
  306. package/src/memory/v2/__tests__/migration.test.ts +87 -0
  307. package/src/memory/v2/__tests__/page-index.test.ts +83 -0
  308. package/src/memory/v2/__tests__/prompts-router.test.ts +58 -6
  309. package/src/memory/v2/__tests__/qdrant.test.ts +66 -3
  310. package/src/memory/v2/__tests__/router.test.ts +15 -0
  311. package/src/memory/v2/__tests__/skill-store.test.ts +387 -8
  312. package/src/memory/v2/injection.ts +32 -6
  313. package/src/memory/v2/migration.ts +49 -19
  314. package/src/memory/v2/page-index.ts +35 -5
  315. package/src/memory/v2/prompts/router.ts +11 -8
  316. package/src/memory/v2/prompts/sweep.ts +2 -2
  317. package/src/memory/v2/qdrant.ts +135 -7
  318. package/src/memory/v2/router.ts +9 -8
  319. package/src/memory/v2/skill-store.ts +120 -35
  320. package/src/messaging/providers/slack/__tests__/adapter-token-routing.test.ts +45 -5
  321. package/src/messaging/providers/slack/__tests__/download.test.ts +231 -0
  322. package/src/messaging/providers/slack/adapter.ts +43 -5
  323. package/src/messaging/providers/slack/client.ts +27 -0
  324. package/src/messaging/providers/slack/deep-link.ts +65 -0
  325. package/src/messaging/providers/slack/download.ts +104 -0
  326. package/src/messaging/providers/slack/message-metadata.test.ts +32 -0
  327. package/src/messaging/providers/slack/message-metadata.ts +27 -0
  328. package/src/messaging/providers/slack/render-transcript.test.ts +134 -0
  329. package/src/messaging/providers/slack/render-transcript.ts +69 -5
  330. package/src/messaging/providers/slack/types.ts +20 -1
  331. package/src/notifications/conversation-pairing.ts +2 -1
  332. package/src/notifications/decision-engine.ts +2 -1
  333. package/src/notifications/emit-signal.ts +20 -1
  334. package/src/notifications/home-feed-side-effect.ts +54 -0
  335. package/src/notifications/signal.ts +3 -1
  336. package/src/oauth/connection-resolver.ts +8 -4
  337. package/src/oauth/platform-connection.ts +6 -2
  338. package/src/oauth/seed-providers.ts +10 -1
  339. package/src/permissions/checker.ts +2 -0
  340. package/src/permissions/ipc-risk-types.ts +1 -0
  341. package/src/permissions/question-prompter.test.ts +416 -0
  342. package/src/permissions/question-prompter.ts +294 -0
  343. package/src/platform/client.test.ts +1 -1
  344. package/src/platform/client.ts +1 -1
  345. package/src/plugin-api/constants.ts +26 -0
  346. package/src/plugin-api/index.ts +34 -1
  347. package/src/plugin-api/types.ts +104 -22
  348. package/src/plugins/defaults/circuit-breaker.ts +0 -5
  349. package/src/plugins/defaults/compaction.ts +0 -4
  350. package/src/plugins/defaults/empty-response.ts +0 -2
  351. package/src/plugins/defaults/history-repair.ts +0 -2
  352. package/src/plugins/defaults/injectors.ts +36 -3
  353. package/src/plugins/defaults/llm-call.ts +0 -2
  354. package/src/plugins/defaults/memory-retrieval.ts +0 -1
  355. package/src/plugins/defaults/overflow-reduce.ts +0 -1
  356. package/src/plugins/defaults/persistence.ts +0 -2
  357. package/src/plugins/defaults/title-generate.ts +0 -5
  358. package/src/plugins/defaults/token-estimate.ts +0 -2
  359. package/src/plugins/defaults/tool-error.ts +0 -7
  360. package/src/plugins/defaults/tool-execute.ts +0 -2
  361. package/src/plugins/defaults/tool-result-truncate.ts +0 -4
  362. package/src/plugins/ensure-plugin-api-shim.ts +96 -0
  363. package/src/plugins/external-api.ts +104 -0
  364. package/src/plugins/external-plugin-loader.ts +105 -32
  365. package/src/plugins/feature-gate.ts +22 -0
  366. package/src/plugins/pipeline.ts +37 -0
  367. package/src/plugins/registry.ts +48 -80
  368. package/src/plugins/types.ts +31 -26
  369. package/src/plugins/user-loader.ts +21 -2
  370. package/src/proactive-artifact/aux-message-injector.ts +11 -0
  371. package/src/proactive-artifact/job.test.ts +37 -5
  372. package/src/prompts/__tests__/system-prompt.test.ts +12 -0
  373. package/src/prompts/__tests__/task-progress-hint-section.test.ts +99 -0
  374. package/src/prompts/normalize-onboarding.ts +27 -0
  375. package/src/prompts/sections.ts +302 -0
  376. package/src/prompts/system-prompt.ts +63 -166
  377. package/src/prompts/templates/BOOTSTRAP.md +17 -1
  378. package/src/prompts/templates/system-sections.ts +173 -0
  379. package/src/providers/__tests__/inference.test.ts +22 -7
  380. package/src/providers/anthropic/client.ts +28 -28
  381. package/src/providers/connection-resolution.ts +7 -0
  382. package/src/providers/inference/adapter-factory.ts +41 -4
  383. package/src/providers/inference/connections.ts +74 -29
  384. package/src/providers/inference/resolve-auth.ts +12 -4
  385. package/src/providers/model-catalog.ts +294 -12
  386. package/src/providers/openai/chat-completions-provider.ts +10 -2
  387. package/src/providers/openrouter/client.ts +7 -0
  388. package/src/providers/{managed-proxy → platform-proxy}/constants.ts +4 -1
  389. package/src/providers/{managed-proxy → platform-proxy}/context.ts +3 -3
  390. package/src/providers/provider-availability.ts +17 -2
  391. package/src/providers/provider-catalog-visibility.ts +36 -0
  392. package/src/providers/registry.ts +22 -14
  393. package/src/providers/retry.ts +47 -1
  394. package/src/runtime/__tests__/agent-wake.test.ts +152 -0
  395. package/src/runtime/agent-wake.ts +42 -14
  396. package/src/runtime/auth/route-policy.ts +8 -1
  397. package/src/runtime/btw-sidechain.ts +2 -0
  398. package/src/runtime/http-types.ts +19 -0
  399. package/src/runtime/migrations/origin-mode.ts +1 -1
  400. package/src/runtime/pending-interactions.ts +1 -0
  401. package/src/runtime/routes/__tests__/bookmark-routes.test.ts +17 -0
  402. package/src/runtime/routes/__tests__/conversation-management-routes.test.ts +5 -1
  403. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +107 -20
  404. package/src/runtime/routes/__tests__/question-routes.test.ts +395 -0
  405. package/src/runtime/routes/__tests__/tts-routes.test.ts +64 -1
  406. package/src/runtime/routes/acp-routes-list.test.ts +143 -0
  407. package/src/runtime/routes/acp-routes.ts +5 -3
  408. package/src/runtime/routes/auth-routes.ts +1 -1
  409. package/src/runtime/routes/bookmark-routes.ts +5 -3
  410. package/src/runtime/routes/btw-routes.ts +5 -1
  411. package/src/runtime/routes/channel-availability-routes.ts +121 -0
  412. package/src/runtime/routes/conversation-cli-routes.ts +44 -3
  413. package/src/runtime/routes/conversation-list-routes.ts +3 -20
  414. package/src/runtime/routes/conversation-management-routes.ts +17 -42
  415. package/src/runtime/routes/conversation-query-routes.ts +40 -35
  416. package/src/runtime/routes/conversation-routes.ts +90 -11
  417. package/src/runtime/routes/documents-routes.ts +25 -86
  418. package/src/runtime/routes/group-routes.ts +5 -0
  419. package/src/runtime/routes/inbound-conversation.ts +28 -8
  420. package/src/runtime/routes/inbound-message-handler.ts +236 -41
  421. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +111 -0
  422. package/src/runtime/routes/inbound-stages/background-dispatch.ts +32 -1
  423. package/src/runtime/routes/inbound-stages/edit-intercept.ts +17 -4
  424. package/src/runtime/routes/index.ts +6 -0
  425. package/src/runtime/routes/inference-profile-session-handler.ts +17 -44
  426. package/src/runtime/routes/inference-profile-session-reaper.ts +7 -21
  427. package/src/runtime/routes/inference-provider-connection-routes.ts +65 -21
  428. package/src/runtime/routes/integrations/slack/share.ts +4 -52
  429. package/src/runtime/routes/integrations/slack/token.ts +43 -0
  430. package/src/runtime/routes/integrations/twilio.ts +6 -13
  431. package/src/runtime/routes/notification-routes.ts +1 -1
  432. package/src/runtime/routes/oauth-commands-routes.ts +105 -15
  433. package/src/runtime/routes/oauth-lifecycle-routes.ts +43 -0
  434. package/src/runtime/routes/question-routes.ts +259 -0
  435. package/src/runtime/routes/rename-conversation-routes.ts +2 -33
  436. package/src/runtime/routes/schedule-routes.ts +4 -7
  437. package/src/runtime/routes/subagents-routes.ts +57 -18
  438. package/src/runtime/routes/telemetry-routes.ts +27 -0
  439. package/src/runtime/routes/tts-routes.ts +27 -2
  440. package/src/runtime/routes/workspace-routes.test.ts +43 -0
  441. package/src/runtime/routes/workspace-routes.ts +28 -0
  442. package/src/runtime/services/conversation-serializer.ts +39 -7
  443. package/src/runtime/sync/resource-sync-events.ts +93 -1
  444. package/src/schedule/schedule-store.ts +27 -2
  445. package/src/schedule/scheduler.ts +9 -1
  446. package/src/security/__tests__/untrusted-content.test.ts +86 -0
  447. package/src/security/untrusted-content.ts +93 -8
  448. package/src/skills/catalog-files.ts +1 -1
  449. package/src/skills/catalog-install.ts +233 -116
  450. package/src/skills/clawhub.ts +70 -13
  451. package/src/skills/managed-store.ts +4 -119
  452. package/src/skills/skillssh-registry.ts +27 -48
  453. package/src/subagent/manager.ts +15 -7
  454. package/src/telemetry/types.ts +113 -1
  455. package/src/telemetry/usage-telemetry-reporter.test.ts +312 -5
  456. package/src/telemetry/usage-telemetry-reporter.ts +113 -7
  457. package/src/tools/apps/executors.ts +58 -7
  458. package/src/tools/ask-question/ask-question-tool.test.ts +509 -0
  459. package/src/tools/ask-question/ask-question-tool.ts +304 -0
  460. package/src/tools/browser/browser-execution.ts +15 -11
  461. package/src/tools/computer-use/definitions.ts +3 -3
  462. package/src/tools/credentials/vault.ts +1 -1
  463. package/src/tools/document/document-tool.ts +124 -1
  464. package/src/tools/filesystem/edit.ts +1 -1
  465. package/src/tools/filesystem/list.ts +1 -1
  466. package/src/tools/filesystem/read.ts +1 -1
  467. package/src/tools/filesystem/write.ts +5 -2
  468. package/src/tools/host-filesystem/transfer.ts +1 -1
  469. package/src/tools/host-terminal/host-shell.ts +1 -1
  470. package/src/tools/permission-checker.ts +1 -1
  471. package/src/tools/registry.ts +17 -7
  472. package/src/tools/schedule/create.ts +2 -2
  473. package/src/tools/schema-transforms.ts +7 -2
  474. package/src/tools/side-effects.ts +1 -0
  475. package/src/tools/skills/delete-managed.ts +4 -4
  476. package/src/tools/skills/execute.ts +1 -1
  477. package/src/tools/skills/scaffold-managed.ts +3 -2
  478. package/src/tools/subagent/notify-parent.ts +1 -1
  479. package/src/tools/system/request-permission.ts +2 -2
  480. package/src/tools/terminal/safe-env.ts +60 -1
  481. package/src/tools/tool-manifest.ts +2 -0
  482. package/src/tools/types.ts +72 -21
  483. package/src/tools/ui-surface/definitions.ts +6 -5
  484. package/src/tts/__tests__/provider-adapters.test.ts +76 -2
  485. package/src/tts/providers/elevenlabs-provider.ts +75 -1
  486. package/src/types/onboarding-context.ts +2 -0
  487. package/src/util/errors.ts +17 -0
  488. package/src/util/platform.ts +10 -0
  489. package/src/watcher/__tests__/engine.test.ts +22 -0
  490. package/src/watcher/engine.ts +6 -2
  491. package/src/workspace/migrations/057-repair-stale-gemini-model-ids.ts +80 -15
  492. package/src/workspace/migrations/072-seed-reply-suggestion-callsite.ts +35 -22
  493. package/src/workspace/migrations/073-repair-recall-callsite-empty-profile.ts +3 -1
  494. package/src/workspace/migrations/083-system-prompt-prefix-to-file.ts +191 -0
  495. package/src/workspace/migrations/084-remove-legacy-skills-index.ts +276 -0
  496. package/src/workspace/migrations/085-memory-v2-bm25-b-reembed-disabled-v2-pages.ts +137 -0
  497. package/src/workspace/migrations/086-revert-stale-gemini-mis-rewrites.ts +198 -0
  498. package/src/workspace/migrations/registry.ts +8 -0
  499. package/src/workspace/migrations/runner.ts +39 -9
  500. package/src/workspace/migrations/types.ts +4 -0
  501. package/examples/plugins/echo/bun.lock +0 -25
  502. package/src/__tests__/context-window-manager.test.ts +0 -2481
  503. package/src/context/__tests__/compact-prompt.test.ts +0 -63
  504. package/src/context/prompts/compact.md +0 -26
  505. package/src/prompts/__tests__/build-cli-reference-section.test.ts +0 -37
  506. /package/src/__tests__/{secret-routes-managed-proxy.test.ts → secret-routes-platform-proxy.test.ts} +0 -0
@@ -15,10 +15,13 @@
15
15
  * - It swallows errors from the embedding backend — the function resolves
16
16
  * and the cache is unchanged from prior state.
17
17
  *
18
- * Hermetic by design: the catalog loader, state resolver, embedding backend,
19
- * Qdrant module, and feature-flag resolver are all module-mocked so the suite
20
- * never reaches a real backend or filesystem.
18
+ * Hermetic by design: the embedding backend, Qdrant module, and feature-flag
19
+ * resolver are module-mocked so the suite never reaches a real backend. One
20
+ * regression case uses a temp workspace to exercise disk-discovered skills.
21
21
  */
22
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
23
+ import { tmpdir } from "node:os";
24
+ import { join } from "node:path";
22
25
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
23
26
 
24
27
  import { makeMockLogger } from "../../../__tests__/helpers/mock-logger.js";
@@ -48,9 +51,15 @@ interface PruneCall {
48
51
  options?: { kind?: string };
49
52
  }
50
53
 
54
+ interface BackfillCall {
55
+ prefix: string;
56
+ kind: string;
57
+ allowedSuffixes: ReadonlySet<string>;
58
+ }
59
+
51
60
  interface TestState {
52
- catalog: SkillSummary[];
53
- resolved: ResolvedSkill[];
61
+ catalog: SkillSummary[] | null;
62
+ resolved: ResolvedSkill[] | null;
54
63
  fullCatalog: CatalogSkill[];
55
64
  fullCatalogThrows: Error | null;
56
65
  flagsEnabled: Record<string, boolean>;
@@ -60,6 +69,10 @@ interface TestState {
60
69
  upsertCalls: UpsertCall[];
61
70
  pruneCalls: PruneCall[];
62
71
  upsertThrows: Error | null;
72
+ backfillCalls: BackfillCall[];
73
+ backfillReturn: number;
74
+ backfillThrows: Error | null;
75
+ callSequence: Array<"upsert" | "prune" | "backfill">;
63
76
  }
64
77
 
65
78
  const state: TestState = {
@@ -74,6 +87,10 @@ const state: TestState = {
74
87
  upsertCalls: [],
75
88
  pruneCalls: [],
76
89
  upsertThrows: null,
90
+ backfillCalls: [],
91
+ backfillReturn: 0,
92
+ backfillThrows: null,
93
+ callSequence: [],
77
94
  };
78
95
 
79
96
  // Stub config so resolveSkillStates / mcp augmentation have something to read.
@@ -83,16 +100,39 @@ mock.module("../../../config/loader.js", () => ({
83
100
  qdrant: { url: "http://127.0.0.1:6333", vectorSize: 3, onDisk: false },
84
101
  },
85
102
  mcp: { servers: {} },
86
- skills: { entries: {}, allowBundled: null },
103
+ skills: { entries: {}, allowBundled: [] },
87
104
  }),
88
105
  }));
89
106
 
90
107
  mock.module("../../../config/skills.js", () => ({
91
- loadSkillCatalog: () => state.catalog,
108
+ loadSkillCatalog: () => state.catalog ?? [],
92
109
  }));
93
110
 
94
111
  mock.module("../../../config/skill-state.js", () => ({
95
- resolveSkillStates: () => state.resolved,
112
+ resolveSkillStates: (
113
+ catalog: SkillSummary[],
114
+ config: { skills?: { allowBundled?: string[] | null } },
115
+ ) => {
116
+ if (state.resolved) return state.resolved;
117
+ return catalog
118
+ .filter((summary) => {
119
+ const allowBundled = config.skills?.allowBundled;
120
+ return !(
121
+ summary.source === "bundled" &&
122
+ allowBundled != null &&
123
+ !allowBundled.includes(summary.id)
124
+ );
125
+ })
126
+ .map((summary) => ({
127
+ summary,
128
+ state:
129
+ summary.source === "managed" ||
130
+ summary.source === "bundled" ||
131
+ summary.source === "plugin"
132
+ ? "enabled"
133
+ : "disabled",
134
+ }));
135
+ },
96
136
  }));
97
137
 
98
138
  mock.module("../../../config/assistant-feature-flags.js", () => ({
@@ -115,6 +155,7 @@ mock.module("../../embedding-backend.js", () => ({
115
155
  mock.module("../qdrant.js", () => ({
116
156
  upsertConceptPageEmbedding: async (params: UpsertCall) => {
117
157
  if (state.upsertThrows) throw state.upsertThrows;
158
+ state.callSequence.push("upsert");
118
159
  state.upsertCalls.push(params);
119
160
  },
120
161
  pruneSlugsWithPrefixExcept: async (
@@ -122,8 +163,19 @@ mock.module("../qdrant.js", () => ({
122
163
  activeSuffixes: readonly string[],
123
164
  options?: { kind?: string },
124
165
  ) => {
166
+ state.callSequence.push("prune");
125
167
  state.pruneCalls.push({ prefix, activeSuffixes, options });
126
168
  },
169
+ backfillKindOnPointsWithPrefix: async (
170
+ prefix: string,
171
+ kind: string,
172
+ allowedSuffixes: ReadonlySet<string>,
173
+ ) => {
174
+ if (state.backfillThrows) throw state.backfillThrows;
175
+ state.callSequence.push("backfill");
176
+ state.backfillCalls.push({ prefix, kind, allowedSuffixes });
177
+ return state.backfillReturn;
178
+ },
127
179
  }));
128
180
 
129
181
  mock.module("../../../skills/catalog-cache.js", () => ({
@@ -170,6 +222,10 @@ function resetState(): void {
170
222
  state.upsertCalls.length = 0;
171
223
  state.pruneCalls.length = 0;
172
224
  state.upsertThrows = null;
225
+ state.backfillCalls.length = 0;
226
+ state.backfillReturn = 0;
227
+ state.backfillThrows = null;
228
+ state.callSequence.length = 0;
173
229
  _resetSkillStoreForTests();
174
230
  }
175
231
 
@@ -390,6 +446,154 @@ describe("seedV2SkillEntries", () => {
390
446
  expect(getSkillCapability("skills/unknown-skill")).toBeNull();
391
447
  });
392
448
 
449
+ test("skips stale in-flight seed results when a newer refresh is requested", async () => {
450
+ const skillA = makeSummary({
451
+ id: "example-skill-a",
452
+ displayName: "Skill A",
453
+ });
454
+ const skillB = makeSummary({
455
+ id: "example-skill-b",
456
+ displayName: "Skill B",
457
+ });
458
+ state.catalog = [skillA];
459
+ state.resolved = [{ summary: skillA, state: "enabled" }];
460
+ state.embedReturn = [[0.1, 0.2, 0.3]];
461
+
462
+ const firstSeed = seedV2SkillEntries();
463
+ state.catalog = [skillB];
464
+ state.resolved = [{ summary: skillB, state: "enabled" }];
465
+ const secondSeed = seedV2SkillEntries();
466
+
467
+ await Promise.all([firstSeed, secondSeed]);
468
+
469
+ expect(state.upsertCalls.map((call) => call.slug)).toEqual([
470
+ "skills/example-skill-b",
471
+ ]);
472
+ expect(getSkillCapability("example-skill-a")).toBeNull();
473
+ expect(getSkillCapability("example-skill-b")?.content).toContain("Skill B");
474
+ });
475
+
476
+ test("continues draining when waiter continuations enqueue additional generations", async () => {
477
+ const skillA = makeSummary({
478
+ id: "example-skill-a",
479
+ displayName: "Skill A",
480
+ });
481
+ const skillB = makeSummary({
482
+ id: "example-skill-b",
483
+ displayName: "Skill B",
484
+ });
485
+ const skillC = makeSummary({
486
+ id: "example-skill-c",
487
+ displayName: "Skill C",
488
+ });
489
+
490
+ function useSkill(skill: SkillSummary, dense: number[]): void {
491
+ state.catalog = [skill];
492
+ state.resolved = [{ summary: skill, state: "enabled" }];
493
+ state.embedReturn = [dense];
494
+ }
495
+
496
+ useSkill(skillA, [0.1, 0.2, 0.3]);
497
+ const firstSeed = seedV2SkillEntries();
498
+ const secondSeed = firstSeed.then(() => {
499
+ useSkill(skillB, [0.4, 0.5, 0.6]);
500
+ return seedV2SkillEntries();
501
+ });
502
+ const thirdSeed = secondSeed.then(() => {
503
+ useSkill(skillC, [0.7, 0.8, 0.9]);
504
+ return seedV2SkillEntries();
505
+ });
506
+
507
+ let timeout: ReturnType<typeof setTimeout> | undefined;
508
+ try {
509
+ await expect(
510
+ Promise.race([
511
+ Promise.all([firstSeed, secondSeed, thirdSeed]),
512
+ new Promise<never>((_, reject) => {
513
+ timeout = setTimeout(
514
+ () => reject(new Error("seed queue stalled")),
515
+ 500,
516
+ );
517
+ }),
518
+ ]),
519
+ ).resolves.toBeDefined();
520
+ } finally {
521
+ if (timeout) clearTimeout(timeout);
522
+ }
523
+
524
+ expect(state.upsertCalls.map((call) => call.slug)).toEqual([
525
+ "skills/example-skill-a",
526
+ "skills/example-skill-b",
527
+ "skills/example-skill-c",
528
+ ]);
529
+ expect(getSkillCapability("example-skill-a")).toBeNull();
530
+ expect(getSkillCapability("example-skill-b")).toBeNull();
531
+ expect(getSkillCapability("example-skill-c")?.content).toContain("Skill C");
532
+ });
533
+
534
+ test("seeds disk-discovered managed skills omitted from a stale SKILLS.md index", async () => {
535
+ const workspaceDir = mkdtempSync(join(tmpdir(), "skill-store-index-"));
536
+ state.resolved = null;
537
+ state.embedReturn = [[0.7, 0.8, 0.9]];
538
+
539
+ try {
540
+ const skillsDir = join(workspaceDir, "skills");
541
+ const skillDir = join(skillsDir, "geo-article-writer");
542
+ mkdirSync(skillDir, { recursive: true });
543
+ writeFileSync(join(skillsDir, "SKILLS.md"), "- stale-only\n", "utf-8");
544
+ writeFileSync(
545
+ join(skillDir, "SKILL.md"),
546
+ `---
547
+ name: "Geo Article Writer"
548
+ description: "Writes local geo articles"
549
+ metadata:
550
+ vellum:
551
+ activation-hints:
552
+ - user asks for local article drafts
553
+ avoid-when:
554
+ - user only wants citation extraction
555
+ ---
556
+
557
+ Write a local article draft.
558
+ `,
559
+ "utf-8",
560
+ );
561
+
562
+ state.catalog = [
563
+ {
564
+ id: "geo-article-writer",
565
+ name: "Geo Article Writer",
566
+ displayName: "Geo Article Writer",
567
+ description: "Writes local geo articles",
568
+ directoryPath: skillDir,
569
+ skillFilePath: join(skillDir, "SKILL.md"),
570
+ source: "managed",
571
+ activationHints: ["user asks for local article drafts"],
572
+ avoidWhen: ["user only wants citation extraction"],
573
+ },
574
+ ];
575
+
576
+ await seedV2SkillEntries();
577
+ } finally {
578
+ rmSync(workspaceDir, { recursive: true, force: true });
579
+ }
580
+
581
+ expect(state.upsertCalls).toHaveLength(1);
582
+ expect(state.upsertCalls[0].slug).toBe("skills/geo-article-writer");
583
+
584
+ const entry = getSkillCapability("geo-article-writer");
585
+ expect(entry).not.toBeNull();
586
+ expect(entry?.id).toBe("geo-article-writer");
587
+ expect(entry?.content).toContain('The "Geo Article Writer" skill');
588
+ expect(entry?.content).toContain("Writes local geo articles");
589
+ expect(entry?.content).toContain(
590
+ "Use when: user asks for local article drafts.",
591
+ );
592
+ expect(entry?.content).toContain(
593
+ "Avoid when: user only wants citation extraction.",
594
+ );
595
+ });
596
+
393
597
  test("swallows errors from embedWithBackend and leaves prior cache intact", async () => {
394
598
  const skillA = makeSummary({ id: "example-skill-a" });
395
599
  state.catalog = [skillA];
@@ -444,6 +648,118 @@ describe("seedV2SkillEntries", () => {
444
648
  expect([...state.pruneCalls[0].activeSuffixes]).toEqual(["remote-only"]);
445
649
  });
446
650
 
651
+ test("passes kind: 'skill' to upsert and prune so legacy skill rows stay scoped to the skill kind", async () => {
652
+ const skillA = makeSummary({ id: "example-skill-a" });
653
+ state.catalog = [skillA];
654
+ state.resolved = [{ summary: skillA, state: "enabled" }];
655
+ state.fullCatalog = [
656
+ { id: "example-skill-a", name: "example-skill-a", description: "A" },
657
+ ];
658
+ state.embedReturn = [[0.1, 0.2, 0.3]];
659
+
660
+ await seedV2SkillEntries();
661
+
662
+ expect(state.upsertCalls).toHaveLength(1);
663
+ expect(state.upsertCalls[0].kind).toBe("skill");
664
+ expect(state.pruneCalls).toHaveLength(1);
665
+ expect(state.pruneCalls[0].options).toEqual({ kind: "skill" });
666
+ });
667
+
668
+ test("runs the legacy kind backfill before pruning so kindless skill points become prunable", async () => {
669
+ // Simulates an install carrying legacy skill points written before the
670
+ // kind discriminator existed: the backfill must run before prune so the
671
+ // kind-scoped prune can see and delete the orphans.
672
+ const skillA = makeSummary({ id: "example-skill-a" });
673
+ state.catalog = [skillA];
674
+ state.resolved = [{ summary: skillA, state: "enabled" }];
675
+ state.fullCatalog = [
676
+ { id: "example-skill-a", name: "example-skill-a", description: "A" },
677
+ ];
678
+ state.embedReturn = [[0.1, 0.2, 0.3]];
679
+ state.backfillReturn = 3;
680
+
681
+ await seedV2SkillEntries();
682
+
683
+ expect(state.backfillCalls).toHaveLength(1);
684
+ expect(state.backfillCalls[0].prefix).toBe("skills/");
685
+ expect(state.backfillCalls[0].kind).toBe("skill");
686
+ expect([...state.backfillCalls[0].allowedSuffixes].sort()).toEqual([
687
+ "example-skill-a",
688
+ ]);
689
+ expect(state.pruneCalls).toHaveLength(1);
690
+ expect(state.pruneCalls[0].options).toEqual({ kind: "skill" });
691
+ expect(state.callSequence.filter((s) => s !== "upsert")).toEqual([
692
+ "backfill",
693
+ "prune",
694
+ ]);
695
+ });
696
+
697
+ test("backfill only runs once per process across repeated seed runs", async () => {
698
+ const skillA = makeSummary({ id: "example-skill-a" });
699
+ state.catalog = [skillA];
700
+ state.resolved = [{ summary: skillA, state: "enabled" }];
701
+ state.fullCatalog = [
702
+ { id: "example-skill-a", name: "example-skill-a", description: "A" },
703
+ ];
704
+ state.embedReturn = [[0.1, 0.2, 0.3]];
705
+
706
+ await seedV2SkillEntries();
707
+ expect(state.backfillCalls).toHaveLength(1);
708
+
709
+ // A second seed should not re-scan: new upserts already carry kind, so
710
+ // there's nothing for the backfill to do.
711
+ state.embedReturn = [[0.1, 0.2, 0.3]];
712
+ await seedV2SkillEntries();
713
+ expect(state.backfillCalls).toHaveLength(1);
714
+ expect(state.pruneCalls).toHaveLength(2);
715
+ });
716
+
717
+ test("backfill failure is non-fatal — prune still runs and lastSeedError stays clean", async () => {
718
+ const skillA = makeSummary({ id: "example-skill-a" });
719
+ state.catalog = [skillA];
720
+ state.resolved = [{ summary: skillA, state: "enabled" }];
721
+ state.fullCatalog = [
722
+ { id: "example-skill-a", name: "example-skill-a", description: "A" },
723
+ ];
724
+ state.embedReturn = [[0.1, 0.2, 0.3]];
725
+ state.backfillThrows = new Error("qdrant scroll exploded");
726
+
727
+ await expect(
728
+ seedV2SkillEntries({ throwOnError: true }),
729
+ ).resolves.toBeUndefined();
730
+
731
+ // Prune still ran despite the backfill failure — we don't want to block
732
+ // the steady-state prune when the legacy scan trips.
733
+ expect(state.pruneCalls).toHaveLength(1);
734
+ });
735
+
736
+ test("backfill allowlist spans installed + remote catalog ids so user-authored skills/* pages stay untagged", async () => {
737
+ // Regression: backfilling kind on every `skills/*` point would also tag
738
+ // user-authored concept pages slugged like `skills/my-notes` — those
739
+ // would then be pruned as stale skills. The allowlist must contain
740
+ // every legitimate skill id we know about (installed + remote catalog)
741
+ // and nothing else.
742
+ const installed = makeSummary({ id: "installed-skill" });
743
+ state.catalog = [installed];
744
+ state.resolved = [{ summary: installed, state: "enabled" }];
745
+ state.fullCatalog = [
746
+ { id: "installed-skill", name: "installed-skill", description: "X" },
747
+ { id: "remote-only-skill", name: "remote-only-skill", description: "Y" },
748
+ ];
749
+ state.embedReturn = [
750
+ [0.1, 0.2, 0.3],
751
+ [0.4, 0.5, 0.6],
752
+ ];
753
+
754
+ await seedV2SkillEntries();
755
+
756
+ expect(state.backfillCalls).toHaveLength(1);
757
+ expect([...state.backfillCalls[0].allowedSuffixes].sort()).toEqual([
758
+ "installed-skill",
759
+ "remote-only-skill",
760
+ ]);
761
+ });
762
+
447
763
  test("skips pruning when catalog fetch returns empty (network failure guard)", async () => {
448
764
  const skillA = makeSummary({ id: "example-skill-a" });
449
765
  state.catalog = [skillA];
@@ -473,6 +789,38 @@ describe("getSkillCapability", () => {
473
789
 
474
790
  expect(getSkillCapability("does-not-exist")).toBeNull();
475
791
  });
792
+
793
+ test("mutating the returned entry does not corrupt the cache", async () => {
794
+ const skillA = makeSummary({ id: "example-skill-a" });
795
+ state.catalog = [skillA];
796
+ state.resolved = [{ summary: skillA, state: "enabled" }];
797
+ state.embedReturn = [[0.1, 0.2, 0.3]];
798
+
799
+ await seedV2SkillEntries();
800
+
801
+ const first = getSkillCapability("example-skill-a");
802
+ expect(first).not.toBeNull();
803
+ const originalContent = first!.content;
804
+
805
+ // Frozen entries throw in strict mode when mutated; suppress so we can
806
+ // prove cache invariance even if a future refactor swaps freeze for a
807
+ // plain clone.
808
+ try {
809
+ (first as unknown as { id: string }).id = "tampered";
810
+ (first as unknown as { content: string }).content = "tampered";
811
+ } catch {
812
+ // expected under Object.freeze
813
+ }
814
+
815
+ const second = getSkillCapability("example-skill-a");
816
+ expect(second?.id).toBe("example-skill-a");
817
+ expect(second?.content).toBe(originalContent);
818
+
819
+ // listSkillEntries path also unaffected.
820
+ const viaList = listSkillEntries();
821
+ expect(viaList[0].id).toBe("example-skill-a");
822
+ expect(viaList[0].content).toBe(originalContent);
823
+ });
476
824
  });
477
825
 
478
826
  describe("listSkillEntries", () => {
@@ -521,4 +869,35 @@ describe("listSkillEntries", () => {
521
869
  expect(second).toHaveLength(1);
522
870
  expect(second[0].id).toBe("example-skill-a");
523
871
  });
872
+
873
+ test("mutating a returned entry does not corrupt the cache", async () => {
874
+ const skillA = makeSummary({ id: "example-skill-a" });
875
+ state.catalog = [skillA];
876
+ state.resolved = [{ summary: skillA, state: "enabled" }];
877
+ state.embedReturn = [[0.1, 0.2, 0.3]];
878
+
879
+ await seedV2SkillEntries();
880
+
881
+ const first = listSkillEntries();
882
+ expect(first).toHaveLength(1);
883
+ const originalContent = first[0].content;
884
+
885
+ // Frozen entries throw in strict mode (ESM tests are strict) when
886
+ // mutated; suppress so we can prove cache invariance even if a future
887
+ // refactor swaps freeze for a plain clone.
888
+ try {
889
+ (first[0] as { id: string }).id = "tampered";
890
+ (first[0] as { content: string }).content = "tampered";
891
+ } catch {
892
+ // expected under Object.freeze
893
+ }
894
+
895
+ const second = listSkillEntries();
896
+ expect(second[0].id).toBe("example-skill-a");
897
+ expect(second[0].content).toBe(originalContent);
898
+
899
+ // Lookup-by-id path also unaffected.
900
+ const viaLookup = getSkillCapability("example-skill-a");
901
+ expect(viaLookup?.content).toBe(originalContent);
902
+ });
524
903
  });
@@ -144,6 +144,7 @@ export async function injectMemoryV2Block(
144
144
  } = params;
145
145
 
146
146
  const workspaceDir = getWorkspaceDir();
147
+ const mode: InjectMemoryV2Mode = params.mode ?? "per-turn";
147
148
 
148
149
  // (1) Hydrate. Missing rows are normal at conversation start — proceed
149
150
  // with an effective empty prior state so the first turn can still inject.
@@ -151,10 +152,17 @@ export async function injectMemoryV2Block(
151
152
  const priorState = await hydrate(database, conversationId);
152
153
 
153
154
  // Flag-gated router dispatch: when the LLM router is enabled, route the
154
- // per-turn page selection through `runRouter` and reuse `finalizeInjection`
155
- // for persistence, render, and telemetry. The activation pipeline below
155
+ // page selection through `runRouter` and reuse `finalizeInjection` for
156
+ // persistence, render, and telemetry. The activation pipeline below
156
157
  // remains the default (flag-off) behavior — every code path past this
157
158
  // branch only runs when the router is disabled.
159
+ //
160
+ // Runs on both `per-turn` and `context-load`. The `everInjected` dedupe
161
+ // concern from earlier doesn't apply post-compaction because
162
+ // `evictCompactedTurnsV2` in `ConversationGraphMemory.onCompacted`
163
+ // empties the list before this code runs. Router abstention on
164
+ // context-load means no v2 pages restored that turn, which is preferable
165
+ // to letting the activation graph pick something arbitrary.
158
166
  if (config.memory.v2.router.enabled) {
159
167
  return injectViaRouter({
160
168
  workspaceDir,
@@ -214,7 +222,6 @@ export async function injectMemoryV2Block(
214
222
  // prior cached attachments don't exist or have been thrown away. The user
215
223
  // message gets a complete top-K dump alongside the static
216
224
  // essentials/threads/recent block, then per-turn turns just add deltas.
217
- const mode: InjectMemoryV2Mode = params.mode ?? "per-turn";
218
225
  const priorEverInjected: readonly EverInjectedEntry[] =
219
226
  priorState?.everInjected ?? [];
220
227
  const { topNow, toInject } = selectInjections({
@@ -310,6 +317,14 @@ async function finalizeInjection(args: {
310
317
  telemetryRows: MemoryV2ConceptRowRecord[];
311
318
  config: AssistantConfig;
312
319
  nextStateMap: Record<string, number>;
320
+ /**
321
+ * When true, errors thrown inside the helper (save / render / status
322
+ * finalization) are logged and swallowed instead of re-thrown. Used by
323
+ * the router-failure path, which is already a best-effort cleanup: a
324
+ * transient SQLite write here must not abort the turn on top of the
325
+ * router failure that already happened. Defaults to throwing.
326
+ */
327
+ bestEffort?: boolean;
313
328
  }): Promise<InjectMemoryV2BlockResult> {
314
329
  const {
315
330
  workspaceDir,
@@ -449,9 +464,16 @@ async function finalizeInjection(args: {
449
464
  } catch (err) {
450
465
  // Stash the error and let `finally` flush a best-effort telemetry row
451
466
  // before we re-throw to the caller. `mode = "errored"` flags the row
452
- // for observability dashboards / inspector queries.
467
+ // for observability dashboards / inspector queries. On the best-effort
468
+ // path the error is logged and swallowed so the trailing return stands.
453
469
  caughtErr = err;
454
470
  mode = "errored";
471
+ if (args.bestEffort) {
472
+ log.warn(
473
+ { err, conversationId, turn: currentTurn },
474
+ "Memory v2 finalizeInjection error on best-effort path — swallowing",
475
+ );
476
+ }
455
477
  } finally {
456
478
  try {
457
479
  recordMemoryV2ActivationLog({
@@ -469,7 +491,7 @@ async function finalizeInjection(args: {
469
491
  }
470
492
  }
471
493
 
472
- if (caughtErr !== undefined) throw caughtErr;
494
+ if (caughtErr !== undefined && !args.bestEffort) throw caughtErr;
473
495
  return { block, toInject: newlyInjected };
474
496
  }
475
497
 
@@ -537,7 +559,10 @@ async function injectViaRouter(args: {
537
559
  // (preserving `priorEverInjected` so future turns still subtract
538
560
  // previously-attached slugs) and writes the telemetry row through the
539
561
  // same code path as the success branch — no inline duplication of
540
- // `save` + `recordMemoryV2ActivationLog`.
562
+ // `save` + `recordMemoryV2ActivationLog`. `bestEffort: true` matches
563
+ // the pre-refactor inline behavior of logging and continuing if the
564
+ // stub-state `save()` throws — we don't want a transient SQLite write
565
+ // to abort the turn on top of the router failure that already happened.
541
566
  return finalizeInjection({
542
567
  workspaceDir,
543
568
  database,
@@ -550,6 +575,7 @@ async function injectViaRouter(args: {
550
575
  telemetryRows: [],
551
576
  config,
552
577
  nextStateMap: {},
578
+ bestEffort: true,
553
579
  });
554
580
  }
555
581
 
@@ -643,12 +643,15 @@ export async function runMemoryV2Migration(
643
643
  }
644
644
 
645
645
  /**
646
- * HTML marker embedded in each appended block so a crash-recovery rerun can
647
- * detect already-applied promotions and skip the append. `appendLines` is a
648
- * read-modify-write without this, a crash between `appendPromotions` and
649
- * `writeSentinel` would let the next boot duplicate every promotion line.
646
+ * Paired HTML markers wrapped around each appended block. The opening marker
647
+ * also serves as the idempotency guard: `appendLines` is a read-modify-write,
648
+ * and without it a crash between `appendPromotions` and `writeSentinel` would
649
+ * let the next boot duplicate every promotion line. The closing marker
650
+ * delimits the migration-inserted region so a force-rerun strip can remove
651
+ * exactly that block without touching user/assistant edits appended below.
650
652
  */
651
- const PROMOTION_MARKER = "<!-- migration:v1-to-v2 -->";
653
+ const PROMOTION_MARKER_OPEN = "<!-- migration:v1-to-v2 -->";
654
+ const PROMOTION_MARKER_CLOSE = "<!-- /migration:v1-to-v2 -->";
652
655
 
653
656
  /**
654
657
  * Append each promotion bucket to its target file. Files are created if
@@ -680,8 +683,8 @@ async function appendPromotions(
680
683
 
681
684
  /**
682
685
  * Append `lines` to `path`, creating it (with a trailing newline) if absent.
683
- * If the file already contains `PROMOTION_MARKER`, the append is skipped — a
684
- * prior partially-completed migration already wrote this block.
686
+ * If the file already contains `PROMOTION_MARKER_OPEN`, the append is skipped
687
+ * — a prior partially-completed migration already wrote this block.
685
688
  */
686
689
  async function appendLines(path: string, lines: string[]): Promise<void> {
687
690
  let existing = "";
@@ -690,16 +693,17 @@ async function appendLines(path: string, lines: string[]): Promise<void> {
690
693
  } catch (err) {
691
694
  if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
692
695
  }
693
- if (existing.includes(PROMOTION_MARKER)) return;
696
+ if (existing.includes(PROMOTION_MARKER_OPEN)) return;
694
697
  const trailing = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
695
- const next = `${existing}${trailing}${PROMOTION_MARKER}\n${lines.join("\n")}\n`;
698
+ const block = `${PROMOTION_MARKER_OPEN}\n${lines.join("\n")}\n${PROMOTION_MARKER_CLOSE}\n`;
699
+ const next = `${existing}${trailing}${block}`;
696
700
  await writeFile(path, next, "utf-8");
697
701
  }
698
702
 
699
703
  /**
700
- * Strip any prior migration-block (everything from the marker line through
701
- * end of file) from each promotion target. Called on force-reruns so the
702
- * marker guard in `appendLines` doesn't skip the new promotions.
704
+ * Strip any prior migration-block from each promotion target. Called on
705
+ * force-reruns so the marker guard in `appendLines` doesn't skip the new
706
+ * promotions.
703
707
  */
704
708
  async function stripPromotionMarkerBlocks(workspaceDir: string): Promise<void> {
705
709
  const memoryDir = join(workspaceDir, "memory");
@@ -718,6 +722,16 @@ async function stripPromotionMarkerBlocks(workspaceDir: string): Promise<void> {
718
722
  await Promise.all(candidates.map(stripMarkerBlock));
719
723
  }
720
724
 
725
+ /**
726
+ * Remove the migration-inserted block from `path` while preserving content
727
+ * outside it. The block is identified by the
728
+ * `PROMOTION_MARKER_OPEN ... PROMOTION_MARKER_CLOSE` envelope.
729
+ *
730
+ * If an opening marker is present without a matching close, strip from the
731
+ * opening marker to the next blank line, or to EOF if none is found. Content
732
+ * appended after such an unclosed block without a blank-line separator can
733
+ * be dropped on that fallback path.
734
+ */
721
735
  async function stripMarkerBlock(path: string): Promise<void> {
722
736
  let existing: string;
723
737
  try {
@@ -726,13 +740,29 @@ async function stripMarkerBlock(path: string): Promise<void> {
726
740
  if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
727
741
  throw err;
728
742
  }
729
- const idx = existing.indexOf(PROMOTION_MARKER);
730
- if (idx === -1) return;
731
- // Cut from the start of the marker line. `idx` already points at the marker,
732
- // which `appendLines` always wrote at the start of its own line, so a plain
733
- // slice here also drops the leading newline that preceded it (if any).
734
- const trimmed = existing.slice(0, idx).replace(/\n+$/, "");
735
- const next = trimmed.length === 0 ? "" : `${trimmed}\n`;
743
+ const openIdx = existing.indexOf(PROMOTION_MARKER_OPEN);
744
+ if (openIdx === -1) return;
745
+
746
+ let endIdx: number;
747
+ const closeIdx = existing.indexOf(PROMOTION_MARKER_CLOSE, openIdx);
748
+ if (closeIdx !== -1) {
749
+ endIdx = closeIdx + PROMOTION_MARKER_CLOSE.length;
750
+ if (existing[endIdx] === "\n") endIdx += 1;
751
+ } else {
752
+ const blankIdx = existing.indexOf("\n\n", openIdx);
753
+ endIdx = blankIdx === -1 ? existing.length : blankIdx + 2;
754
+ }
755
+
756
+ const head = existing.slice(0, openIdx).replace(/\n+$/, "");
757
+ const tail = existing.slice(endIdx);
758
+ let next: string;
759
+ if (head.length === 0) {
760
+ next = tail;
761
+ } else if (tail.length === 0) {
762
+ next = `${head}\n`;
763
+ } else {
764
+ next = `${head}\n${tail}`;
765
+ }
736
766
  await writeFile(path, next, "utf-8");
737
767
  }
738
768