@vellumai/assistant 0.7.2 → 0.8.0

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 (424) hide show
  1. package/ARCHITECTURE.md +45 -29
  2. package/Dockerfile +1 -0
  3. package/__tests__/permissions/gateway-threshold-reader.test.ts +236 -9
  4. package/bun.lock +3 -0
  5. package/docs/architecture/memory.md +5 -2
  6. package/knip.json +1 -0
  7. package/node_modules/@vellumai/gateway-client/src/ipc-client.ts +13 -4
  8. package/node_modules/@vellumai/ipc-server-utils/bun.lock +24 -0
  9. package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
  10. package/node_modules/@vellumai/ipc-server-utils/src/index.ts +6 -0
  11. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +430 -0
  12. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +221 -0
  13. package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
  14. package/node_modules/@vellumai/skill-host-contracts/src/assistant-event.ts +0 -9
  15. package/node_modules/@vellumai/slack-text/src/index.test.ts +18 -35
  16. package/node_modules/@vellumai/slack-text/src/index.ts +2 -48
  17. package/openapi.yaml +470 -25
  18. package/package.json +3 -1
  19. package/src/__tests__/annotate-risk-options.test.ts +291 -0
  20. package/src/__tests__/app-control-flow.test.ts +21 -11
  21. package/src/__tests__/approval-cascade.test.ts +8 -16
  22. package/src/__tests__/approval-routes-http.test.ts +6 -0
  23. package/src/__tests__/assistant-event-hub.test.ts +48 -0
  24. package/src/__tests__/assistant-event.test.ts +0 -10
  25. package/src/__tests__/assistant-events-sse-hardening.test.ts +2 -7
  26. package/src/__tests__/assistant-feature-flags-integration.test.ts +18 -0
  27. package/src/__tests__/auto-analysis-end-to-end.test.ts +48 -0
  28. package/src/__tests__/background-workers-disk-pressure.test.ts +268 -0
  29. package/src/__tests__/call-constants.test.ts +10 -1
  30. package/src/__tests__/call-controller.test.ts +127 -0
  31. package/src/__tests__/call-conversation-messages.test.ts +8 -2
  32. package/src/__tests__/channel-inbound-disk-pressure.test.ts +537 -0
  33. package/src/__tests__/channel-readiness-service.test.ts +4 -2
  34. package/src/__tests__/cli-memory-v2-reembed-skills.test.ts +58 -28
  35. package/src/__tests__/config-loader-backfill.test.ts +379 -0
  36. package/src/__tests__/config-loader-platform-defaults.test.ts +284 -1
  37. package/src/__tests__/config-schema.test.ts +1 -0
  38. package/src/__tests__/config-watcher-cleanup-throttle.test.ts +18 -9
  39. package/src/__tests__/config-watcher.test.ts +140 -69
  40. package/src/__tests__/context-search-agent-runner.test.ts +61 -3
  41. package/src/__tests__/context-search-conversations-source.test.ts +0 -24
  42. package/src/__tests__/context-search-fanout.test.ts +0 -1
  43. package/src/__tests__/context-search-memory-source.test.ts +6 -33
  44. package/src/__tests__/context-search-memory-v2-source.test.ts +0 -2
  45. package/src/__tests__/context-search-pkb-source.test.ts +12 -7
  46. package/src/__tests__/context-search-workspace-source.test.ts +0 -1
  47. package/src/__tests__/conversation-abort-tool-results.test.ts +1 -0
  48. package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +223 -0
  49. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
  50. package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -1
  51. package/src/__tests__/conversation-agent-loop.test.ts +457 -8
  52. package/src/__tests__/conversation-confirmation-signals.test.ts +5 -13
  53. package/src/__tests__/conversation-error.test.ts +150 -3
  54. package/src/__tests__/conversation-init.benchmark.test.ts +1 -1
  55. package/src/__tests__/conversation-process-callsite.test.ts +38 -0
  56. package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -0
  57. package/src/__tests__/conversation-runtime-assembly.test.ts +74 -0
  58. package/src/__tests__/conversation-slash-unknown.test.ts +1 -0
  59. package/src/__tests__/conversation-speed-override.test.ts +0 -3
  60. package/src/__tests__/conversation-store.test.ts +0 -18
  61. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +170 -9
  62. package/src/__tests__/conversation-surfaces-app-control.test.ts +15 -4
  63. package/src/__tests__/conversation-surfaces-data-persist.test.ts +476 -0
  64. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +61 -5
  65. package/src/__tests__/conversation-workspace-injection.test.ts +1 -1
  66. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
  67. package/src/__tests__/credentials-cli.test.ts +7 -0
  68. package/src/__tests__/cu-unified-flow.test.ts +176 -10
  69. package/src/__tests__/date-context.test.ts +164 -2
  70. package/src/__tests__/disk-pressure-guard.test.ts +262 -0
  71. package/src/__tests__/disk-pressure-lifecycle.test.ts +168 -0
  72. package/src/__tests__/disk-pressure-policy.test.ts +241 -0
  73. package/src/__tests__/disk-pressure-routes.test.ts +379 -0
  74. package/src/__tests__/disk-pressure-tools.test.ts +277 -0
  75. package/src/__tests__/disk-usage.test.ts +150 -0
  76. package/src/__tests__/events-client-registration.test.ts +52 -0
  77. package/src/__tests__/events-dev-bypass-actor.test.ts +162 -0
  78. package/src/__tests__/file-write-tool.test.ts +4 -10
  79. package/src/__tests__/filing-service.test.ts +2 -20
  80. package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +10 -26
  81. package/src/__tests__/heartbeat-disk-pressure.test.ts +183 -0
  82. package/src/__tests__/heartbeat-service.test.ts +260 -11
  83. package/src/__tests__/host-app-control-proxy.test.ts +195 -25
  84. package/src/__tests__/host-bash-proxy.test.ts +227 -34
  85. package/src/__tests__/host-bash-routes.test.ts +178 -13
  86. package/src/__tests__/host-cu-proxy.test.ts +210 -3
  87. package/src/__tests__/host-cu-routes-targeted.test.ts +141 -12
  88. package/src/__tests__/host-file-proxy-targeted.test.ts +48 -9
  89. package/src/__tests__/host-file-proxy.test.ts +268 -6
  90. package/src/__tests__/host-file-routes-targeted.test.ts +175 -17
  91. package/src/__tests__/host-transfer-proxy-targeted.test.ts +408 -59
  92. package/src/__tests__/host-transfer-routes-targeted.test.ts +232 -17
  93. package/src/__tests__/http-user-message-parity.test.ts +107 -1
  94. package/src/__tests__/injector-chain.test.ts +36 -16
  95. package/src/__tests__/injector-disk-pressure.test.ts +224 -0
  96. package/src/__tests__/injector-pkb-v2-silenced.test.ts +10 -7
  97. package/src/__tests__/lifecycle-memory-v2-seed.test.ts +154 -67
  98. package/src/__tests__/managed-profile-guard.test.ts +18 -0
  99. package/src/__tests__/mcp-abort-signal.test.ts +130 -0
  100. package/src/__tests__/memory-admin-recall.test.ts +3 -11
  101. package/src/__tests__/memory-retrieval-pipeline.test.ts +22 -1
  102. package/src/__tests__/normalize-onboarding.test.ts +180 -0
  103. package/src/__tests__/notification-decision-fallback.test.ts +91 -0
  104. package/src/__tests__/notification-decision-strategy.test.ts +22 -0
  105. package/src/__tests__/oauth-cli.test.ts +121 -0
  106. package/src/__tests__/oauth-connect-routes.test.ts +316 -0
  107. package/src/__tests__/oauth-provider-seed-logos.test.ts +24 -2
  108. package/src/__tests__/onboarding-persona-write.test.ts +308 -0
  109. package/src/__tests__/openai-provider.test.ts +45 -8
  110. package/src/__tests__/persist-onboarding-artifacts.test.ts +44 -64
  111. package/src/__tests__/platform-callback-registration.test.ts +21 -4
  112. package/src/__tests__/platform.test.ts +2 -1
  113. package/src/__tests__/playbook-execution.test.ts +0 -43
  114. package/src/__tests__/plugin-tool-contribution.test.ts +47 -0
  115. package/src/__tests__/prechat-onboarding-contract.test.ts +214 -27
  116. package/src/__tests__/provider-tool-name.test.ts +23 -0
  117. package/src/__tests__/relay-server.test.ts +60 -5
  118. package/src/__tests__/runtime-events-sse.test.ts +4 -8
  119. package/src/__tests__/scheduler-disk-pressure.test.ts +148 -0
  120. package/src/__tests__/secret-ingress-http.test.ts +0 -1
  121. package/src/__tests__/secret-prompt-log-hygiene.test.ts +7 -5
  122. package/src/__tests__/secret-prompter-channel-fallback.test.ts +7 -5
  123. package/src/__tests__/secret-response-routing.test.ts +7 -5
  124. package/src/__tests__/server-history-render.test.ts +82 -0
  125. package/src/__tests__/skill-include-graph.test.ts +31 -0
  126. package/src/__tests__/skill-load-tool.test.ts +44 -16
  127. package/src/__tests__/skills.test.ts +39 -0
  128. package/src/__tests__/suggestion-routes.test.ts +46 -0
  129. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +0 -42
  130. package/src/__tests__/tool-executor.test.ts +155 -0
  131. package/src/__tests__/twilio-validation.test.ts +2 -2
  132. package/src/__tests__/voice-session-bridge.test.ts +3 -0
  133. package/src/__tests__/workspace-migration-065-bump-stale-heartbeat-interval.test.ts +122 -0
  134. package/src/__tests__/workspace-migration-066-seed-heartbeat-callsite-cost-default.test.ts +285 -0
  135. package/src/__tests__/workspace-migration-068-release-notes-local-timezone.test.ts +90 -0
  136. package/src/__tests__/workspace-migration-069-seed-onboarding-threads.test.ts +120 -0
  137. package/src/__tests__/workspace-migration-071-remove-safe-storage-release-note.test.ts +206 -0
  138. package/src/__tests__/workspace-migration-safe-storage-limits-release.test.ts +78 -0
  139. package/src/agent/loop.ts +11 -0
  140. package/src/approvals/guardian-request-resolvers.ts +3 -32
  141. package/src/backup/snapshot-lock.ts +2 -27
  142. package/src/bundler/compiler-tools.ts +3 -2
  143. package/src/calls/call-constants.ts +5 -8
  144. package/src/calls/call-controller.ts +130 -67
  145. package/src/calls/call-conversation-messages.ts +46 -10
  146. package/src/calls/relay-server.ts +7 -1
  147. package/src/calls/voice-session-bridge.ts +1 -1
  148. package/src/cli/commands/__tests__/webhooks.test.ts +0 -4
  149. package/src/cli/commands/bash.ts +35 -108
  150. package/src/cli/commands/contacts.ts +64 -25
  151. package/src/cli/commands/credentials.ts +56 -0
  152. package/src/cli/commands/memory-v2.ts +11 -10
  153. package/src/cli/commands/oauth/__tests__/connect.test.ts +401 -219
  154. package/src/cli/commands/oauth/connect.ts +124 -40
  155. package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +0 -3
  156. package/src/cli/commands/platform/__tests__/connect.test.ts +7 -1
  157. package/src/cli/commands/platform/__tests__/disconnect.test.ts +7 -1
  158. package/src/cli/commands/platform/__tests__/status.test.ts +103 -6
  159. package/src/cli/commands/platform/index.ts +16 -7
  160. package/src/cli/commands/status.ts +57 -0
  161. package/src/cli/program.ts +4 -2
  162. package/src/config/assistant-feature-flags.ts +13 -3
  163. package/src/config/bundled-skills/app-builder/SKILL.md +1 -3
  164. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +4 -3
  165. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +13 -7
  166. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +2 -2
  167. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +2 -2
  168. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +2 -2
  169. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +2 -2
  170. package/src/config/env.ts +0 -8
  171. package/src/config/feature-flag-registry.json +13 -5
  172. package/src/config/loader.ts +199 -27
  173. package/src/config/schemas/__tests__/memory-v2.test.ts +10 -5
  174. package/src/config/schemas/call-site-catalog.ts +14 -0
  175. package/src/config/schemas/channels.ts +0 -5
  176. package/src/config/schemas/heartbeat.ts +1 -1
  177. package/src/config/schemas/llm.ts +2 -0
  178. package/src/config/schemas/memory-lifecycle.ts +13 -0
  179. package/src/config/schemas/memory-v2.ts +76 -12
  180. package/src/config/schemas/platform.ts +43 -3
  181. package/src/config/schemas/services.ts +28 -0
  182. package/src/config/seed-inference-profiles.ts +230 -33
  183. package/src/contacts/contact-store.ts +0 -25
  184. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +32 -0
  185. package/src/daemon/__tests__/conversation-tool-setup.test.ts +86 -25
  186. package/src/daemon/assistant-attachments.ts +4 -4
  187. package/src/daemon/config-watcher.ts +85 -57
  188. package/src/daemon/conversation-agent-loop-handlers.ts +38 -0
  189. package/src/daemon/conversation-agent-loop.ts +183 -43
  190. package/src/daemon/conversation-error.ts +87 -15
  191. package/src/daemon/conversation-lifecycle.ts +22 -10
  192. package/src/daemon/conversation-process.ts +8 -0
  193. package/src/daemon/conversation-runtime-assembly.ts +26 -0
  194. package/src/daemon/conversation-store.ts +2 -2
  195. package/src/daemon/conversation-surfaces.ts +211 -29
  196. package/src/daemon/conversation-tool-setup.ts +66 -19
  197. package/src/daemon/conversation.ts +18 -23
  198. package/src/daemon/date-context.ts +71 -22
  199. package/src/daemon/disk-pressure-background-gate.ts +73 -0
  200. package/src/daemon/disk-pressure-guard.ts +343 -0
  201. package/src/daemon/disk-pressure-policy.ts +163 -0
  202. package/src/daemon/handlers/shared.ts +26 -1
  203. package/src/daemon/handlers/skills.ts +3 -4
  204. package/src/daemon/host-app-control-proxy.ts +137 -41
  205. package/src/daemon/host-bash-proxy.ts +47 -22
  206. package/src/daemon/host-browser-proxy.ts +1 -1
  207. package/src/daemon/host-cu-proxy.ts +50 -4
  208. package/src/daemon/host-file-proxy.ts +44 -8
  209. package/src/daemon/host-transfer-proxy.ts +97 -6
  210. package/src/daemon/lifecycle.ts +167 -101
  211. package/src/daemon/meet-host-supervisor.ts +4 -4
  212. package/src/daemon/meet-manifest-loader.ts +0 -1
  213. package/src/daemon/memory-v2-startup.ts +66 -15
  214. package/src/daemon/message-protocol.ts +3 -0
  215. package/src/daemon/message-types/conversations.ts +4 -0
  216. package/src/daemon/message-types/disk-pressure.ts +9 -0
  217. package/src/daemon/message-types/messages.ts +22 -1
  218. package/src/daemon/profiler-run-store.ts +5 -5
  219. package/src/daemon/tool-setup-types.ts +2 -2
  220. package/src/documents/document-store.ts +119 -0
  221. package/src/filing/filing-service.ts +29 -5
  222. package/src/heartbeat/__tests__/heartbeat-feed-event.test.ts +9 -16
  223. package/src/heartbeat/__tests__/heartbeat-run-store.test.ts +36 -0
  224. package/src/heartbeat/heartbeat-run-store.ts +13 -0
  225. package/src/heartbeat/heartbeat-service.ts +205 -31
  226. package/src/home/feed-scheduler.ts +18 -0
  227. package/src/inbound/platform-callback-registration.ts +8 -15
  228. package/src/ipc/__tests__/clients-list-ipc.test.ts +169 -0
  229. package/src/ipc/assistant-server.ts +149 -38
  230. package/src/ipc/gateway-client.ts +37 -3
  231. package/src/ipc/skill-server.ts +99 -42
  232. package/src/live-voice/live-voice-archive.ts +4 -4
  233. package/src/live-voice/protocol.ts +5 -7
  234. package/src/media/image-service.ts +1 -7
  235. package/src/memory/__tests__/fixtures/memory-v2-activation-fixtures.ts +21 -13
  236. package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +34 -51
  237. package/src/memory/__tests__/memory-v2-activation-log-store.test.ts +0 -6
  238. package/src/memory/__tests__/memory-v2-concept-frequency.test.ts +272 -0
  239. package/src/memory/admin.ts +5 -9
  240. package/src/memory/context-search/agent-runner.ts +19 -2
  241. package/src/memory/context-search/sources/conversations.ts +2 -11
  242. package/src/memory/context-search/sources/memory-v2.ts +1 -16
  243. package/src/memory/context-search/sources/memory.ts +2 -3
  244. package/src/memory/context-search/sources/pkb.ts +2 -3
  245. package/src/memory/context-search/types.ts +0 -1
  246. package/src/memory/conversation-crud.ts +4 -12
  247. package/src/memory/db-init.ts +2 -0
  248. package/src/memory/embedding-runtime-manager.ts +119 -5
  249. package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +136 -82
  250. package/src/memory/graph/__tests__/handle-remember-v2.test.ts +11 -26
  251. package/src/memory/graph/conversation-graph-memory.ts +72 -61
  252. package/src/memory/graph/extraction.ts +1 -3
  253. package/src/memory/graph/graph-search.test.ts +11 -67
  254. package/src/memory/graph/graph-search.ts +4 -24
  255. package/src/memory/graph/retriever.test.ts +12 -1
  256. package/src/memory/graph/retriever.ts +10 -15
  257. package/src/memory/graph/tool-handlers.ts +3 -4
  258. package/src/memory/graph/tools.ts +4 -4
  259. package/src/memory/indexer.ts +53 -45
  260. package/src/memory/job-handlers/backfill.ts +2 -11
  261. package/src/memory/job-handlers/cleanup.ts +43 -0
  262. package/src/memory/job-handlers/embedding.ts +6 -8
  263. package/src/memory/job-handlers/summarization.ts +2 -7
  264. package/src/memory/jobs/__tests__/embed-concept-page.test.ts +116 -0
  265. package/src/memory/jobs/embed-concept-page.ts +223 -87
  266. package/src/memory/jobs-store.ts +48 -0
  267. package/src/memory/jobs-worker.ts +85 -43
  268. package/src/memory/memory-v2-activation-log-store.ts +32 -14
  269. package/src/memory/memory-v2-concept-frequency.ts +169 -0
  270. package/src/memory/migrations/239-trace-events-created-at-index.ts +18 -0
  271. package/src/memory/migrations/index.ts +1 -0
  272. package/src/memory/pkb/pkb-search.test.ts +7 -0
  273. package/src/memory/pkb/pkb-search.ts +4 -5
  274. package/src/memory/qdrant-client.ts +3 -13
  275. package/src/memory/rerank-local.ts +374 -0
  276. package/src/memory/search/semantic.ts +10 -72
  277. package/src/memory/trace-event-store.ts +1 -17
  278. package/src/memory/v2/__tests__/activation.test.ts +346 -255
  279. package/src/memory/v2/__tests__/consolidation-job.test.ts +61 -40
  280. package/src/memory/v2/__tests__/injection.test.ts +297 -190
  281. package/src/memory/v2/__tests__/prompts-consolidation.test.ts +61 -2
  282. package/src/memory/v2/__tests__/qdrant.test.ts +326 -9
  283. package/src/memory/v2/__tests__/reranker.test.ts +338 -0
  284. package/src/memory/v2/__tests__/sim.test.ts +113 -196
  285. package/src/memory/v2/__tests__/skill-store.test.ts +71 -65
  286. package/src/memory/v2/__tests__/static-context.test.ts +77 -14
  287. package/src/memory/v2/__tests__/sweep-job.test.ts +19 -33
  288. package/src/memory/v2/activation.ts +149 -156
  289. package/src/memory/v2/consolidation-job.ts +69 -20
  290. package/src/memory/v2/injection.ts +75 -68
  291. package/src/memory/v2/page-store.ts +39 -0
  292. package/src/memory/v2/prompts/consolidation.ts +41 -1
  293. package/src/memory/v2/qdrant.ts +306 -46
  294. package/src/memory/v2/reranker.ts +177 -0
  295. package/src/memory/v2/sim.ts +77 -110
  296. package/src/memory/v2/skill-content.ts +4 -3
  297. package/src/memory/v2/skill-store.ts +82 -59
  298. package/src/memory/v2/static-context.ts +26 -8
  299. package/src/memory/v2/sweep-job.ts +5 -6
  300. package/src/memory/v2/types.ts +17 -10
  301. package/src/notifications/copy-composer.ts +47 -0
  302. package/src/notifications/decision-engine.ts +46 -0
  303. package/src/notifications/signal.ts +4 -0
  304. package/src/oauth/AGENTS.md +3 -1
  305. package/src/oauth/__tests__/oauth-connect-state.test.ts +137 -0
  306. package/src/oauth/connect-orchestrator.ts +2 -0
  307. package/src/oauth/connection-resolver.test.ts +66 -1
  308. package/src/oauth/connection-resolver.ts +55 -1
  309. package/src/oauth/oauth-connect-state.ts +77 -0
  310. package/src/oauth/seed-providers.ts +58 -1
  311. package/src/permissions/gateway-threshold-reader.ts +116 -8
  312. package/src/permissions/prompter.ts +86 -96
  313. package/src/permissions/secret-prompter.ts +31 -31
  314. package/src/plugins/defaults/injectors.ts +36 -4
  315. package/src/plugins/defaults/memory-retrieval.ts +5 -6
  316. package/src/plugins/types.ts +7 -0
  317. package/src/proactive-artifact/aux-message-injector.ts +74 -0
  318. package/src/proactive-artifact/decision.test.ts +226 -0
  319. package/src/proactive-artifact/decision.ts +165 -0
  320. package/src/proactive-artifact/index.ts +7 -0
  321. package/src/proactive-artifact/job.test.ts +914 -0
  322. package/src/proactive-artifact/job.ts +366 -0
  323. package/src/proactive-artifact/message-copy.ts +58 -0
  324. package/src/proactive-artifact/trigger-state.test.ts +277 -0
  325. package/src/proactive-artifact/trigger-state.ts +119 -0
  326. package/src/prompts/normalize-onboarding.ts +80 -0
  327. package/src/prompts/persona-resolver.ts +101 -9
  328. package/src/prompts/system-prompt.ts +21 -7
  329. package/src/prompts/templates/BOOTSTRAP.md +13 -5
  330. package/src/prompts/templates/SOUL.md +13 -28
  331. package/src/providers/__tests__/retry-callsite.test.ts +222 -1
  332. package/src/providers/model-intents.ts +7 -0
  333. package/src/providers/openrouter/client.ts +8 -0
  334. package/src/providers/retry.ts +50 -0
  335. package/src/providers/types.ts +1 -0
  336. package/src/runtime/__tests__/agent-wake.test.ts +456 -3
  337. package/src/runtime/agent-wake.ts +238 -100
  338. package/src/runtime/assistant-event-hub.ts +36 -6
  339. package/src/runtime/assistant-event.ts +0 -1
  340. package/src/runtime/auth/__tests__/route-policy.test.ts +64 -0
  341. package/src/runtime/auth/route-policy.ts +15 -1
  342. package/src/runtime/auth/same-actor.ts +216 -0
  343. package/src/runtime/channel-approvals.ts +3 -2
  344. package/src/runtime/channel-retry-sweep.ts +65 -1
  345. package/src/runtime/local-actor-identity.ts +52 -11
  346. package/src/runtime/pending-interactions.ts +27 -15
  347. package/src/runtime/routes/__tests__/client-routes.test.ts +155 -0
  348. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +0 -5
  349. package/src/runtime/routes/__tests__/heartbeat-routes.test.ts +1 -1
  350. package/src/runtime/routes/__tests__/memory-v2-routes.test.ts +147 -0
  351. package/src/runtime/routes/approval-routes.ts +7 -3
  352. package/src/runtime/routes/client-routes.ts +20 -2
  353. package/src/runtime/routes/consolidation-routes.ts +8 -9
  354. package/src/runtime/routes/contact-routes.ts +0 -25
  355. package/src/runtime/routes/conversation-query-routes.ts +44 -1
  356. package/src/runtime/routes/conversation-routes.ts +35 -26
  357. package/src/runtime/routes/debug-bash-routes.ts +165 -0
  358. package/src/runtime/routes/disk-pressure-routes.ts +121 -0
  359. package/src/runtime/routes/document-pdf-renderer.ts +6 -2
  360. package/src/runtime/routes/documents-routes.ts +2 -75
  361. package/src/runtime/routes/events-routes.ts +41 -9
  362. package/src/runtime/routes/filing-routes.ts +2 -3
  363. package/src/runtime/routes/host-bash-routes.ts +23 -3
  364. package/src/runtime/routes/host-cu-routes.ts +33 -6
  365. package/src/runtime/routes/host-file-routes.ts +32 -6
  366. package/src/runtime/routes/host-transfer-routes.ts +79 -16
  367. package/src/runtime/routes/identity-routes.ts +7 -138
  368. package/src/runtime/routes/inbound-message-handler.ts +77 -12
  369. package/src/runtime/routes/index.ts +6 -0
  370. package/src/runtime/routes/memory-item-routes.test.ts +37 -17
  371. package/src/runtime/routes/memory-item-routes.ts +5 -6
  372. package/src/runtime/routes/memory-v2-routes.ts +136 -17
  373. package/src/runtime/routes/oauth-connect-routes.ts +153 -0
  374. package/src/runtime/verification-outbound-actions.ts +4 -4
  375. package/src/schedule/run-script.ts +37 -5
  376. package/src/schedule/scheduler.ts +20 -1
  377. package/src/security/encrypted-store.ts +2 -0
  378. package/src/security/secure-keys.ts +55 -0
  379. package/src/skills/include-graph.ts +35 -13
  380. package/src/skills/remote-skill-policy.ts +4 -10
  381. package/src/subagent/index.ts +1 -7
  382. package/src/subagent/manager.ts +1 -15
  383. package/src/tasks/task-runner.ts +0 -1
  384. package/src/tasks/task-store.ts +0 -3
  385. package/src/tools/background-tool-registry.ts +17 -3
  386. package/src/tools/document/document-tool.ts +20 -0
  387. package/src/tools/executor.ts +18 -2
  388. package/src/tools/host-filesystem/edit.test.ts +151 -0
  389. package/src/tools/host-filesystem/edit.ts +43 -1
  390. package/src/tools/host-filesystem/read.test.ts +129 -0
  391. package/src/tools/host-filesystem/read.ts +43 -1
  392. package/src/tools/host-filesystem/transfer.test.ts +127 -2
  393. package/src/tools/host-filesystem/transfer.ts +56 -11
  394. package/src/tools/host-filesystem/write.test.ts +134 -0
  395. package/src/tools/host-filesystem/write.ts +43 -1
  396. package/src/tools/host-terminal/host-shell.ts +13 -6
  397. package/src/tools/mcp/mcp-tool-factory.ts +2 -1
  398. package/src/tools/memory/register.test.ts +14 -9
  399. package/src/tools/memory/register.ts +1 -2
  400. package/src/tools/permission-checker.ts +15 -0
  401. package/src/tools/provider-tool-name.ts +28 -0
  402. package/src/tools/registry.ts +30 -9
  403. package/src/tools/skills/load.ts +24 -20
  404. package/src/tools/terminal/shell.ts +9 -1
  405. package/src/tools/tool-approval-handler.ts +31 -6
  406. package/src/tools/tool-name-aliases.ts +19 -0
  407. package/src/tools/types.ts +43 -3
  408. package/src/tts/provider-catalog.ts +3 -5
  409. package/src/util/disk-usage.ts +138 -0
  410. package/src/util/platform.ts +21 -11
  411. package/src/util/process-liveness.ts +26 -0
  412. package/src/workspace/heartbeat-service.ts +19 -0
  413. package/src/workspace/migrations/065-bump-stale-heartbeat-interval.ts +60 -0
  414. package/src/workspace/migrations/066-seed-heartbeat-callsite-cost-default.ts +146 -0
  415. package/src/workspace/migrations/067-release-notes-safe-storage-limits.ts +14 -0
  416. package/src/workspace/migrations/068-release-notes-local-timezone.ts +65 -0
  417. package/src/workspace/migrations/069-seed-onboarding-threads.ts +28 -0
  418. package/src/workspace/migrations/070-memory-v2-summary-schema-rebuild.ts +31 -0
  419. package/src/workspace/migrations/071-remove-safe-storage-release-note.ts +111 -0
  420. package/src/workspace/migrations/registry.ts +14 -0
  421. package/src/__tests__/conversation-tool-setup-memory-scope.test.ts +0 -167
  422. package/src/memory/v2/__tests__/skill-qdrant.test.ts +0 -657
  423. package/src/memory/v2/skill-qdrant.ts +0 -404
  424. package/src/signals/bash.ts +0 -198
