@vellumai/assistant 0.8.10 → 0.8.11-staging.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (400) hide show
  1. package/bun.lock +62 -1
  2. package/docs/workspace-tools.md +196 -0
  3. package/examples/plugins/echo/README.md +3 -3
  4. package/knip.json +1 -0
  5. package/openapi.yaml +460 -128
  6. package/package.json +2 -1
  7. package/scripts/build-plugin-api.ts +299 -0
  8. package/src/__tests__/agent-loop-callsite-precedence.test.ts +7 -0
  9. package/src/__tests__/agent-loop-compaction-events.test.ts +197 -0
  10. package/src/__tests__/agent-loop-exit-reason.test.ts +93 -96
  11. package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +2 -0
  12. package/src/__tests__/agent-loop-output-hooks.test.ts +274 -1
  13. package/src/__tests__/agent-loop-override-profile.test.ts +3 -0
  14. package/src/__tests__/agent-loop-provider-error-recording.test.ts +4 -0
  15. package/src/__tests__/agent-loop-thinking.test.ts +4 -0
  16. package/src/__tests__/agent-loop.test.ts +578 -5
  17. package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +0 -1
  18. package/src/__tests__/approval-cascade.test.ts +1 -0
  19. package/src/__tests__/background-workers-disk-pressure.test.ts +0 -2
  20. package/src/__tests__/btw-routes.test.ts +0 -1
  21. package/src/__tests__/build-persisted-content.test.ts +75 -1
  22. package/src/__tests__/catalog-install-normalize.test.ts +141 -0
  23. package/src/__tests__/ces-startup-timeout.test.ts +60 -0
  24. package/src/__tests__/compaction-events.test.ts +1 -0
  25. package/src/__tests__/config-managed-gemini-defaults.test.ts +2 -46
  26. package/src/__tests__/context-overflow-reducer.test.ts +264 -124
  27. package/src/__tests__/context-window-manager-overflow-rung.test.ts +351 -0
  28. package/src/__tests__/conversation-abort-tool-results.test.ts +1 -1
  29. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +13 -5
  30. package/src/__tests__/conversation-agent-loop-overflow.test.ts +284 -455
  31. package/src/__tests__/conversation-agent-loop.test.ts +131 -551
  32. package/src/__tests__/conversation-app-control-instantiation.test.ts +13 -0
  33. package/src/__tests__/conversation-confirmation-signals.test.ts +1 -0
  34. package/src/__tests__/conversation-fork-crud.test.ts +259 -0
  35. package/src/__tests__/conversation-history-web-search.test.ts +1 -1
  36. package/src/__tests__/conversation-lifecycle.test.ts +257 -1
  37. package/src/__tests__/conversation-process-callsite.test.ts +1 -0
  38. package/src/__tests__/conversation-provider-retry-repair.test.ts +38 -377
  39. package/src/__tests__/conversation-queue.test.ts +1 -39
  40. package/src/__tests__/conversation-runtime-assembly.test.ts +119 -8
  41. package/src/__tests__/conversation-skill-tools.test.ts +491 -5
  42. package/src/__tests__/conversation-slash-queue.test.ts +1 -1
  43. package/src/__tests__/conversation-slash-unknown.test.ts +1 -0
  44. package/src/__tests__/conversation-speed-override.test.ts +1 -0
  45. package/src/__tests__/conversation-store.test.ts +74 -0
  46. package/src/__tests__/conversation-surfaces-app-control.test.ts +4 -1
  47. package/src/__tests__/conversation-tool-setup-attribution.test.ts +323 -0
  48. package/src/__tests__/conversation-tool-setup-tools-disabled.test.ts +34 -0
  49. package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -0
  50. package/src/__tests__/conversation-workspace-injection.test.ts +1 -1
  51. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -0
  52. package/src/__tests__/corrected-target.test.ts +93 -0
  53. package/src/__tests__/credential-execution-feature-gates.test.ts +3 -5
  54. package/src/__tests__/credential-execution-tools.test.ts +23 -11
  55. package/src/__tests__/credential-security-invariants.test.ts +6 -1
  56. package/src/__tests__/db-schedule-syntax-migration.test.ts +80 -0
  57. package/src/__tests__/device-id.test.ts +70 -1
  58. package/src/__tests__/embedding-managed-proxy-selection.test.ts +6 -40
  59. package/src/__tests__/empty-response-hook.test.ts +242 -66
  60. package/src/__tests__/external-plugin-loader.test.ts +0 -31
  61. package/src/__tests__/get-skill-detail-audit.test.ts +43 -1
  62. package/src/__tests__/guardian-routing-invariants.test.ts +91 -0
  63. package/src/__tests__/history-repair-hook.test.ts +228 -3
  64. package/src/__tests__/host-app-control-proxy.test.ts +45 -0
  65. package/src/__tests__/host-browser-proxy.test.ts +254 -9
  66. package/src/__tests__/identity-routes.test.ts +1 -0
  67. package/src/__tests__/image-recovery-hook.test.ts +387 -0
  68. package/src/__tests__/injector-chain.test.ts +5 -4
  69. package/src/__tests__/injector-v3-suppression.test.ts +373 -47
  70. package/src/__tests__/intent-routing.test.ts +7 -0
  71. package/src/__tests__/memory-retrieval-hook.test.ts +117 -15
  72. package/src/__tests__/notification-decision-strategy.test.ts +3 -3
  73. package/src/__tests__/oauth-store.test.ts +0 -85
  74. package/src/__tests__/{context-overflow-policy.test.ts → overflow-policy.test.ts} +1 -1
  75. package/src/__tests__/parallel-tool.benchmark.test.ts +4 -0
  76. package/src/__tests__/persist-unsendable-image-downscale.test.ts +29 -9
  77. package/src/__tests__/persist-unsendable-image.test.ts +4 -4
  78. package/src/__tests__/persistence-secret-redaction.test.ts +78 -0
  79. package/src/__tests__/plugin-bootstrap.test.ts +82 -73
  80. package/src/__tests__/plugin-tool-contribution.test.ts +7 -4
  81. package/src/__tests__/plugin-types.test.ts +0 -8
  82. package/src/__tests__/provider-catalog-visibility.test.ts +1 -9
  83. package/src/__tests__/prune-old-conversations-job.test.ts +99 -0
  84. package/src/__tests__/registry.test.ts +240 -1
  85. package/src/__tests__/require-fresh-approval.test.ts +3 -0
  86. package/src/__tests__/schedule-routes.test.ts +116 -1
  87. package/src/__tests__/schedule-store.test.ts +28 -0
  88. package/src/__tests__/schedule-tools.test.ts +94 -1
  89. package/src/__tests__/server-history-render.test.ts +39 -0
  90. package/src/__tests__/skill-projection-feature-flag.test.ts +13 -0
  91. package/src/__tests__/skill-projection.benchmark.test.ts +25 -7
  92. package/src/__tests__/skills.test.ts +202 -0
  93. package/src/__tests__/slim-skill-category.test.ts +195 -0
  94. package/src/__tests__/strip-memory-injections.test.ts +33 -39
  95. package/src/__tests__/test-support/tool-invocation-seed.ts +79 -0
  96. package/src/__tests__/title-generate-hook.test.ts +9 -7
  97. package/src/__tests__/tool-audit-listener.test.ts +264 -1
  98. package/src/__tests__/tool-error-hook.test.ts +4 -3
  99. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +1 -0
  100. package/src/__tests__/tool-executor-lifecycle-events.test.ts +273 -0
  101. package/src/__tests__/tool-result-truncate-hook.test.ts +1 -0
  102. package/src/__tests__/tool-start-timestamp.test.ts +218 -0
  103. package/src/__tests__/tools-get-route.test.ts +202 -0
  104. package/src/__tests__/workspace-tool-loader.test.ts +319 -0
  105. package/src/__tests__/workspace-tools-watcher-flag.test.ts +70 -0
  106. package/src/agent/loop.ts +569 -319
  107. package/src/api/events/tool-result.ts +9 -0
  108. package/src/api/events/tool-use-start.ts +7 -0
  109. package/src/api/index.ts +10 -0
  110. package/src/api/responses/conversation-message.ts +135 -27
  111. package/src/api/responses/memory-v3-selection-log.ts +4 -4
  112. package/src/approvals/guardian-request-resolvers.ts +26 -0
  113. package/src/browser-session/backends/host-bridge.ts +29 -0
  114. package/src/browser-session/index.ts +1 -0
  115. package/src/browser-session/types.ts +5 -1
  116. package/src/cli/commands/__tests__/schedules.test.ts +62 -4
  117. package/src/cli/commands/__tests__/skills.test.ts +53 -0
  118. package/src/cli/commands/channel-verification-sessions.ts +6 -6
  119. package/src/cli/commands/inference-providers.ts +0 -8
  120. package/src/cli/commands/plugins.ts +2 -2
  121. package/src/cli/commands/schedules.ts +27 -4
  122. package/src/cli/commands/skills.ts +187 -146
  123. package/src/cli/commands/tools.ts +106 -0
  124. package/src/cli/lib/__tests__/install-from-github.test.ts +256 -328
  125. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +6 -2
  126. package/src/cli/lib/__tests__/plugin-details.test.ts +10 -16
  127. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +2 -2
  128. package/src/cli/lib/__tests__/search-plugins.test.ts +145 -240
  129. package/src/cli/lib/install-from-github.ts +187 -117
  130. package/src/cli/lib/plugin-catalog-cache.ts +9 -9
  131. package/src/cli/lib/plugin-details.ts +38 -68
  132. package/src/cli/lib/plugin-marketplace.ts +42 -14
  133. package/src/cli/lib/search-plugins.ts +29 -129
  134. package/src/cli/program.ts +2 -0
  135. package/src/config/bundled-skills/acp/SKILL.md +1 -0
  136. package/src/config/bundled-skills/app-builder/SKILL.md +1 -0
  137. package/src/config/bundled-skills/app-control/SKILL.md +1 -0
  138. package/src/config/bundled-skills/computer-use/SKILL.md +1 -0
  139. package/src/config/bundled-skills/contacts/SKILL.md +1 -0
  140. package/src/config/bundled-skills/document-editor/SKILL.md +1 -0
  141. package/src/config/bundled-skills/followups/SKILL.md +1 -0
  142. package/src/config/bundled-skills/image-studio/SKILL.md +1 -0
  143. package/src/config/bundled-skills/media-processing/SKILL.md +1 -0
  144. package/src/config/bundled-skills/messaging/SKILL.md +1 -0
  145. package/src/config/bundled-skills/phone-calls/SKILL.md +1 -0
  146. package/src/config/bundled-skills/playbooks/SKILL.md +1 -0
  147. package/src/config/bundled-skills/schedule/SKILL.md +1 -0
  148. package/src/config/bundled-skills/schedule/TOOLS.json +11 -3
  149. package/src/config/bundled-skills/sequences/SKILL.md +1 -0
  150. package/src/config/bundled-skills/settings/SKILL.md +1 -0
  151. package/src/config/bundled-skills/skill-management/SKILL.md +101 -1
  152. package/src/config/bundled-skills/subagent/SKILL.md +1 -0
  153. package/src/config/bundled-skills/transcribe/SKILL.md +1 -0
  154. package/src/config/env-registry.ts +23 -0
  155. package/src/config/feature-flag-registry.json +17 -81
  156. package/src/config/loader.ts +5 -22
  157. package/src/config/schema.ts +2 -0
  158. package/src/config/schemas/__tests__/compaction-logs.test.ts +56 -0
  159. package/src/config/schemas/__tests__/memory-v2.test.ts +0 -1
  160. package/src/config/schemas/__tests__/memory-v3.test.ts +61 -1
  161. package/src/config/schemas/compaction-logs.ts +79 -0
  162. package/src/config/schemas/memory-v2.ts +0 -8
  163. package/src/config/schemas/memory-v3.ts +104 -33
  164. package/src/config/seed-inference-profiles.ts +1 -1
  165. package/src/config/skills.ts +117 -47
  166. package/src/context/compactor.ts +11 -0
  167. package/src/context/strip-injections.ts +38 -4
  168. package/src/credential-execution/feature-gates.ts +0 -21
  169. package/src/credential-execution/startup-timeout.ts +32 -4
  170. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +18 -0
  171. package/src/daemon/conversation-agent-loop-handlers.ts +140 -91
  172. package/src/daemon/conversation-agent-loop.ts +123 -658
  173. package/src/daemon/conversation-error.ts +6 -33
  174. package/src/daemon/conversation-lifecycle.ts +1 -1
  175. package/src/daemon/conversation-runtime-assembly.ts +183 -22
  176. package/src/daemon/conversation-skill-tools.ts +137 -8
  177. package/src/daemon/conversation-slash.ts +0 -14
  178. package/src/daemon/conversation-store.ts +2 -19
  179. package/src/daemon/conversation-tool-setup.ts +87 -1
  180. package/src/daemon/conversation.ts +119 -50
  181. package/src/daemon/external-plugins-bootstrap.ts +36 -95
  182. package/src/daemon/handlers/config-channels.ts +11 -2
  183. package/src/daemon/handlers/shared.ts +9 -1
  184. package/src/daemon/handlers/skills.ts +10 -4
  185. package/src/daemon/host-app-control-proxy.ts +72 -57
  186. package/src/daemon/host-browser-proxy.ts +117 -22
  187. package/src/daemon/lifecycle.ts +34 -5
  188. package/src/daemon/message-protocol.ts +0 -7
  189. package/src/daemon/message-types/schedules.ts +1 -0
  190. package/src/daemon/message-types/skills.ts +17 -0
  191. package/src/daemon/providers-setup.ts +3 -0
  192. package/src/daemon/server.ts +3 -3
  193. package/src/daemon/tool-setup-types.ts +9 -3
  194. package/src/daemon/trust-context.ts +23 -0
  195. package/src/daemon/workspace-tools-watcher.ts +324 -0
  196. package/src/events/tool-audit-listener.ts +78 -16
  197. package/src/events/tool-metrics-listener.ts +2 -5
  198. package/src/memory/__tests__/compaction-log-writer-clickhouse.test.ts +227 -0
  199. package/src/memory/__tests__/conversation-queries.test.ts +176 -0
  200. package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +20 -32
  201. package/src/memory/compaction-log-writer-clickhouse.ts +418 -0
  202. package/src/memory/conversation-crud.ts +134 -3
  203. package/src/memory/conversation-queries.ts +64 -7
  204. package/src/memory/db-init.ts +14 -0
  205. package/src/memory/embedding-backend.test.ts +130 -1
  206. package/src/memory/embedding-backend.ts +79 -106
  207. package/src/memory/embedding-gemini.ts +5 -0
  208. package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +12 -0
  209. package/src/memory/graph/__tests__/handle-remember-v2.test.ts +19 -0
  210. package/src/memory/graph/conversation-graph-memory.ts +36 -25
  211. package/src/memory/graph/tool-handlers.ts +3 -0
  212. package/src/memory/job-handlers/cleanup.ts +3 -1
  213. package/src/memory/jobs-store.ts +0 -28
  214. package/src/memory/jobs-worker.ts +10 -22
  215. package/src/memory/memory-marker.ts +29 -0
  216. package/src/memory/memory-retrospective-startup-cleanup.ts +1 -1
  217. package/src/memory/migrations/268-add-memory-v3-selections.ts +6 -0
  218. package/src/memory/migrations/270-schedule-description.ts +36 -0
  219. package/src/memory/migrations/275-tool-invocations-add-skill-id.test.ts +81 -0
  220. package/src/memory/migrations/275-tool-invocations-add-skill-id.ts +20 -0
  221. package/src/memory/migrations/276-tool-invocations-created-at-id-index.test.ts +68 -0
  222. package/src/memory/migrations/276-tool-invocations-created-at-id-index.ts +20 -0
  223. package/src/memory/migrations/277-add-memory-v3-ever-injected.ts +29 -0
  224. package/src/memory/migrations/278-tool-invocations-telemetry-columns.test.ts +96 -0
  225. package/src/memory/migrations/278-tool-invocations-telemetry-columns.ts +39 -0
  226. package/src/memory/migrations/279-create-skill-loaded-events.test.ts +84 -0
  227. package/src/memory/migrations/279-create-skill-loaded-events.ts +26 -0
  228. package/src/memory/migrations/280-conversations-surfaced-at.test.ts +88 -0
  229. package/src/memory/migrations/280-conversations-surfaced-at.ts +24 -0
  230. package/src/memory/migrations/index.ts +10 -0
  231. package/src/memory/migrations/registry.ts +8 -0
  232. package/src/memory/schema/conversations.ts +16 -0
  233. package/src/memory/schema/infrastructure.ts +26 -0
  234. package/src/memory/skill-loaded-events-store.test.ts +160 -0
  235. package/src/memory/skill-loaded-events-store.ts +95 -0
  236. package/src/memory/tool-executed-events-store.test.ts +219 -0
  237. package/src/memory/tool-executed-events-store.ts +102 -0
  238. package/src/memory/tool-usage-store.ts +15 -3
  239. package/src/memory/v2/__tests__/consolidation-job.test.ts +117 -12
  240. package/src/memory/v2/__tests__/consolidation-prompt-flag-gating-guard.test.ts +189 -0
  241. package/src/memory/v2/__tests__/injected-block-slugs.test.ts +90 -0
  242. package/src/memory/v2/__tests__/page-store.test.ts +33 -0
  243. package/src/memory/v2/__tests__/prompts-consolidation.test.ts +88 -15
  244. package/src/memory/v2/activation-store.ts +50 -1
  245. package/src/memory/v2/consolidation-job.ts +113 -29
  246. package/src/memory/v2/injected-block-slugs.ts +79 -0
  247. package/src/memory/v2/injection.ts +6 -1
  248. package/src/memory/v2/prompts/consolidation.ts +414 -13
  249. package/src/memory/v2/router.ts +2 -28
  250. package/src/memory/v2/static-context.ts +1 -1
  251. package/src/memory/v2/types.ts +16 -0
  252. package/src/notifications/__tests__/copy-composer.test.ts +244 -0
  253. package/src/notifications/access-request-copy.ts +298 -0
  254. package/src/notifications/adapters/slack.ts +3 -3
  255. package/src/notifications/adapters/telegram.ts +2 -1
  256. package/src/notifications/copy-composer.ts +49 -267
  257. package/src/notifications/decision-engine.ts +16 -35
  258. package/src/notifications/home-feed-side-effect.ts +1 -6
  259. package/src/oauth/oauth-store.ts +0 -9
  260. package/src/permissions/checker.test.ts +83 -1
  261. package/src/permissions/checker.ts +25 -2
  262. package/src/permissions/gateway-threshold-reader.test.ts +182 -0
  263. package/src/permissions/gateway-threshold-reader.ts +80 -0
  264. package/src/platform/client.ts +1 -3
  265. package/src/platform/feature-gate.ts +3 -12
  266. package/src/plugin-api/constants.ts +4 -2
  267. package/src/plugin-api/index.ts +63 -11
  268. package/src/plugin-api/types.ts +236 -71
  269. package/src/plugins/defaults/compaction/compact.ts +66 -2
  270. package/src/plugins/defaults/compaction/context-overflow-reducer.ts +240 -32
  271. package/src/plugins/defaults/compaction/corrected-target.ts +53 -0
  272. package/src/{daemon/context-overflow-policy.ts → plugins/defaults/compaction/overflow-policy.ts} +1 -1
  273. package/src/plugins/defaults/compaction/window-manager.ts +303 -1
  274. package/src/plugins/defaults/empty-response/hooks/post-model-call.ts +173 -0
  275. package/src/plugins/defaults/empty-response/hooks/stop.ts +11 -115
  276. package/src/plugins/defaults/empty-response/nudge-state-store.ts +46 -0
  277. package/src/plugins/defaults/history-repair/hooks/post-model-call.ts +50 -0
  278. package/src/plugins/defaults/history-repair/hooks/stop.ts +22 -0
  279. package/src/plugins/defaults/history-repair/repair-state-store.ts +51 -0
  280. package/src/plugins/defaults/history-repair/terminal.ts +39 -2
  281. package/src/plugins/defaults/image-recovery/detect.ts +25 -0
  282. package/src/plugins/defaults/image-recovery/hooks/post-model-call.ts +73 -0
  283. package/src/plugins/defaults/image-recovery/hooks/stop.ts +22 -0
  284. package/src/plugins/defaults/image-recovery/image-recovery-state-store.ts +48 -0
  285. package/src/plugins/defaults/image-recovery/package.json +14 -0
  286. package/src/{daemon/persist-unsendable-image.ts → plugins/defaults/image-recovery/recover.ts} +67 -14
  287. package/src/plugins/defaults/index.ts +71 -5
  288. package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +76 -112
  289. package/src/plugins/defaults/memory-retrieval/hooks/{user-prompt-submit-temp.ts → user-prompt-submit.ts} +83 -74
  290. package/src/plugins/defaults/memory-retrieval/injector-chain.ts +14 -8
  291. package/src/plugins/defaults/memory-retrieval/injectors.ts +2 -18
  292. package/src/plugins/defaults/memory-retrieval/package.json +14 -0
  293. package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +1157 -0
  294. package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +683 -0
  295. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +161 -140
  296. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +160 -0
  297. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +335 -316
  298. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +145 -53
  299. package/src/plugins/defaults/memory-v3-shadow/__tests__/render-injection.test.ts +38 -1
  300. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +18 -8
  301. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +112 -71
  302. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +192 -51
  303. package/src/plugins/defaults/memory-v3-shadow/__tests__/types.test.ts +4 -16
  304. package/src/plugins/defaults/memory-v3-shadow/card.test.ts +173 -0
  305. package/src/plugins/defaults/memory-v3-shadow/card.ts +116 -0
  306. package/src/plugins/defaults/memory-v3-shadow/core-set.test.ts +104 -0
  307. package/src/plugins/defaults/memory-v3-shadow/core-set.ts +59 -0
  308. package/src/plugins/defaults/memory-v3-shadow/ever-injected-store.test.ts +305 -0
  309. package/src/plugins/defaults/memory-v3-shadow/ever-injected-store.ts +278 -0
  310. package/src/plugins/defaults/memory-v3-shadow/hot-set.test.ts +138 -0
  311. package/src/plugins/defaults/memory-v3-shadow/hot-set.ts +85 -0
  312. package/src/plugins/defaults/memory-v3-shadow/injector.ts +331 -24
  313. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +119 -13
  314. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +169 -114
  315. package/src/plugins/defaults/memory-v3-shadow/page-content.ts +47 -16
  316. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +144 -66
  317. package/src/plugins/defaults/memory-v3-shadow/prune.test.ts +758 -0
  318. package/src/plugins/defaults/memory-v3-shadow/prune.ts +471 -0
  319. package/src/plugins/defaults/memory-v3-shadow/render-injection.ts +68 -16
  320. package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +19 -12
  321. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +96 -43
  322. package/src/plugins/defaults/memory-v3-shadow/types.ts +34 -17
  323. package/src/plugins/defaults/title-generate/hooks/stop.ts +9 -11
  324. package/src/plugins/pipeline.ts +8 -5
  325. package/src/plugins/types.ts +5 -51
  326. package/src/providers/cache-control.ts +26 -0
  327. package/src/providers/inference/__tests__/base-url-route-validation.test.ts +1 -2
  328. package/src/providers/model-catalog.ts +13 -1
  329. package/src/providers/openai/__tests__/tool-choice-mapping.test.ts +147 -0
  330. package/src/providers/openai/chat-completions-provider.ts +46 -0
  331. package/src/providers/openai/responses-provider.ts +45 -0
  332. package/src/providers/registry.ts +0 -8
  333. package/src/runtime/__tests__/agent-wake.test.ts +0 -1
  334. package/src/runtime/agent-wake.ts +13 -0
  335. package/src/runtime/routes/__tests__/acp-routes.test.ts +151 -0
  336. package/src/runtime/routes/__tests__/consolidation-routes.test.ts +12 -50
  337. package/src/runtime/routes/__tests__/conversation-surface-routes.test.ts +322 -0
  338. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +0 -62
  339. package/src/runtime/routes/__tests__/plugins-routes.test.ts +31 -11
  340. package/src/runtime/routes/acp-routes.test.ts +106 -0
  341. package/src/runtime/routes/acp-routes.ts +248 -2
  342. package/src/runtime/routes/browser-tabs-routes.ts +1 -1
  343. package/src/runtime/routes/channel-verification-routes.ts +14 -5
  344. package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +0 -14
  345. package/src/runtime/routes/consolidation-routes.ts +6 -82
  346. package/src/runtime/routes/conversation-list-routes.ts +6 -0
  347. package/src/runtime/routes/conversation-management-routes.ts +71 -0
  348. package/src/runtime/routes/identity-routes.ts +8 -0
  349. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +33 -34
  350. package/src/runtime/routes/inference-provider-connection-routes.ts +0 -45
  351. package/src/runtime/routes/plugins-routes.ts +34 -45
  352. package/src/runtime/routes/schedule-routes.ts +43 -5
  353. package/src/runtime/routes/settings-routes.ts +140 -15
  354. package/src/runtime/routes/skills-routes.ts +18 -6
  355. package/src/runtime/services/__tests__/conversation-serializer.test.ts +140 -0
  356. package/src/runtime/services/conversation-serializer.ts +38 -1
  357. package/src/runtime/verification-outbound-actions.ts +147 -2
  358. package/src/runtime/verification-templates.ts +29 -3
  359. package/src/schedule/schedule-store.ts +19 -0
  360. package/src/skills/catalog-install.ts +77 -13
  361. package/src/tasks/task-scheduler.ts +1 -0
  362. package/src/telemetry/types.ts +66 -1
  363. package/src/telemetry/usage-telemetry-reporter.test.ts +542 -13
  364. package/src/telemetry/usage-telemetry-reporter.ts +213 -20
  365. package/src/tools/browser/__tests__/browser-execution-acquire.test.ts +49 -2
  366. package/src/tools/browser/__tests__/browser-status.test.ts +29 -5
  367. package/src/tools/browser/browser-execution.ts +27 -9
  368. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +380 -4
  369. package/src/tools/browser/cdp-client/__tests__/host-bridge-cdp-client.test.ts +107 -0
  370. package/src/tools/browser/cdp-client/__tests__/types.test.ts +6 -1
  371. package/src/tools/browser/cdp-client/factory.ts +217 -17
  372. package/src/tools/browser/cdp-client/host-bridge-cdp-client.ts +67 -0
  373. package/src/tools/browser/cdp-client/types.ts +22 -2
  374. package/src/tools/credential-execution/make-authenticated-request.ts +2 -1
  375. package/src/tools/credential-execution/manage-secure-command-tool.ts +169 -164
  376. package/src/tools/credential-execution/run-authenticated-command.ts +2 -1
  377. package/src/tools/executor.ts +39 -7
  378. package/src/tools/registry.ts +387 -5
  379. package/src/tools/schedule/create.ts +16 -0
  380. package/src/tools/schedule/list.ts +12 -4
  381. package/src/tools/schedule/update.ts +12 -0
  382. package/src/tools/skills/load.ts +11 -6
  383. package/src/tools/terminal/safe-env.ts +2 -0
  384. package/src/tools/types.ts +65 -9
  385. package/src/tools/workspace-tools/loader.ts +673 -0
  386. package/src/usage/attribution.ts +28 -0
  387. package/src/util/device-id.ts +17 -3
  388. package/src/util/platform.ts +16 -0
  389. package/tsconfig.plugin-api.json +13 -0
  390. package/src/__tests__/plugin-external-api.test.ts +0 -68
  391. package/src/__tests__/plugin-skill-contribution.test.ts +0 -355
  392. package/src/daemon/message-types/browser.ts +0 -10
  393. package/src/notifications/__tests__/emit-signal-home-feed.test.ts +0 -187
  394. package/src/plugins/defaults/memory-v3-shadow/__tests__/fixtures/eval-turns.json +0 -36
  395. package/src/plugins/defaults/memory-v3-shadow/__tests__/fixtures/live-turns.json +0 -37
  396. package/src/plugins/defaults/memory-v3-shadow/__tests__/working-set-eviction.test.ts +0 -106
  397. package/src/plugins/defaults/memory-v3-shadow/__tests__/working-set-skeleton.test.ts +0 -44
  398. package/src/plugins/defaults/memory-v3-shadow/working-set.ts +0 -91
  399. package/src/plugins/external-api.ts +0 -114
  400. package/src/plugins/plugin-skill-contributions.ts +0 -292