@@ -70,16 +70,6 @@ const state = {
70
70
  points: Array<{ score?: number; payload: Record<string, unknown> }>;
71
71
  }>,
72
72
  },
73
- // Separate response queue for the dedicated skills collection so a test
74
- // querying both concept pages and skills doesn't have to interleave.
75
- skillQueryResponses: {
76
- dense: [] as Array<{
77
- points: Array<{ score?: number; payload: Record<string, unknown> }>;
78
- }>,
79
- sparse: [] as Array<{
80
- points: Array<{ score?: number; payload: Record<string, unknown> }>;
81
- }>,
82
- },
83
73
  queryCalls: [] as Array<{
84
74
  collection: string;
85
75
  using: string;
@@ -146,12 +136,12 @@ class MockQdrantClient {
146
136
  limit: params.limit,
147
137
  filter: params.filter,
148
138
  });
149
- const channel = params.using as "dense" | "sparse";
150
- const queue =
151
- name === "memory_v2_skills"
152
- ? state.skillQueryResponses[channel]
153
- : state.queryResponses[channel];
154
- return queue.shift() ?? { points: [] };
139
+ // Both `dense` and `summary_dense` consume from the dense queue (and
140
+ // similarly for sparse). The four-channel hybrid query fires them in
141
+ // order: body-dense, body-sparse, summary-dense, summary-sparse — so
142
+ // the queue order matches the call order.
143
+ const channel = params.using.endsWith("sparse") ? "sparse" : "dense";
144
+ return state.queryResponses[channel].shift() ?? { points: [] };
155
145
  }
156
146
  }
157
147
 
@@ -159,10 +149,7 @@ mock.module("@qdrant/js-client-rest", () => ({
159
149
  QdrantClient: MockQdrantClient,
160
150
  }));
161
151
 
162
- const { simBatch, simSkillBatch, clamp01, effectiveWeights } =
163
- await import("../sim.js");
164
- const { _resetMemoryV2SkillQdrantForTests } =
165
- await import("../skill-qdrant.js");
152
+ const { simBatch, clamp01, effectiveWeights } = await import("../sim.js");
166
153
  const { _resetMemoryV2QdrantForTests } = await import("../qdrant.js");
167
154
 
168
155
  // ---------------------------------------------------------------------------
@@ -175,15 +162,12 @@ function resetState(): void {
175
162
  state.embedReturn = [[0.1, 0.2, 0.3]];
176
163
  state.queryResponses.dense.length = 0;
177
164
  state.queryResponses.sparse.length = 0;
178
- state.skillQueryResponses.dense.length = 0;
179
- state.skillQueryResponses.sparse.length = 0;
180
165
  state.queryCalls.length = 0;
181
166
  // Bun's `mock.module` persists across files in the same process, so the
182
- // qdrant modules' singletons may already hold a MockQdrantClient instance
183
- // from a sibling test file. Reset both readiness caches so each test in
184
- // this file gets a fresh `new QdrantClient()` resolved against our mock.
167
+ // qdrant module's singleton may already hold a MockQdrantClient instance
168
+ // from a sibling test file. Reset readiness so each test in this file
169
+ // gets a fresh `new QdrantClient()` resolved against our mock.
185
170
  _resetMemoryV2QdrantForTests();
186
- _resetMemoryV2SkillQdrantForTests();
187
171
  }