@@ -1,17 +1,17 @@
1
1
  /**
2
- * Tests for `orchestrate.ts` (section-lane pipeline).
2
+ * Tests for `orchestrate.ts` (cache-ordered candidate pool).
3
3
  *
4
- * The orchestrator composes three deterministic candidate lanes — the
5
- * section-grain BM25 needle, the dense lane, and link-graph edge expansion —
6
- * into ONE unified pool, runs a SINGLE forced-tool select over the pool, then
7
- * carries forward the working set. Synthetic capability pages (skills / CLI
8
- * commands) are indexed like any other page, so they enter the pool through the
9
- * lanes (e.g. the needle) rather than being always-added.
4
+ * The orchestrator composes the stable-prefix lanes (curated core + frecency
5
+ * hot, both computed at lane init and passed in) with three deterministic
6
+ * finder lanes — the section-grain BM25 needle, the dense lane, and link-graph
7
+ * edge expansion — into ONE cache-ordered pool, then runs a SINGLE forced-tool
8
+ * select over it. The result is this turn's selections only; cross-turn
9
+ * persistence is the injector's job, not the orchestrator's.
10
10
  *
11
11
  * The select provider is stubbed (no network); a single stub answers the one
12
12
  * `select_pages` call per turn by reading the numbered `<candidates>` block.
13
13
  * The dense lane is stubbed at the module boundary. The needle and edge graph
14
- * are real, built from tiny inline fixtures so the pool union is exercised
14
+ * are real, built from tiny inline fixtures so the pool assembly is exercised
15
15
  * end-to-end.
16
16
  */