188
172
 
189
173
  function configWithWeights(
@@ -205,10 +189,18 @@ function configWithWeights(
205
189
  /**
206
190
  * Stage a single Qdrant response that maps each (slug, denseScore?, sparseScore?)
207
191
  * tuple onto the dense or sparse channel, mirroring how `hybridQueryConceptPages`
208
- * merges per-channel hits.
192
+ * merges per-channel hits. Optional `summaryDenseScore` / `summarySparseScore`
193
+ * stage the summary-side channels — pages without those entries fall through
194
+ * to body-only scoring at fusion time.
209
195
  */
210
196
  function stageHybridResponse(
211
- hits: Array<{ slug: string; denseScore?: number; sparseScore?: number }>,
197
+ hits: Array<{
198
+ slug: string;
199
+ denseScore?: number;
200
+ sparseScore?: number;
201
+ summaryDenseScore?: number;
202
+ summarySparseScore?: number;
203
+ }>,
212
204
  ): void {
213
205
  state.queryResponses.dense.push({
214
206
  points: hits
@@ -220,6 +212,20 @@ function stageHybridResponse(
220
212
  .filter((h) => h.sparseScore !== undefined)
221
213
  .map((h) => ({ score: h.sparseScore, payload: { slug: h.slug } })),
222
214
  });
215
+ // The four-channel hybrid query also fires `summary_dense` and
216
+ // `summary_sparse` queries against the same collection. Tests that don't
217
+ // care about summary scores leave those channels empty so the fallback
218
+ // (body-only) path runs.
219
+ state.queryResponses.dense.push({
220
+ points: hits
221
+ .filter((h) => h.summaryDenseScore !== undefined)
222
+ .map((h) => ({ score: h.summaryDenseScore, payload: { slug: h.slug } })),
223
+ });
224
+ state.queryResponses.sparse.push({
225
+ points: hits
226
+ .filter((h) => h.summarySparseScore !== undefined)
227
+ .map((h) => ({ score: h.summarySparseScore, payload: { slug: h.slug } })),
228
+ });
223
229
  }
224
230
 
225
231
  beforeEach(resetState);
@@ -488,15 +494,16 @@ describe("simBatch", () => {
488
494
  expect(out.get("loud-page")).toBe(1);
489
495
  });
490
496
 
491
- test("forwards the candidate slugs as a Qdrant slug-IN filter", async () => {
497
+ test("forwards the candidate slugs as a Qdrant slug-IN filter on every channel", async () => {
492
498
  const config = configWithWeights(0.7, 0.3);
493
499
  stageHybridResponse([]);
494
500
 
495
501
  await simBatch("query", ["alice", "bob", "carol"], config);
496
502
 
497
- // Both channels (dense + sparse) ran with the same slug-restriction
498
- // filter and the same per-channel limit equal to the candidate count.
499
- expect(state.queryCalls).toHaveLength(2);
503
+ // All four channels (body dense + sparse, summary dense + sparse) ran
504
+ // with the same slug-restriction filter and the same per-channel limit
505
+ // equal to the candidate count.
506
+ expect(state.queryCalls).toHaveLength(4);
500
507
  for (const call of state.queryCalls) {
501
508
  expect(call.limit).toBe(3);
502
509
  expect(call.filter).toEqual({
@@ -516,194 +523,104 @@ describe("simBatch", () => {
516
523
  expect(state.sparseCalls).toEqual(["hello world"]);
517
524
  });
518
525
 
519
- test("returned scores are always in [0, 1] for arbitrary inputs", async () => {
520
- const config = configWithWeights(0.7, 0.3);
526
+ test("takes max(body, summary) per slug summary higher than body wins", async () => {
527
+ // Body channels return a modest score; summary channels return a much
528
+ // higher score. The max collapses to the summary score.
529
+ const config = configWithWeights(1.0, 0.0);
521
530
  stageHybridResponse([
522
- { slug: "a", denseScore: 0.99, sparseScore: 100 },
523
- { slug: "b", denseScore: 0.5, sparseScore: 50 },
524
- { slug: "c", denseScore: 0.0, sparseScore: 1 },
525
- { slug: "d", denseScore: 0.123, sparseScore: 0 }, // explicit zero
531
+ {
532
+ slug: "alice",
533
+ denseScore: 0.3,
534
+ summaryDenseScore: 0.7,
535
+ },
526
536
  ]);
527
537
 
528
- const out = await simBatch("query", ["a", "b", "c", "d"], config);
529
-
530
- for (const [, score] of out) {
531
- expect(score).toBeGreaterThanOrEqual(0);
532
- expect(score).toBeLessThanOrEqual(1);
533
- }
534
- });
535
- });
536
-
537
- // ---------------------------------------------------------------------------
538
- // simSkillBatch
539
- // ---------------------------------------------------------------------------
538
+ const out = await simBatch("query", ["alice"], config);
540
539
 
541
- /**
542
- * Stage a single hybrid response on the dedicated skills queues. Mirrors
543
- * `stageHybridResponse` but uses `payload.id` (skills' Qdrant payload key)
544
- * instead of `payload.slug`.
545
- */
546
- function stageSkillHybridResponse(
547
- hits: Array<{ id: string; denseScore?: number; sparseScore?: number }>,
548
- ): void {
549
- state.skillQueryResponses.dense.push({
550
- points: hits
551
- .filter((h) => h.denseScore !== undefined)
552
- .map((h) => ({ score: h.denseScore, payload: { id: h.id } })),
540
+ expect(out.get("alice")).toBeCloseTo(0.7, 6);
553
541
  });
554
- state.skillQueryResponses.sparse.push({
555
- points: hits
556
- .filter((h) => h.sparseScore !== undefined)
557
- .map((h) => ({ score: h.sparseScore, payload: { id: h.id } })),
558
- });
559
- }
560
-
561
- describe("simSkillBatch", () => {
562
- test("empty id list returns empty map without touching backends", async () => {
563
- const config = configWithWeights(0.7, 0.3);
564
-
565
- const out = await simSkillBatch("anything", [], config);
566
542
 
567
- expect(out.size).toBe(0);
568
- expect(state.embedCalls).toHaveLength(0);
569
- expect(state.sparseCalls).toHaveLength(0);
570
- expect(state.queryCalls).toHaveLength(0);
571
- });
572
-
573
- test("empty text returns empty map without touching backends", async () => {
574
- const config = configWithWeights(0.7, 0.3);
575
-
576
- for (const text of ["", " ", "\n\n"]) {
577
- state.embedCalls.length = 0;
578
- state.sparseCalls.length = 0;
579
- state.queryCalls.length = 0;
580
- const out = await simSkillBatch(text, ["example-skill-a"], config);
581
- expect(out.size).toBe(0);
582
- expect(state.embedCalls).toHaveLength(0);
583
- expect(state.sparseCalls).toHaveLength(0);
584
- expect(state.queryCalls).toHaveLength(0);
585
- }
586
- });
587
-
588
- test("queries the dedicated skills collection and forwards an id-IN filter", async () => {
589
- const config = configWithWeights(0.7, 0.3);
590
- stageSkillHybridResponse([]);
543
+ test("takes max(body, summary) per slug — body higher than summary wins", async () => {
544
+ // Inverse case: body dominates, max stays at body.
545
+ const config = configWithWeights(1.0, 0.0);
546
+ stageHybridResponse([
547
+ {
548
+ slug: "alice",
549
+ denseScore: 0.9,
550
+ summaryDenseScore: 0.4,
551
+ },
552
+ ]);
591
553
 
592
- await simSkillBatch(
593
- "query",
594
- ["example-skill-a", "example-skill-b"],
595
- config,
596
- );
554
+ const out = await simBatch("query", ["alice"], config);
597
555
 
598
- expect(state.queryCalls).toHaveLength(2);
599
- for (const call of state.queryCalls) {
600
- expect(call.collection).toBe("memory_v2_skills");
601
- // The candidate ids are forwarded as a Qdrant filter so Qdrant scores
602
- // exactly the candidate set, not its global top-K. Without this,
603
- // candidate ids absent from the global top-K silently score 0.
604
- expect(call.filter).toEqual({
605
- must: [
606
- { key: "id", match: { any: ["example-skill-a", "example-skill-b"] } },
607
- ],
608
- });
609
- // Limit equals the candidate count.
610
- expect(call.limit).toBe(2);
611
- }
556
+ expect(out.get("alice")).toBeCloseTo(0.9, 6);
612
557
  });
613
558
 
614
- test("fuses dense + sparse with the configured weight blend", async () => {
615
- const config = configWithWeights(0.4, 0.6);
616
- stageSkillHybridResponse([
617
- { id: "example-skill-a", denseScore: 0.5, sparseScore: 4 }, // sparse-norm 1.0
618
- { id: "example-skill-b", denseScore: 0.25, sparseScore: 2 }, // sparse-norm 0.5
559
+ test("falls back to body-only when the page has no summary embedding", async () => {
560
+ // Pages predating the summary field have no summary_dense/sparse vectors.
561
+ // Their summary channels return no hits — the max collapses to body.
562
+ const config = configWithWeights(1.0, 0.0);
563
+ stageHybridResponse([
564
+ {
565
+ slug: "legacy-page",
566
+ denseScore: 0.6,
567
+ // summaryDenseScore / summarySparseScore omitted
568
+ },
619
569
  ]);
620
570
 
621
- const out = await simSkillBatch(
622
- "query",
623
- ["example-skill-a", "example-skill-b"],
624
- config,
625
- );
571
+ const out = await simBatch("query", ["legacy-page"], config);
626
572
 
627
- // example-skill-a: 0.4 * 0.5 + 0.6 * 1.0 = 0.8
628
- // example-skill-b: 0.4 * 0.25 + 0.6 * 0.5 = 0.4
629
- expect(out.get("example-skill-a")).toBeCloseTo(0.8, 6);
630
- expect(out.get("example-skill-b")).toBeCloseTo(0.4, 6);
573
+ expect(out.get("legacy-page")).toBeCloseTo(0.6, 6);
631
574
  });
632
575
 
633
- test("dense-only and sparse-only hits are handled symmetrically", async () => {
634
- const config = configWithWeights(0.7, 0.3);
635
- stageSkillHybridResponse([
636
- { id: "example-skill-a", denseScore: 0.5 /* sparse omitted */ },
637
- { id: "example-skill-b", sparseScore: 8 /* dense omitted */ },
576
+ test("normalizes body and summary sparse channels independently", async () => {
577
+ // Summary sparse scores live on a different scale than body sparse —
578
+ // a small absolute summary-sparse value (1.5) on the only page that
579
+ // has summary signal still normalizes to 1.0 within the summary
580
+ // channel, so the summary-only fused score should win out.
581
+ const config = configWithWeights(0.0, 1.0);
582
+ stageHybridResponse([
583
+ {
584
+ slug: "alice",
585
+ denseScore: 0.0,
586
+ sparseScore: 100, // body sparse max in this batch
587
+ },
588
+ {
589
+ slug: "bob",
590
+ denseScore: 0.0,
591
+ sparseScore: 0.5, // body sparse normalized = 0.005
592
+ summaryDenseScore: 0.0,
593
+ summarySparseScore: 1.5, // summary sparse max in this batch
594
+ },
638
595
  ]);
639
596
 
640
- const out = await simSkillBatch(
641
- "query",
642
- ["example-skill-a", "example-skill-b"],
643
- config,
644
- );
597
+ const out = await simBatch("query", ["alice", "bob"], config);
645
598
 
646
- // example-skill-a: 0.7 * 0.5 + 0.3 * 0 = 0.35
647
- // example-skill-b: 0.7 * 0 + 0.3 * 1.0 = 0.30 (sparse-norm = 8/8)
648
- expect(out.get("example-skill-a")).toBeCloseTo(0.35, 6);
649
- expect(out.get("example-skill-b")).toBeCloseTo(0.3, 6);
599
+ // Alice has only body. Body sparse normalized to 1.0; sparse_weight=1.0 → 1.0.
600
+ expect(out.get("alice")).toBeCloseTo(1.0, 6);
601
+ // Bob's summary side normalizes its 1.5 (only sparse-bearing summary
602
+ // hit) — a single sparse-bearing hit is below the adaptive-spread
603
+ // floor, so the channel collapses to base weights and the lone
604
+ // sparseNormalized=1.0 hit yields a fused summary score of 1.0.
605
+ // Body side has only bob's tiny sparse=0.5 against the body batch max
606
+ // of 100 → ~0.005. The max picks the summary side.
607
+ expect(out.get("bob")).toBeCloseTo(1.0, 6);
650
608
  });
651
609
 
652
- test("forwards candidate ids as the Qdrant restriction; only candidates in result", async () => {
653
- // The bug we're guarding against: when the skills collection has more
654
- // skills than `ids.length`, calling `hybridQuerySkills` without a filter
655
- // returns Qdrant's global top-K. Candidate ids absent from that top-K
656
- // would silently score 0. The fix is to forward the candidate ids as a
657
- // server-side restriction so Qdrant scores exactly the candidate set.
610
+ test("returned scores are always in [0, 1] for arbitrary inputs", async () => {
658
611
  const config = configWithWeights(0.7, 0.3);
659
- stageSkillHybridResponse([
660
- { id: "example-skill-a", denseScore: 0.5, sparseScore: 1 },
661
- // `example-skill-c` would never be returned in production once the
662
- // filter is applied; the post-filter in simSkillBatch defensively
663
- // drops it even if a stale payload slips through.
664
- { id: "example-skill-c", denseScore: 0.9, sparseScore: 1 },
612
+ stageHybridResponse([
613
+ { slug: "a", denseScore: 0.99, sparseScore: 100 },
614
+ { slug: "b", denseScore: 0.5, sparseScore: 50 },
615
+ { slug: "c", denseScore: 0.0, sparseScore: 1 },
616
+ { slug: "d", denseScore: 0.123, sparseScore: 0 }, // explicit zero
665
617
  ]);
666
618
 
667
- const out = await simSkillBatch(
668
- "query",
669
- ["example-skill-a", "example-skill-b"],
670
- config,
671
- );
619
+ const out = await simBatch("query", ["a", "b", "c", "d"], config);
672
620
 
673
- // The Qdrant filter was forwarded — both channels carry the id-IN
674
- // restriction matching the caller's candidate set.
675
- expect(state.queryCalls).toHaveLength(2);
676
- for (const call of state.queryCalls) {
677
- expect(call.filter).toEqual({
678
- must: [
679
- { key: "id", match: { any: ["example-skill-a", "example-skill-b"] } },
680
- ],
681
- });
621
+ for (const [, score] of out) {
622
+ expect(score).toBeGreaterThanOrEqual(0);
623
+ expect(score).toBeLessThanOrEqual(1);
682
624
  }
683
- // Only candidate ids appear in the result map.
684
- expect(out.has("example-skill-a")).toBe(true);
685
- expect(out.has("example-skill-c")).toBe(false);
686
- });
687
-
688
- test("returned scores are clamped into [0, 1]", async () => {
689
- const config = configWithWeights(0.8, 0.5); // intentionally sums to > 1
690
- stageSkillHybridResponse([
691
- { id: "example-skill-a", denseScore: 1.0, sparseScore: 1 },
692
- ]);
693
-
694
- const out = await simSkillBatch("query", ["example-skill-a"], config);
695
-
696
- expect(out.get("example-skill-a")).toBe(1);
697
- });
698
-
699
- test("embeds the query text exactly once via dense + sparse backends", async () => {
700
- const config = configWithWeights(0.7, 0.3);
701
- stageSkillHybridResponse([]);
702
-
703
- await simSkillBatch("hello skill", ["example-skill-a"], config);
704
-
705
- expect(state.embedCalls).toHaveLength(1);
706
- expect(state.embedCalls[0].inputs).toEqual(["hello skill"]);
707
- expect(state.sparseCalls).toEqual(["hello skill"]);
708
625
  });
709
626
  });
@@ -1,12 +1,17 @@
1
1
  /**
2
2
  * Tests for `assistant/src/memory/v2/skill-store.ts`.
3
3
  *
4
- * Coverage matrix from PR 5 acceptance criteria:
4
+ * Coverage matrix:
5
5
  * - `seedV2SkillEntries` enumerates the catalog and calls
6
- * `upsertSkillEmbedding` for each enabled skill.
6
+ * `upsertConceptPageEmbedding` with `slug: "skills/<id>"` for each
7
+ * enabled skill in the unified `memory_v2_concept_pages` collection.
7
8
  * - It skips skills whose declared feature flag is disabled.
8
- * - It calls `pruneSkillsExcept` with the active id list.
9
- * - It populates the `entries` cache so `getSkillCapability` returns each entry.
9
+ * - It calls `pruneSlugsWithPrefixExcept("skills/", ...)` with the active
10
+ * id list as suffixes, so stale skill slugs in the unified collection
11
+ * get pruned without touching concept-page slugs.
12
+ * - It populates the `entries` cache so `getSkillCapability` returns each
13
+ * entry — accepting both bare ids (`"example-skill"`) and unified-collection
14
+ * slugs (`"skills/example-skill"`).
10
15
  * - It swallows errors from the embedding backend — the function resolves
11
16
  * and the cache is unchanged from prior state.
12
17
  *
@@ -29,6 +34,18 @@ mock.module("../../../util/logger.js", () => ({
29
34
  // Programmable test state — drives every mocked dependency below.
30
35
  // ---------------------------------------------------------------------------
31
36
 
37
+ interface UpsertCall {
38
+ slug: string;
39
+ dense: number[];
40
+ sparse: { indices: number[]; values: number[] };
41
+ updatedAt: number;
42
+ }
43
+
44
+ interface PruneCall {
45
+ prefix: string;
46
+ activeSuffixes: readonly string[];
47
+ }
48
+
32
49
  interface TestState {
33
50
  catalog: SkillSummary[];
34
51
  resolved: ResolvedSkill[];
@@ -38,14 +55,8 @@ interface TestState {
38
55
  embedThrows: Error | null;
39
56
  embedReturn: number[][];
40
57
  sparseReturn: { indices: number[]; values: number[] };
41
- upsertCalls: Array<{
42
- id: string;
43
- content: string;
44
- dense: number[];
45
- sparse: { indices: number[]; values: number[] };
46
- updatedAt: number;
47
- }>;
48
- pruneCalls: Array<readonly string[]>;
58
+ upsertCalls: UpsertCall[];
59
+ pruneCalls: PruneCall[];
49
60
  upsertThrows: Error | null;
50
61
  }
51
62
 
@@ -99,13 +110,16 @@ mock.module("../../embedding-backend.js", () => ({
99
110
  generateSparseEmbedding: () => state.sparseReturn,
100
111
  }));
101
112
 
102
- mock.module("../skill-qdrant.js", () => ({
103
- upsertSkillEmbedding: async (params: TestState["upsertCalls"][number]) => {
113
+ mock.module("../qdrant.js", () => ({
114
+ upsertConceptPageEmbedding: async (params: UpsertCall) => {
104
115
  if (state.upsertThrows) throw state.upsertThrows;
105
116
  state.upsertCalls.push(params);
106
117
  },
107
- pruneSkillsExcept: async (ids: readonly string[]) => {
108
- state.pruneCalls.push(ids);
118
+ pruneSlugsWithPrefixExcept: async (
119
+ prefix: string,
120
+ activeSuffixes: readonly string[],
121
+ ) => {
122
+ state.pruneCalls.push({ prefix, activeSuffixes });
109
123
  },
110
124
  }));
111
125
 
@@ -160,7 +174,7 @@ afterEach(resetState);
160
174
  // ---------------------------------------------------------------------------
161
175
 
162
176
  describe("seedV2SkillEntries", () => {
163
- test("enumerates the catalog and upserts one point per enabled skill", async () => {
177
+ test("upserts each enabled skill into the unified collection under skills/<id>", async () => {
164
178
  const skillA = makeSummary({
165
179
  id: "example-skill-a",
166
180
  displayName: "Skill A",
@@ -182,15 +196,16 @@ describe("seedV2SkillEntries", () => {
182
196
  await seedV2SkillEntries();
183
197
 
184
198
  expect(state.upsertCalls).toHaveLength(2);
185
- const ids = state.upsertCalls.map((c) => c.id).sort();
186
- expect(ids).toEqual(["example-skill-a", "example-skill-b"]);
187
-
188
- // Each upsert carries the per-skill dense + sparse + content payload.
189
- const callA = state.upsertCalls.find((c) => c.id === "example-skill-a")!;
199
+ const slugs = state.upsertCalls.map((c) => c.slug).sort();
200
+ expect(slugs).toEqual(["skills/example-skill-a", "skills/example-skill-b"]);
201
+
202
+ // Each upsert carries the per-skill dense + sparse + updatedAt payload,
203
+ // keyed under the unified `skills/<id>` slug.
204
+ const callA = state.upsertCalls.find(
205
+ (c) => c.slug === "skills/example-skill-a",
206
+ )!;
190
207
  expect(callA.dense).toEqual([0.1, 0.2, 0.3]);
191
208
  expect(callA.sparse).toEqual(state.sparseReturn);
192
- expect(callA.content).toContain("Skill A");
193
- expect(callA.content).toContain("(example-skill-a)");
194
209
  expect(callA.updatedAt).toBeGreaterThan(0);
195
210
  });
196
211
 
@@ -207,12 +222,11 @@ describe("seedV2SkillEntries", () => {
207
222
  await seedV2SkillEntries();
208
223
 
209
224
  expect(state.upsertCalls).toHaveLength(1);
210
- expect(state.upsertCalls[0].id).toBe("example-skill-a");
225
+ expect(state.upsertCalls[0].slug).toBe("skills/example-skill-a");
211
226
  });
212
227
 
213
228
  test("does not re-seed an installed-but-disabled skill from the remote catalog", async () => {
214
- // Regression for https://github.com/vellum-ai/vellum-assistant/pull/28635
215
- // (Codex P1): if `seenIds` is built only from enabled skills, a locally
229
+ // Regression: if `seenIds` is built only from enabled skills, a locally
216
230
  // installed-but-disabled skill falls through to the catalog loop and gets
217
231
  // embedded as if it were a discoverable uninstalled skill — contradicting
218
232
  // the user's explicit disablement.
@@ -223,8 +237,6 @@ describe("seedV2SkillEntries", () => {
223
237
  { summary: enabledSkill, state: "enabled" },
224
238
  { summary: disabledSkill, state: "disabled" },
225
239
  ];
226
- // The remote catalog also contains the disabled skill (same id) — the
227
- // seed function must NOT pull it back in via `getCatalog()`.
228
240
  state.fullCatalog = [
229
241
  {
230
242
  id: "example-skill-b",
@@ -237,7 +249,7 @@ describe("seedV2SkillEntries", () => {
237
249
  await seedV2SkillEntries();
238
250
 
239
251
  expect(state.upsertCalls).toHaveLength(1);
240
- expect(state.upsertCalls[0].id).toBe("example-skill-a");
252
+ expect(state.upsertCalls[0].slug).toBe("skills/example-skill-a");
241
253
  });
242
254
 
243
255
  test("seeds genuinely uninstalled catalog skills alongside enabled installed skills", async () => {
@@ -263,8 +275,11 @@ describe("seedV2SkillEntries", () => {
263
275
 
264
276
  await seedV2SkillEntries();
265
277
 
266
- const ids = state.upsertCalls.map((c) => c.id).sort();
267
- expect(ids).toEqual(["example-skill-a", "uninstalled-skill"]);
278
+ const slugs = state.upsertCalls.map((c) => c.slug).sort();
279
+ expect(slugs).toEqual([
280
+ "skills/example-skill-a",
281
+ "skills/uninstalled-skill",
282
+ ]);
268
283
  });
269
284
 
270
285
  test("skips skills whose declared feature flag is disabled", async () => {
@@ -284,10 +299,10 @@ describe("seedV2SkillEntries", () => {
284
299
  await seedV2SkillEntries();
285
300
 
286
301
  expect(state.upsertCalls).toHaveLength(1);
287
- expect(state.upsertCalls[0].id).toBe("example-skill-b");
302
+ expect(state.upsertCalls[0].slug).toBe("skills/example-skill-b");
288
303
  });
289
304
 
290
- test("calls pruneSkillsExcept with the active id list", async () => {
305
+ test("calls pruneSlugsWithPrefixExcept with the active id list and the skills/ prefix", async () => {
291
306
  const skillA = makeSummary({ id: "example-skill-a" });
292
307
  const skillB = makeSummary({ id: "example-skill-b" });
293
308
  state.catalog = [skillA, skillB];
@@ -309,13 +324,14 @@ describe("seedV2SkillEntries", () => {
309
324
  await seedV2SkillEntries();
310
325
 
311
326
  expect(state.pruneCalls).toHaveLength(1);
312
- expect([...state.pruneCalls[0]].sort()).toEqual([
327
+ expect(state.pruneCalls[0].prefix).toBe("skills/");
328
+ expect([...state.pruneCalls[0].activeSuffixes].sort()).toEqual([
313
329
  "example-skill-a",
314
330
  "example-skill-b",
315
331
  ]);
316
332
  });
317
333
 
318
- test("passes only the active (post-flag-filter) ids to pruneSkillsExcept", async () => {
334
+ test("passes only the active (post-flag-filter) ids to pruneSlugsWithPrefixExcept", async () => {
319
335
  const flagged = makeSummary({
320
336
  id: "example-skill-a",
321
337
  featureFlag: "off-flag",
@@ -327,8 +343,6 @@ describe("seedV2SkillEntries", () => {
327
343
  { summary: unflagged, state: "enabled" },
328
344
  ];
329
345
  state.flagsEnabled = { "off-flag": false };
330
- // Remote catalog must be non-empty so catalogAvailable is true and
331
- // pruning is not skipped.
332
346
  state.fullCatalog = [
333
347
  { id: "example-skill-a", name: "example-skill-a", description: "A" },
334
348
  { id: "example-skill-b", name: "example-skill-b", description: "B" },
@@ -338,44 +352,35 @@ describe("seedV2SkillEntries", () => {
338
352
  await seedV2SkillEntries();
339
353
 
340
354
  expect(state.pruneCalls).toHaveLength(1);
341
- expect([...state.pruneCalls[0]]).toEqual(["example-skill-b"]);
355
+ expect(state.pruneCalls[0].prefix).toBe("skills/");
356
+ expect([...state.pruneCalls[0].activeSuffixes]).toEqual([
357
+ "example-skill-b",
358
+ ]);
342
359
  });
343
360
 
344
- test("populates the entries cache so getSkillCapability returns each entry", async () => {
361
+ test("populates the entries cache so getSkillCapability resolves both bare id and unified slug", async () => {
345
362
  const skillA = makeSummary({
346
363
  id: "example-skill-a",
347
364
  displayName: "Skill A",
348
365
  });
349
- const skillB = makeSummary({
350
- id: "example-skill-b",
351
- displayName: "Skill B",
352
- });
353
- state.catalog = [skillA, skillB];
354
- state.resolved = [
355
- { summary: skillA, state: "enabled" },
356
- { summary: skillB, state: "enabled" },
357
- ];
358
- state.embedReturn = [
359
- [0.1, 0.2, 0.3],
360
- [0.4, 0.5, 0.6],
361
- ];
366
+ state.catalog = [skillA];
367
+ state.resolved = [{ summary: skillA, state: "enabled" }];
368
+ state.embedReturn = [[0.1, 0.2, 0.3]];
362
369
 
363
370
  expect(getSkillCapability("example-skill-a")).toBeNull();
364
371
 
365
372
  await seedV2SkillEntries();
366
373
 
367
- const entryA = getSkillCapability("example-skill-a");
368
- const entryB = getSkillCapability("example-skill-b");
369
- expect(entryA).not.toBeNull();
370
- expect(entryA?.id).toBe("example-skill-a");
371
- expect(entryA?.content).toContain("Skill A");
372
-
373
- expect(entryB).not.toBeNull();
374
- expect(entryB?.id).toBe("example-skill-b");
375
- expect(entryB?.content).toContain("Skill B");
374
+ // Bare id and unified-slug forms both resolve to the same entry.
375
+ const byId = getSkillCapability("example-skill-a");
376
+ const bySlug = getSkillCapability("skills/example-skill-a");
377
+ expect(byId).not.toBeNull();
378
+ expect(byId?.id).toBe("example-skill-a");
379
+ expect(byId?.content).toContain("Skill A");
380
+ expect(bySlug).toEqual(byId);
376
381
 
377
- // Unknown ids return null even when the cache is populated.
378
382
  expect(getSkillCapability("unknown-skill")).toBeNull();
383
+ expect(getSkillCapability("skills/unknown-skill")).toBeNull();
379
384
  });
380
385
 
381
386
  test("swallows errors from embedWithBackend and leaves prior cache intact", async () => {
@@ -426,9 +431,10 @@ describe("seedV2SkillEntries", () => {
426
431
  await seedV2SkillEntries();
427
432
 
428
433
  expect(state.upsertCalls).toHaveLength(1);
429
- expect(state.upsertCalls[0].id).toBe("remote-only");
434
+ expect(state.upsertCalls[0].slug).toBe("skills/remote-only");
430
435
  expect(state.pruneCalls).toHaveLength(1);
431
- expect([...state.pruneCalls[0]]).toEqual(["remote-only"]);
436
+ expect(state.pruneCalls[0].prefix).toBe("skills/");
437
+ expect([...state.pruneCalls[0].activeSuffixes]).toEqual(["remote-only"]);
432
438
  });
433
439
 
434
440
  test("skips pruning when catalog fetch returns empty (network failure guard)", async () => {