17
17
 
@@ -23,8 +23,10 @@ import type {
23
23
  Provider,
24
24
  ProviderResponse,
25
25
  } from "../../../../providers/types.js";
26
+ import { renderCard } from "../card.js";
26
27
  import type { EdgeGraph } from "../edge.js";
27
28
  import { buildEdgeGraph } from "../edge.js";
29
+ import type { OrchestrateDeps } from "../orchestrate.js";
28
30
  import { buildSectionNeedle } from "../section-needle.js";
29
31
  import { buildSectionIndex } from "../sections.js";
30
32
  import type { MemoryRoutingTurn, SectionIndex, Slug } from "../types.js";
@@ -66,7 +68,6 @@ mock.module("../dense.js", () => ({
66
68
 
67
69
  const { orchestrate, DEFAULT_NEEDLE_K, DEFAULT_DENSE_K } =
68
70
  await import("../orchestrate.js");
69
- const { WorkingSet } = await import("../working-set.js");
70
71
 
71
72
  // ---------------------------------------------------------------------------
72
73
  // Fixtures: a tiny corpus of pages with bodies + `links:` frontmatter.
@@ -100,11 +101,13 @@ function makeEntries(): PageIndexEntry[] {
100
101
  }));
101
102
  }
102
103
 
103
- async function buildLanes(): Promise<{
104
+ interface Lanes {
104
105
  sectionIndex: SectionIndex;
105
106
  needle: ReturnType<typeof buildSectionNeedle>;
106
107
  edgeGraph: EdgeGraph;
107
- }> {
108
+ }
109
+
110
+ async function buildLanes(): Promise<Lanes> {
108
111
  const sectionIndex = await buildSectionIndex(SLUGS, async (s) => PAGES[s]!);
109
112
  const needle = buildSectionNeedle(sectionIndex);
110
113
  const edgeGraph = await buildEdgeGraph(makeEntries(), async (s) => RAW[s]!);
@@ -113,6 +116,35 @@ async function buildLanes(): Promise<{
113
116
 
114
117
  const config = {} as never;
115
118
 
119
+ /**
120
+ * Orchestrate deps with empty stable-prefix lanes unless overridden. Mirrors
121
+ * lane init: every core/hot slug gets a pre-rendered card (from the RAW
122
+ * fixture when one exists) unless the test overrides `prefixCards` itself.
123
+ */
124
+ function depsOf(
125
+ lanes: Lanes,
126
+ overrides: Partial<OrchestrateDeps> = {},
127
+ ): OrchestrateDeps {
128
+ const coreSlugs = overrides.coreSlugs ?? [];
129
+ const hotSlugs = overrides.hotSlugs ?? [];
130
+ const prefixCards = new Map<Slug, string>(
131
+ [...coreSlugs, ...hotSlugs].map((slug) => [
132
+ slug,
133
+ renderCard(slug, RAW[slug] ?? ""),
134
+ ]),
135
+ );
136
+ return {
137
+ sectionIndex: lanes.sectionIndex,
138
+ needle: lanes.needle,
139
+ denseConfig: config,
140
+ edgeGraph: lanes.edgeGraph,
141
+ coreSlugs,
142
+ hotSlugs,
143
+ prefixCards,
144
+ ...overrides,
145
+ };
146
+ }
147
+
116
148
  function makeTurn(
117
149
  turnNumber: number,
118
150
  currentMessage: string,
@@ -134,39 +166,74 @@ function toolUseResponse(input: Record<string, unknown>): ProviderResponse {
134
166
  };
135
167
  }
136
168
 
137
- /** Parse the numbered `<candidates>` block back into an ordered slug list. */
138
- function candidateSlugs(messages: Message[]): Slug[] {
169
+ /**
170
+ * Parse the two-segment selector input back into the globally-numbered pool:
171
+ * stable-prefix cards (`<candidate_cards>`, identified by their
172
+ * `[i] # memory/concepts/<slug>.md` header line) and finder lines
173
+ * (`<candidates>`, `[i] slug — descriptor`). Also captures the raw stable
174
+ * prefix block for byte-identity assertions.
175
+ */
176
+ function parsePool(messages: Message[]): {
177
+ slugs: Slug[];
178
+ lines: string[];
179
+ prefixBlock: string | null;
180
+ } {
181
+ const entries: Array<{ id: number; slug: string; line: string }> = [];
182
+ let prefixBlock: string | null = null;
139
183
  for (const msg of messages) {
140
184
  for (const block of msg.content) {
141
185
  if (block.type !== "text") continue;
142
- const m = /<candidates>\n([\s\S]*?)\n<\/candidates>/.exec(block.text);
143
- if (!m) continue;
144
- return m[1]
145
- .split("\n")
146
- .map((line) => /^\[\d+\] (\S+) —/.exec(line)?.[1])
147
- .filter((s): s is string => !!s);
186
+ const cards = /<candidate_cards>\n([\s\S]*?)\n<\/candidate_cards>/.exec(
187
+ block.text,
188
+ );
189
+ if (cards) {
190
+ prefixBlock = cards[0];
191
+ for (const m of cards[1].matchAll(
192
+ /^\[(\d+)\] # memory\/concepts\/(.+)\.md$/gm,
193
+ )) {
194
+ entries.push({ id: Number(m[1]), slug: m[2]!, line: m[0] });
195
+ }
196
+ }
197
+ const finder = /<candidates>\n([\s\S]*?)\n<\/candidates>/.exec(
198
+ block.text,
199
+ );
200
+ if (finder) {
201
+ for (const line of finder[1].split("\n")) {
202
+ const m = /^\[(\d+)\] (\S+)(?: — |$)/.exec(line);
203
+ if (m) entries.push({ id: Number(m[1]), slug: m[2]!, line });
204
+ }
205
+ }
148
206
  }
149
207
  }
150
- return [];
208
+ entries.sort((a, b) => a.id - b.id);
209
+ return {
210
+ slugs: entries.map((e) => e.slug),
211
+ lines: entries.map((e) => e.line),
212
+ prefixBlock,
213
+ };
151
214
  }
152
215
 
153
216
  /**
154
217
  * Provider that selects the pool candidates whose slug is in `keep` (mapping
155
218
  * each back to its 1-based id), pinning those in `pin`. Captures the rendered
156
- * candidate list for pool assertions.
219
+ * candidate list (slugs and raw lines) for pool assertions.
157
220
  */
158
221
  let lastPool: Slug[] = [];
222
+ let lastPoolLines: string[] = [];
223
+ let lastPrefixBlock: string | null = null;
159
224
  let selectCalls = 0;
160
225
  function selectProvider(keep: Slug[], pin: Slug[] = []): Provider {
161
226
  return {
162
227
  name: "stub",
163
228
  sendMessage: async (messages) => {
164
229
  selectCalls++;
165
- const pool = candidateSlugs(messages);
166
- lastPool = pool;
230
+ const parsed = parsePool(messages);
231
+ lastPool = parsed.slugs;
232
+ lastPoolLines = parsed.lines;
233
+ lastPrefixBlock = parsed.prefixBlock;
167
234
  const ids: number[] = [];
168
235
  const pinned_ids: number[] = [];
169
- pool.forEach((slug, i) => {
236
+ parsed.slugs.forEach((slug, i) => {
170
237
  if (keep.includes(slug)) ids.push(i + 1);
171
238
  if (pin.includes(slug)) pinned_ids.push(i + 1);
172
239
  });
@@ -180,6 +247,8 @@ beforeEach(() => {
180
247
  providerStub = null;
181
248
  denseHits = [];
182
249
  lastPool = [];
250
+ lastPoolLines = [];
251
+ lastPrefixBlock = null;
183
252
  selectCalls = 0;
184
253
  });
185
254
 
@@ -188,9 +257,9 @@ afterAll(() => {
188
257
  });
189
258
 
190
259
  // ---------------------------------------------------------------------------
191
- // Pool composition: the candidate pool is the union of the lanes. Synthetic
192
- // capability pages are no longer always-added — they enter through a lane (see
193
- // the dedicated test below).
260
+ // Pool composition: the candidate pool is the cache-ordered union of the
261
+ // lanes. Synthetic capability pages are not always-added — they enter through
262
+ // a lane (see the dedicated test below).
194
263
  // ---------------------------------------------------------------------------
195
264
 
196
265
  describe("orchestrate — candidate pool composition", () => {
@@ -201,13 +270,7 @@ describe("orchestrate — candidate pool composition", () => {
201
270
  denseHits = [{ article: "topic-b", section: 0 }];
202
271
  providerStub = selectProvider([]); // selection is irrelevant to pool union
203
272
 
204
- await orchestrate(makeTurn(1, "apple"), {
205
- sectionIndex: lanes.sectionIndex,
206
- needle: lanes.needle,
207
- denseConfig: config,
208
- edgeGraph: lanes.edgeGraph,
209
- workingSet: new WorkingSet(),
210
- });
273
+ await orchestrate(makeTurn(1, "apple"), depsOf(lanes));
211
274
 
212
275
  expect(selectCalls).toBe(1);
213
276
  expect(new Set(lastPool)).toEqual(
@@ -230,13 +293,10 @@ describe("orchestrate — candidate pool composition", () => {
230
293
  const edgeGraph = await buildEdgeGraph(makeEntries(), async (s) => RAW[s]!);
231
294
 
232
295
  providerStub = selectProvider([]); // selection irrelevant to pool union
233
- await orchestrate(makeTurn(1, "kumquat"), {
234
- sectionIndex,
235
- needle,
236
- denseConfig: config,
237
- edgeGraph,
238
- workingSet: new WorkingSet(),
239
- });
296
+ await orchestrate(
297
+ makeTurn(1, "kumquat"),
298
+ depsOf({ sectionIndex, needle, edgeGraph }),
299
+ );
240
300
 
241
301
  // The needle ranked the capability page on the "kumquat" term, so it is in
242
302
  // the candidate pool.
@@ -245,30 +305,12 @@ describe("orchestrate — candidate pool composition", () => {
245
305
 
246
306
  test("edge curated link description becomes the edge candidate's descriptor", async () => {
247
307
  const lanes = await buildLanes();
248
- let descriptorLine = "";
249
- providerStub = {
250
- name: "stub",
251
- sendMessage: async (messages) => {
252
- for (const msg of messages) {
253
- for (const block of msg.content) {
254
- if (block.type !== "text") continue;
255
- const m = /\[\d+\] topic-d — ([^\n]+)/.exec(block.text);
256
- if (m) descriptorLine = m[1]!;
257
- }
258
- }
259
- return toolUseResponse({ ids: [] });
260
- },
261
- };
308
+ providerStub = selectProvider([]);
262
309
 
263
- await orchestrate(makeTurn(1, "apple"), {
264
- sectionIndex: lanes.sectionIndex,
265
- needle: lanes.needle,
266
- denseConfig: config,
267
- edgeGraph: lanes.edgeGraph,
268
- workingSet: new WorkingSet(),
269
- });
310
+ await orchestrate(makeTurn(1, "apple"), depsOf(lanes));
270
311
 
271
- expect(descriptorLine).toContain("the curated edge from a to d");
312
+ const line = lastPoolLines.find((l) => / topic-d — /.test(l));
313
+ expect(line).toContain("the curated edge from a to d");
272
314
  });
273
315
 
274
316
  test("needleK and denseK default to their constants", async () => {
@@ -282,31 +324,187 @@ describe("orchestrate — candidate pool composition", () => {
282
324
  bestSection: () => -1,
283
325
  };
284
326
  providerStub = selectProvider([]);
285
- await orchestrate(makeTurn(1, "x"), {
286
- sectionIndex: lanes.sectionIndex,
287
- needle,
288
- denseConfig: config,
289
- edgeGraph: lanes.edgeGraph,
290
- workingSet: new WorkingSet(),
291
- });
327
+ await orchestrate(makeTurn(1, "x"), depsOf(lanes, { needle }));
292
328
  expect(needleK).toBe(DEFAULT_NEEDLE_K);
293
329
  expect(DEFAULT_DENSE_K).toBe(100);
294
330
  });
295
331
 
296
- test("sectionBySlug is populated from matched lane sections", async () => {
332
+ test("matchedSections is populated from matched lane sections", async () => {
297
333
  const lanes = await buildLanes();
298
334
  denseHits = [];
299
335
  providerStub = selectProvider(["topic-a"]);
300
- const result = await orchestrate(makeTurn(1, "apple"), {
301
- sectionIndex: lanes.sectionIndex,
302
- needle: lanes.needle,
303
- denseConfig: config,
304
- edgeGraph: lanes.edgeGraph,
305
- workingSet: new WorkingSet(),
306
- });
336
+ const result = await orchestrate(makeTurn(1, "apple"), depsOf(lanes));
307
337
  // topic-a matched "apple" in its `## Details` section.
308
- expect(result.sectionBySlug.get("topic-a")?.article).toBe("topic-a");
309
- expect(result.sectionBySlug.get("topic-a")?.text).toContain("apple");
338
+ expect(result.matchedSections.get("topic-a")?.article).toBe("topic-a");
339
+ expect(result.matchedSections.get("topic-a")?.text).toContain("apple");
340
+ });
341
+ });
342
+
343
+ // ---------------------------------------------------------------------------
344
+ // Cache order: the pool's stable prefix is core (file order) then hot (score
345
+ // order); finder candidates follow, deduped against the prefix. The prefix is
346
+ // byte-identical across turns while the lanes are unchanged — that is the
347
+ // whole point of the ordering (selector-input KV cache).
348
+ // ---------------------------------------------------------------------------
349
+
350
+ describe("orchestrate — cache-ordered pool (core + hot + finders)", () => {
351
+ test("pool order is core, then hot, then finder candidates", async () => {
352
+ const lanes = await buildLanes();
353
+ // Core and hot pages do not match "apple"; the needle surfaces topic-a.
354
+ // topic-a links to topic-d, but topic-d is HOT (stable prefix), so the
355
+ // edge lane does not re-surface it.
356
+ denseHits = [{ article: "topic-b", section: 0 }];
357
+ providerStub = selectProvider([]);
358
+
359
+ const result = await orchestrate(
360
+ makeTurn(1, "apple"),
361
+ depsOf(lanes, { coreSlugs: ["topic-c"], hotSlugs: ["topic-d"] }),
362
+ );
363
+
364
+ expect(lastPool).toEqual(["topic-c", "topic-d", "topic-a", "topic-b"]);
365
+ expect(result.lanes.core).toEqual(["topic-c"]);
366
+ expect(result.lanes.hot).toEqual(["topic-d"]);
367
+ // The edge lane skipped topic-d (already in the stable prefix).
368
+ expect(result.lanes.finder.map((c) => c.slug)).toEqual([
369
+ "topic-a",
370
+ "topic-b",
371
+ ]);
372
+ });
373
+
374
+ test("the rendered stable prefix is byte-identical across turns with different queries", async () => {
375
+ const lanes = await buildLanes();
376
+ const deps = depsOf(lanes, {
377
+ coreSlugs: ["topic-c"],
378
+ hotSlugs: ["topic-b"],
379
+ });
380
+
381
+ providerStub = selectProvider([]);
382
+ await orchestrate(makeTurn(1, "apple"), deps);
383
+ const prefix1 = lastPrefixBlock;
384
+
385
+ await orchestrate(makeTurn(2, "grape"), deps);
386
+ const prefix2 = lastPrefixBlock;
387
+
388
+ // Stable-prefix cards are pre-rendered and query-independent, so the
389
+ // whole rendered cards block matches byte-for-byte across turns.
390
+ expect(prefix1).not.toBeNull();
391
+ expect(prefix2).toBe(prefix1!);
392
+ expect(prefix1).toContain("topic-c");
393
+ expect(prefix1).toContain("topic-b");
394
+ });
395
+
396
+ test("stable-prefix candidates render their pre-rendered cards verbatim", async () => {
397
+ const lanes = await buildLanes();
398
+ providerStub = selectProvider([]);
399
+
400
+ await orchestrate(
401
+ makeTurn(1, "zzzz"),
402
+ depsOf(lanes, {
403
+ coreSlugs: ["topic-c"],
404
+ hotSlugs: ["topic-d"],
405
+ prefixCards: new Map([
406
+ [
407
+ "topic-c",
408
+ "# memory/concepts/topic-c.md\nlead for topic c\n\n[sections: §Notes]",
409
+ ],
410
+ ["topic-d", "# memory/concepts/topic-d.md\nlead for topic d"],
411
+ ]),
412
+ }),
413
+ );
414
+
415
+ expect(lastPrefixBlock).toContain(
416
+ "[1] # memory/concepts/topic-c.md\nlead for topic c\n\n[sections: §Notes]",
417
+ );
418
+ expect(lastPrefixBlock).toContain(
419
+ "[2] # memory/concepts/topic-d.md\nlead for topic d",
420
+ );
421
+ });
422
+
423
+ test("a stable-prefix slug with no pre-rendered card throws (never silently degrades)", async () => {
424
+ const lanes = await buildLanes();
425
+ providerStub = selectProvider([]);
426
+
427
+ await expect(
428
+ orchestrate(
429
+ makeTurn(1, "zzzz"),
430
+ depsOf(lanes, {
431
+ coreSlugs: ["topic-c"],
432
+ hotSlugs: ["topic-d"],
433
+ // topic-d is missing — a lane-init bug; a degraded card would break
434
+ // the byte-stable-prefix contract, so orchestrate must throw.
435
+ prefixCards: new Map([["topic-c", renderCard("topic-c", "")]]),
436
+ }),
437
+ ),
438
+ ).rejects.toThrow('no pre-rendered card for stable-prefix slug "topic-d"');
439
+ });
440
+
441
+ test("a finder hit on a core page repeats as a finder line and dedupes on selection", async () => {
442
+ const lanes = await buildLanes();
443
+ // topic-a is CORE and the needle also hits it on "apple". The stub keeps
444
+ // BOTH occurrences (card id + finder-line id).
445
+ providerStub = selectProvider(["topic-a"]);
446
+
447
+ const result = await orchestrate(
448
+ makeTurn(1, "apple"),
449
+ depsOf(lanes, { coreSlugs: ["topic-a"] }),
450
+ );
451
+
452
+ // The pool lists topic-a twice — once as the stable-prefix card, once as
453
+ // a finder line carrying its CURRENT matched section (the tail is not
454
+ // deduped against the prefix, by design: filtering would not change the
455
+ // prefix here, but the snippet line is the page's current relevance).
456
+ expect(lastPool.filter((s) => s === "topic-a")).toHaveLength(2);
457
+ expect(lastPool[0]).toBe("topic-a");
458
+ // The finder line shows the matched-section snippet.
459
+ expect(lastPoolLines.find((l) => /^\[2\] topic-a — /.test(l))).toContain(
460
+ "apple",
461
+ );
462
+ // Selecting both ids still yields ONE selection (slug dedup), the finder
463
+ // lane records the hit, and the matched section survives downstream.
464
+ expect(result.selections).toEqual([{ slug: "topic-a", pinned: false }]);
465
+ expect(result.lanes.finder.map((c) => c.slug)).toContain("topic-a");
466
+ expect(result.matchedSections.get("topic-a")?.text).toContain("apple");
467
+ });
468
+
469
+ test("a hot slug duplicated into core is defensively dropped from hot", async () => {
470
+ const lanes = await buildLanes();
471
+ providerStub = selectProvider([]);
472
+
473
+ const result = await orchestrate(
474
+ makeTurn(1, "zzzz"),
475
+ depsOf(lanes, {
476
+ coreSlugs: ["topic-c"],
477
+ hotSlugs: ["topic-c", "topic-d"],
478
+ }),
479
+ );
480
+
481
+ expect(result.lanes.core).toEqual(["topic-c"]);
482
+ expect(result.lanes.hot).toEqual(["topic-d"]);
483
+ expect(lastPool).toEqual(["topic-c", "topic-d"]);
484
+ });
485
+
486
+ test("selections are current-turn only — no carried set is unioned in", async () => {
487
+ const lanes = await buildLanes();
488
+ const deps = depsOf(lanes, { coreSlugs: ["topic-c"] });
489
+
490
+ // Turn 1 selects topic-a.
491
+ providerStub = selectProvider(["topic-a"]);
492
+ const t1 = await orchestrate(makeTurn(1, "apple"), deps);
493
+ expect(t1.selections.map((s) => s.slug)).toEqual(["topic-a"]);
494
+
495
+ // Turn 2 selects only topic-b; topic-a does NOT reappear (cross-turn
496
+ // persistence is the injector's job now, not orchestration's).
497
+ denseHits = [{ article: "topic-b", section: 0 }];
498
+ providerStub = selectProvider(["topic-b"]);
499
+ const t2 = await orchestrate(makeTurn(2, "cherry"), deps);
500
+ expect(t2.selections.map((s) => s.slug)).toEqual(["topic-b"]);
501
+ });
502
+
503
+ test("pinned flags survive selection dedup", async () => {
504
+ const lanes = await buildLanes();
505
+ providerStub = selectProvider(["topic-a"], ["topic-a"]);
506
+ const result = await orchestrate(makeTurn(1, "apple"), depsOf(lanes));
507
+ expect(result.selections).toEqual([{ slug: "topic-a", pinned: true }]);
310
508
  });
311
509
  });
312
510
 
@@ -319,53 +517,18 @@ describe("orchestrate — candidate pool composition", () => {
319
517
  // ---------------------------------------------------------------------------
320
518
 
321
519
  describe("orchestrate — edge-only injection", () => {
322
- test("an edge-only page records NO sectionBySlug entry (→ full-page inject)", async () => {
520
+ test("an edge-only page records NO matchedSections entry (→ full-page inject)", async () => {
323
521
  const lanes = await buildLanes();
324
522
  // "apple" hits topic-a (needle); topic-a links to topic-d (edge-only — the
325
523
  // query never hits topic-d). Select topic-d so it is in the result.
326
524
  denseHits = [];
327
525
  providerStub = selectProvider(["topic-d"]);
328
- const result = await orchestrate(makeTurn(1, "apple"), {
329
- sectionIndex: lanes.sectionIndex,
330
- needle: lanes.needle,
331
- denseConfig: config,
332
- edgeGraph: lanes.edgeGraph,
333
- workingSet: new WorkingSet(),
334
- });
526
+ const result = await orchestrate(makeTurn(1, "apple"), depsOf(lanes));
335
527
 
336
- // topic-d was selected and injected, but with NO matched section — so
528
+ // topic-d was selected, but with NO matched section — so
337
529
  // `renderV3SectionContent(slug, undefined)` falls back to the full page.
338
- expect(result.finalInjection).toContain("topic-d");
339
- expect(result.sectionBySlug.has("topic-d")).toBe(false);
340
- });
341
-
342
- test("an edge-only page still carries the curated links description as its descriptor", async () => {
343
- const lanes = await buildLanes();
344
- let descriptorLine = "";
345
- providerStub = {
346
- name: "stub",
347
- sendMessage: async (messages) => {
348
- for (const msg of messages) {
349
- for (const block of msg.content) {
350
- if (block.type !== "text") continue;
351
- const m = /\[\d+\] topic-d — ([^\n]+)/.exec(block.text);
352
- if (m) descriptorLine = m[1]!;
353
- }
354
- }
355
- return toolUseResponse({ ids: [] });
356
- },
357
- };
358
-
359
- await orchestrate(makeTurn(1, "apple"), {
360
- sectionIndex: lanes.sectionIndex,
361
- needle: lanes.needle,
362
- denseConfig: config,
363
- edgeGraph: lanes.edgeGraph,
364
- workingSet: new WorkingSet(),
365
- });
366
-
367
- // The curated `links` description (not the lead text) is the descriptor.
368
- expect(descriptorLine).toContain("the curated edge from a to d");
530
+ expect(result.selections.map((s) => s.slug)).toContain("topic-d");
531
+ expect(result.matchedSections.has("topic-d")).toBe(false);
369
532
  });
370
533
 
371
534
  test("an edge-only page with no curated description falls back to bestSection text as the descriptor", async () => {
@@ -373,7 +536,7 @@ describe("orchestrate — edge-only injection", () => {
373
536
  // candidate's descriptor falls back to the page's best section against the
374
537
  // query. The query never hits dst-page, so bestSection returns its lead;
375
538
  // that lead text becomes the descriptor (and the page is still injected in
376
- // full, with no sectionBySlug entry).
539
+ // full, with no matchedSections entry).
377
540
  const pages: Record<Slug, string> = {
378
541
  "src-page": "lead for src\n## Body\nalpha bravo about src",
379
542
  "dst-page": "lead content for dst page\n## Extra\nnothing relevant here",
@@ -396,34 +559,19 @@ describe("orchestrate — edge-only injection", () => {
396
559
  const needle = buildSectionNeedle(sectionIndex);
397
560
  const edgeGraph = await buildEdgeGraph(entries, async (s) => raw[s]!);
398
561
 
399
- let descriptorLine = "";
400
- providerStub = {
401
- name: "stub",
402
- sendMessage: async (messages) => {
403
- for (const msg of messages) {
404
- for (const block of msg.content) {
405
- if (block.type !== "text") continue;
406
- const m = /\[\d+\] dst-page — ([^\n]+)/.exec(block.text);
407
- if (m) descriptorLine = m[1]!;
408
- }
409
- }
410
- return toolUseResponse({ ids: [] });
411
- },
412
- };
562
+ providerStub = selectProvider([]);
413
563
 
414
564
  // "alpha" hits src-page (needle); src-page links to dst-page (edge-only, no
415
565
  // curated description).
416
- const result = await orchestrate(makeTurn(1, "alpha"), {
417
- sectionIndex,
418
- needle,
419
- denseConfig: config,
420
- edgeGraph,
421
- workingSet: new WorkingSet(),
422
- });
566
+ const result = await orchestrate(
567
+ makeTurn(1, "alpha"),
568
+ depsOf({ sectionIndex, needle, edgeGraph }),
569
+ );
423
570
 
424
571
  // Descriptor fell back to dst-page's lead text; still no matched section.
425
- expect(descriptorLine).toContain("lead content for dst page");
426
- expect(result.sectionBySlug.has("dst-page")).toBe(false);
572
+ const line = lastPoolLines.find((l) => / dst-page — /.test(l));
573
+ expect(line).toContain("lead content for dst page");
574
+ expect(result.matchedSections.has("dst-page")).toBe(false);
427
575
  });
428
576
  });
429
577
 
@@ -443,202 +591,76 @@ describe("orchestrate — dense liveness filter", () => {
443
591
  { article: "gone-page", section: 0 },
444
592
  ];
445
593
  providerStub = selectProvider([]); // selection irrelevant to pool membership
446
- const result = await orchestrate(makeTurn(1, "apple"), {
447
- sectionIndex: lanes.sectionIndex,
448
- needle: lanes.needle,
449
- denseConfig: config,
450
- edgeGraph: lanes.edgeGraph,
451
- workingSet: new WorkingSet(),
452
- });
594
+ const result = await orchestrate(makeTurn(1, "apple"), depsOf(lanes));
453
595
 
454
596
  // The live dense hit is pooled; the deleted page is dropped entirely.
455
597
  expect(lastPool).toContain("topic-b");
456
598
  expect(lastPool).not.toContain("gone-page");
457
- expect(result.laneBySlug.has("gone-page")).toBe(false);
599
+ expect(result.lanes.finder.map((c) => c.slug)).not.toContain("gone-page");
600
+ });
601
+
602
+ test("a dense hit with an unresolvable ordinal falls back to the lead-section snippet", async () => {
603
+ const lanes = await buildLanes();
604
+ // Ordinal 99 resolves to no section, so the candidate carries no match
605
+ // text — its finder line falls back to the page's lead-section text.
606
+ denseHits = [{ article: "topic-b", section: 99 }];
607
+ providerStub = selectProvider([]);
608
+ await orchestrate(makeTurn(1, "zzzz"), depsOf(lanes));
609
+
610
+ const line = lastPoolLines.find((l) => / topic-b — /.test(l));
611
+ expect(line).toContain("lead for topic b");
458
612
  });
459
613
  });
460
614
 
461
615
  // ---------------------------------------------------------------------------
462
- // Lane provenance: each pooled slug records the candgen lane that FIRST
463
- // surfaced it (needle → dense → edge precedence), exposed via
464
- // `result.laneBySlug` so the selection telemetry can attribute true sources.
616
+ // Lane provenance: each finder candidate records the lane that FIRST surfaced
617
+ // it (needle → dense → edge precedence), exposed via `result.lanes.finder` so
618
+ // the selection telemetry can attribute true sources.
465
619
  // ---------------------------------------------------------------------------
466
620
 
467
- describe("orchestrate — lane provenance (laneBySlug)", () => {
468
- test("laneBySlug tags each pooled slug with its surfacing lane", async () => {
621
+ describe("orchestrate — finder lane provenance", () => {
622
+ test("each finder candidate is tagged with its surfacing lane", async () => {
469
623
  const lanes = await buildLanes();
470
624
  // "apple" hits topic-a (needle). Dense returns topic-b. topic-a links to
471
625
  // topic-d (edge). So each lane contributes exactly one distinct slug.
472
626
  denseHits = [{ article: "topic-b", section: 0 }];
473
627
  providerStub = selectProvider([]); // selection irrelevant to pool provenance
474
- const result = await orchestrate(makeTurn(1, "apple"), {
475
- sectionIndex: lanes.sectionIndex,
476
- needle: lanes.needle,
477
- denseConfig: config,
478
- edgeGraph: lanes.edgeGraph,
479
- workingSet: new WorkingSet(),
480
- });
628
+ const result = await orchestrate(makeTurn(1, "apple"), depsOf(lanes));
481
629
 
482
- expect(result.laneBySlug.get("topic-a")).toBe("needle");
483
- expect(result.laneBySlug.get("topic-b")).toBe("dense");
484
- expect(result.laneBySlug.get("topic-d")).toBe("edge");
630
+ const laneOf = new Map(result.lanes.finder.map((c) => [c.slug, c.lane]));
631
+ expect(laneOf.get("topic-a")).toBe("needle");
632
+ expect(laneOf.get("topic-b")).toBe("dense");
633
+ expect(laneOf.get("topic-d")).toBe("edge");
485
634
  });
486
635
 
487
636
  test("a slug surfaced by needle AND dense keeps the needle lane (first wins)", async () => {
488
637
  const lanes = await buildLanes();
489
638
  // topic-a is surfaced by the needle on "apple"; dense ALSO returns topic-a.
490
- // Needle runs first (step 2a before 2b), so the recorded lane stays needle.
639
+ // Needle runs first, so the recorded lane stays needle.
491
640
  denseHits = [{ article: "topic-a", section: 0 }];
492
641
  providerStub = selectProvider([]);
493
- const result = await orchestrate(makeTurn(1, "apple"), {
494
- sectionIndex: lanes.sectionIndex,
495
- needle: lanes.needle,
496
- denseConfig: config,
497
- edgeGraph: lanes.edgeGraph,
498
- workingSet: new WorkingSet(),
499
- });
500
-
501
- expect(result.laneBySlug.get("topic-a")).toBe("needle");
502
- });
503
- });
504
-
505
- // ---------------------------------------------------------------------------
506
- // Carry-forward: same working-set semantics as the prior tree pipeline.
507
- // ---------------------------------------------------------------------------
508
-
509
- describe("orchestrate — carry-forward", () => {
510
- test("a page selected+pinned in turn 1 carries into turn 2 without re-selection", async () => {
511
- const lanes = await buildLanes();
512
- const workingSet = new WorkingSet();
513
-
514
- // Turn 1 selects+pins topic-a.
515
- providerStub = selectProvider(["topic-a"], ["topic-a"]);
516
- await orchestrate(makeTurn(1, "apple"), {
517
- sectionIndex: lanes.sectionIndex,
518
- needle: lanes.needle,
519
- denseConfig: config,
520
- edgeGraph: lanes.edgeGraph,
521
- workingSet,
522
- });
523
-
524
- // Turn 2 selects a DIFFERENT page and never re-selects topic-a.
525
- denseHits = [{ article: "topic-b", section: 0 }];
526
- providerStub = selectProvider(["topic-b"]);
527
- const t2 = await orchestrate(makeTurn(2, "cherry"), {
528
- sectionIndex: lanes.sectionIndex,
529
- needle: lanes.needle,
530
- denseConfig: config,
531
- edgeGraph: lanes.edgeGraph,
532
- workingSet,
533
- });
534
-
535
- expect(t2.currentSelections.map((s) => s.slug)).not.toContain("topic-a");
536
- expect(t2.finalInjection).toContain("topic-a");
537
- });
538
-
539
- test("carry-forward survives a turn whose selections fill the cap", async () => {
540
- const lanes = await buildLanes();
541
- // Cap of 1: under a naive record-then-cap order this turn's own selection
542
- // would evict the carried page before injection. Snapshotting the carry
543
- // BEFORE recording this turn keeps the earlier page in the injection.
544
- const workingSet = new WorkingSet(1);
545
-
546
- providerStub = selectProvider(["topic-a"]); // turn 1 → topic-a
547
- await orchestrate(makeTurn(1, "apple"), {
548
- sectionIndex: lanes.sectionIndex,
549
- needle: lanes.needle,
550
- denseConfig: config,
551
- edgeGraph: lanes.edgeGraph,
552
- workingSet,
553
- });
554
-
555
- denseHits = [{ article: "topic-b", section: 0 }];
556
- providerStub = selectProvider(["topic-b"]); // turn 2 → topic-b
557
- const t2 = await orchestrate(makeTurn(2, "cherry"), {
558
- sectionIndex: lanes.sectionIndex,
559
- needle: lanes.needle,
560
- denseConfig: config,
561
- edgeGraph: lanes.edgeGraph,
562
- workingSet,
563
- });
564
-
565
- expect(t2.currentSelections.map((s) => s.slug)).toEqual(["topic-b"]);
566
- expect(t2.finalInjection).toContain("topic-a"); // carried despite the cap
567
- });
568
-
569
- test("a stale non-pinned page ages out of the carry-forward window", async () => {
570
- const lanes = await buildLanes();
571
- // window 2: a non-pinned entry unseen for >2 turns evicts.
572
- const workingSet = new WorkingSet(150, 2);
573
-
574
- providerStub = selectProvider(["topic-a"]); // turn 1 selects topic-a
575
- const t1 = await orchestrate(makeTurn(1, "apple"), {
576
- sectionIndex: lanes.sectionIndex,
577
- needle: lanes.needle,
578
- denseConfig: config,
579
- edgeGraph: lanes.edgeGraph,
580
- workingSet,
581
- });
582
- expect(t1.finalInjection).toContain("topic-a");
583
-
584
- // Turns 2–4 never re-select topic-a. By turn 4 (4-1=3 > 2) it ages out.
585
- providerStub = selectProvider([]);
586
- await orchestrate(makeTurn(2, "x"), {
587
- sectionIndex: lanes.sectionIndex,
588
- needle: lanes.needle,
589
- denseConfig: config,
590
- edgeGraph: lanes.edgeGraph,
591
- workingSet,
592
- });
593
- await orchestrate(makeTurn(3, "x"), {
594
- sectionIndex: lanes.sectionIndex,
595
- needle: lanes.needle,
596
- denseConfig: config,
597
- edgeGraph: lanes.edgeGraph,
598
- workingSet,
599
- });
600
- const t4 = await orchestrate(makeTurn(4, "x"), {
601
- sectionIndex: lanes.sectionIndex,
602
- needle: lanes.needle,
603
- denseConfig: config,
604
- edgeGraph: lanes.edgeGraph,
605
- workingSet,
606
- });
607
- expect(t4.finalInjection).not.toContain("topic-a");
608
- });
642
+ const result = await orchestrate(makeTurn(1, "apple"), depsOf(lanes));
609
643
 
610
- test("pinned current-turn selections land in the working set", async () => {
611
- const lanes = await buildLanes();
612
- const ws = new WorkingSet();
613
- providerStub = selectProvider(["topic-a"], ["topic-a"]);
614
- await orchestrate(makeTurn(1, "apple"), {
615
- sectionIndex: lanes.sectionIndex,
616
- needle: lanes.needle,
617
- denseConfig: config,
618
- edgeGraph: lanes.edgeGraph,
619
- workingSet: ws,
620
- });
621
- expect(ws.union().has("topic-a")).toBe(true);
644
+ const entries = result.lanes.finder.filter((c) => c.slug === "topic-a");
645
+ expect(entries).toHaveLength(1);
646
+ expect(entries[0]!.lane).toBe("needle");
622
647
  });
623
648
  });
624
649
 
625
650
  // ---------------------------------------------------------------------------
626
- // Degradation: an empty pool and a provider-unavailable path are recall-safe.
651
+ // Degradation: an empty pool and the recall-safe omitted-ids path.
627
652
  // ---------------------------------------------------------------------------
628
653
 
629
654
  describe("orchestrate — degradation", () => {
630
- test("an empty pool yields no selections and an empty injection", async () => {
655
+ test("an empty pool yields no selections", async () => {
631
656
  const lanes = await buildLanes();
632
657
  providerStub = selectProvider([]);
633
- const result = await orchestrate(makeTurn(1, "zzzzz no-match"), {
634
- sectionIndex: lanes.sectionIndex,
635
- needle: { query: () => [], bestSection: () => -1 },
636
- denseConfig: config,
637
- edgeGraph: lanes.edgeGraph,
638
- workingSet: new WorkingSet(),
639
- });
640
- expect(result.currentSelections).toEqual([]);
641
- expect(result.finalInjection).toEqual([]);
658
+ const result = await orchestrate(
659
+ makeTurn(1, "zzzzz no-match"),
660
+ depsOf(lanes, { needle: { query: () => [], bestSection: () => -1 } }),
661
+ );
662
+ expect(result.selections).toEqual([]);
663
+ expect(result.lanes.finder).toEqual([]);
642
664
  });
643
665
 
644
666
  test("omitted ids keeps ALL pooled candidates (recall-safe)", async () => {
@@ -648,15 +670,12 @@ describe("orchestrate — degradation", () => {
648
670
  name: "stub",
649
671
  sendMessage: async () => toolUseResponse({}), // omitted ids → keep all
650
672
  };
651
- const result = await orchestrate(makeTurn(1, "apple"), {
652
- sectionIndex: lanes.sectionIndex,
653
- needle: lanes.needle,
654
- denseConfig: config,
655
- edgeGraph: lanes.edgeGraph,
656
- workingSet: new WorkingSet(),
657
- });
658
- expect(new Set(result.finalInjection)).toEqual(
659
- new Set(["topic-a", "topic-b", "topic-d"]),
673
+ const result = await orchestrate(
674
+ makeTurn(1, "apple"),
675
+ depsOf(lanes, { coreSlugs: ["topic-c"] }),
676
+ );
677
+ expect(new Set(result.selections.map((s) => s.slug))).toEqual(
678
+ new Set(["topic-c", "topic-a", "topic-b", "topic-d"]),
660
679
  );
661
680
  });
662
681
  });