@vellumai/assistant 0.8.4 → 0.8.5

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 (438) hide show
  1. package/ARCHITECTURE.md +2 -2
  2. package/docs/browser-use-architecture-phase2.md +1 -1
  3. package/knip.json +2 -1
  4. package/openapi.yaml +809 -11
  5. package/package.json +1 -1
  6. package/src/__tests__/anthropic-provider.test.ts +34 -37
  7. package/src/__tests__/assistant-event-hub-self-exclusion.test.ts +293 -0
  8. package/src/__tests__/assistant-feature-flags-integration.test.ts +3 -3
  9. package/src/__tests__/audit-log-rotation.test.ts +70 -16
  10. package/src/__tests__/background-workers-disk-pressure.test.ts +3 -3
  11. package/src/__tests__/btw-routes.test.ts +2 -3
  12. package/src/__tests__/call-controller.test.ts +0 -1
  13. package/src/__tests__/cancel-resolves-conversation-key.test.ts +1 -1
  14. package/src/__tests__/channel-guardian.test.ts +3 -3
  15. package/src/__tests__/checker.test.ts +6 -15
  16. package/src/__tests__/compaction-events.test.ts +1 -0
  17. package/src/__tests__/compactor-call-site-logging.test.ts +214 -0
  18. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +5 -11
  19. package/src/__tests__/computer-use-tools.test.ts +2 -4
  20. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +0 -1
  21. package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +1 -1
  22. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
  23. package/src/__tests__/conversation-agent-loop-overflow.test.ts +197 -2
  24. package/src/__tests__/conversation-agent-loop.test.ts +163 -122
  25. package/src/__tests__/conversation-app-control-instantiation.test.ts +2 -5
  26. package/src/__tests__/conversation-clear-safety.test.ts +25 -25
  27. package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +1 -1
  28. package/src/__tests__/conversation-disk-view-integration.test.ts +2 -2
  29. package/src/__tests__/conversation-error.test.ts +31 -0
  30. package/src/__tests__/conversation-fork-crud.test.ts +178 -15
  31. package/src/__tests__/conversation-lifecycle.test.ts +52 -11
  32. package/src/__tests__/{conversation-load-cleaned-at.test.ts → conversation-load-history-stripped.test.ts} +13 -13
  33. package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -0
  34. package/src/__tests__/conversation-routes-disk-view.test.ts +109 -0
  35. package/src/__tests__/conversation-routes-slash-commands.test.ts +35 -0
  36. package/src/__tests__/conversation-skill-tools.test.ts +2 -5
  37. package/src/__tests__/conversation-store.test.ts +1 -1
  38. package/src/__tests__/conversation-sync-tags.test.ts +99 -32
  39. package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -0
  40. package/src/__tests__/conversation-workspace-injection.test.ts +1 -1
  41. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
  42. package/src/__tests__/credential-execution-feature-gates.test.ts +9 -7
  43. package/src/__tests__/credential-execution-tools.test.ts +6 -6
  44. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  45. package/src/__tests__/credential-vault-unit.test.ts +2 -2
  46. package/src/__tests__/dynamic-page-surface.test.ts +2 -2
  47. package/src/__tests__/email-html-renderer.test.ts +12 -0
  48. package/src/__tests__/gateway-flag-listener.test.ts +237 -0
  49. package/src/__tests__/gemini-provider.test.ts +78 -0
  50. package/src/__tests__/guardian-dispatch.test.ts +0 -1
  51. package/src/__tests__/guardian-outbound-http.test.ts +7 -5
  52. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +1 -1
  53. package/src/__tests__/heartbeat-disk-pressure.test.ts +4 -0
  54. package/src/__tests__/heartbeat-service.test.ts +4 -0
  55. package/src/__tests__/host-shell-tool.test.ts +1 -1
  56. package/src/__tests__/init-feature-flag-overrides.test.ts +5 -6
  57. package/src/__tests__/list-messages-tool-merge.test.ts +70 -11
  58. package/src/__tests__/llm-request-log-call-site.test.ts +136 -0
  59. package/src/__tests__/llm-request-log-source-clickhouse.test.ts +26 -0
  60. package/src/__tests__/llm-resolver.test.ts +77 -9
  61. package/src/__tests__/llm-usage-store.test.ts +66 -0
  62. package/src/__tests__/logger.test.ts +89 -0
  63. package/src/__tests__/mcp-abort-signal.test.ts +2 -2
  64. package/src/__tests__/media-generate-image.test.ts +31 -0
  65. package/src/__tests__/memory-v2-static-injector.test.ts +7 -7
  66. package/src/__tests__/model-intents.test.ts +2 -4
  67. package/src/__tests__/notification-guardian-path.test.ts +0 -1
  68. package/src/__tests__/onboarding-template-contract.test.ts +1 -1
  69. package/src/__tests__/openai-provider.test.ts +46 -0
  70. package/src/__tests__/openai-responses-provider.test.ts +114 -12
  71. package/src/__tests__/pending-interactions-resolved-event.test.ts +0 -1
  72. package/src/__tests__/platform-bash-auto-approve.test.ts +2 -2
  73. package/src/__tests__/platform.test.ts +2 -2
  74. package/src/__tests__/plugin-api-tool-definition.test.ts +92 -0
  75. package/src/__tests__/plugin-bootstrap.test.ts +2 -2
  76. package/src/__tests__/plugin-tool-contribution.test.ts +13 -6
  77. package/src/__tests__/plugin-types.test.ts +3 -2
  78. package/src/__tests__/prechat-onboarding-contract.test.ts +131 -98
  79. package/src/__tests__/pricing.test.ts +12 -0
  80. package/src/__tests__/prune-jobs-changes-parser.test.ts +61 -0
  81. package/src/__tests__/registry.test.ts +2 -8
  82. package/src/__tests__/require-fresh-approval.test.ts +2 -2
  83. package/src/__tests__/runtime-events-sse-bilingual.test.ts +154 -0
  84. package/src/__tests__/shell-tool-proxy-mode.test.ts +1 -1
  85. package/src/__tests__/skill-feature-flags.test.ts +2 -2
  86. package/src/__tests__/skill-projection-feature-flag.test.ts +4 -7
  87. package/src/__tests__/skill-projection.benchmark.test.ts +2 -6
  88. package/src/__tests__/skill-tool-factory.test.ts +1 -1
  89. package/src/__tests__/subagent-notify-parent.test.ts +1 -1
  90. package/src/__tests__/suggestion-routes.test.ts +1 -0
  91. package/src/__tests__/sync-message-contract.test.ts +59 -0
  92. package/src/__tests__/system-prompt.test.ts +145 -131
  93. package/src/__tests__/terminal-tools.test.ts +1 -1
  94. package/src/__tests__/tool-approval-handler.test.ts +1 -5
  95. package/src/__tests__/tool-execute-pipeline.test.ts +2 -2
  96. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -5
  97. package/src/__tests__/tool-executor-lifecycle-events.test.ts +15 -5
  98. package/src/__tests__/tool-executor.test.ts +9 -62
  99. package/src/__tests__/tool-grant-request-escalation.test.ts +1 -6
  100. package/src/__tests__/trusted-contact-approval-notifier.test.ts +0 -1
  101. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1 -6
  102. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
  103. package/src/__tests__/ui-file-upload-surface.test.ts +2 -2
  104. package/src/__tests__/usage-routes.test.ts +3 -0
  105. package/src/__tests__/verification-control-plane-policy.test.ts +2 -2
  106. package/src/__tests__/workspace-git-service.test.ts +6 -5
  107. package/src/__tests__/workspace-migration-089-move-memory-tree-out-of-v3.test.ts +86 -0
  108. package/src/acp/__tests__/prepare-agent-env.test.ts +146 -0
  109. package/src/acp/prepare-agent-env.ts +78 -0
  110. package/src/acp/session-manager.ts +1 -1
  111. package/src/agent/loop.ts +8 -0
  112. package/src/api/README.md +5 -0
  113. package/src/api/index.ts +4 -0
  114. package/src/api/package.json +10 -0
  115. package/src/background-wake/background-wake-routes.test.ts +233 -0
  116. package/src/background-wake/runtime-registry.ts +24 -0
  117. package/src/cli/commands/__tests__/browser.test.ts +23 -5
  118. package/src/cli/commands/__tests__/domain-register.test.ts +110 -0
  119. package/src/cli/commands/__tests__/domain-status.test.ts +33 -33
  120. package/src/cli/commands/__tests__/inference-send.test.ts +108 -5
  121. package/src/cli/commands/__tests__/memory-v2-compare-render.test.ts +98 -0
  122. package/src/cli/commands/__tests__/memory-v2.test.ts +1 -0
  123. package/src/cli/commands/__tests__/memory-v3-render.test.ts +340 -0
  124. package/src/cli/commands/browser.ts +247 -0
  125. package/src/cli/commands/domain.ts +91 -41
  126. package/src/cli/commands/inference.ts +93 -40
  127. package/src/cli/commands/memory-v2-compare-render.ts +115 -0
  128. package/src/cli/commands/memory-v2.ts +176 -1
  129. package/src/cli/commands/memory-v3-render.ts +344 -0
  130. package/src/cli/commands/memory-v3.ts +316 -0
  131. package/src/cli/program.ts +2 -0
  132. package/src/config/assistant-feature-flags.ts +21 -9
  133. package/src/config/bundled-skills/document-editor/SKILL.md +11 -2
  134. package/src/config/bundled-skills/document-editor/TOOLS.json +18 -0
  135. package/src/config/bundled-skills/document-editor/tools/document-open.ts +12 -0
  136. package/src/config/bundled-skills/image-studio/SKILL.md +4 -0
  137. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +2 -2
  138. package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +13 -8
  139. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +10 -3
  140. package/src/config/bundled-skills/phone-calls/references/TRANSCRIPTS.md +16 -14
  141. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +7 -2
  142. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +7 -2
  143. package/src/config/bundled-tool-registry.ts +2 -0
  144. package/src/config/call-site-defaults.ts +7 -6
  145. package/src/config/feature-flag-registry.json +16 -0
  146. package/src/config/schemas/__tests__/memory-v2.test.ts +213 -1
  147. package/src/config/schemas/call-site-catalog.ts +21 -7
  148. package/src/config/schemas/llm.ts +12 -1
  149. package/src/config/schemas/memory-v2.ts +246 -0
  150. package/src/config/schemas/memory.ts +2 -1
  151. package/src/context/compactor.ts +52 -0
  152. package/src/conversations/__tests__/message-consolidation.test.ts +350 -0
  153. package/src/conversations/message-consolidation.ts +404 -0
  154. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +1 -1
  155. package/src/daemon/__tests__/meet-manifest-loader.test.ts +1 -1
  156. package/src/daemon/conversation-agent-loop-handlers.ts +2 -13
  157. package/src/daemon/conversation-agent-loop.ts +126 -76
  158. package/src/daemon/conversation-error.ts +31 -1
  159. package/src/daemon/conversation-lifecycle.ts +27 -22
  160. package/src/daemon/conversation-runtime-assembly.ts +10 -9
  161. package/src/daemon/conversation-tool-setup.ts +63 -3
  162. package/src/daemon/conversation-usage.ts +2 -0
  163. package/src/daemon/conversation.ts +14 -29
  164. package/src/daemon/disk-pressure-guard.ts +14 -2
  165. package/src/daemon/handlers/config-model.test.ts +1 -0
  166. package/src/daemon/handlers/conversations.ts +11 -3
  167. package/src/daemon/host-browser-proxy.ts +5 -5
  168. package/src/daemon/host-cu-proxy.ts +4 -4
  169. package/src/daemon/host-file-proxy.ts +4 -4
  170. package/src/daemon/host-proxy-base.ts +4 -4
  171. package/src/daemon/host-transfer-proxy.ts +10 -10
  172. package/src/daemon/lifecycle.ts +23 -20
  173. package/src/daemon/meet-manifest-loader.ts +1 -7
  174. package/src/daemon/message-types/conversations.ts +6 -9
  175. package/src/daemon/message-types/home.ts +1 -13
  176. package/src/daemon/message-types/messages.ts +6 -14
  177. package/src/daemon/message-types/sync.ts +14 -0
  178. package/src/daemon/shutdown-handlers.ts +24 -5
  179. package/src/daemon/switch-inference-profile-tool.ts +52 -0
  180. package/src/daemon/tool-setup-types.ts +13 -0
  181. package/src/events/relationship-state-updated.ts +25 -0
  182. package/src/heartbeat/__tests__/heartbeat-service.test.ts +1 -1
  183. package/src/home/home-greeting.ts +0 -9
  184. package/src/home/suggested-prompts.ts +0 -9
  185. package/src/ipc/gateway-flag-listener.ts +123 -0
  186. package/src/ipc/skill-routes/registries.ts +8 -12
  187. package/src/memory/__tests__/db-async-query.test.ts +165 -0
  188. package/src/memory/__tests__/db-maintenance.test.ts +115 -0
  189. package/src/memory/__tests__/jobs-store-enqueue-gate.test.ts +241 -0
  190. package/src/memory/__tests__/jobs-store-job-classes.test.ts +28 -1
  191. package/src/memory/__tests__/memory-retrospective-job.test.ts +7 -0
  192. package/src/memory/auto-analysis-enqueue.ts +5 -1
  193. package/src/memory/conversation-crud.ts +71 -70
  194. package/src/memory/conversation-starters-cadence.ts +3 -1
  195. package/src/memory/conversation-title-service.ts +19 -3
  196. package/src/memory/db-async-query.ts +214 -0
  197. package/src/memory/db-init.ts +10 -0
  198. package/src/memory/db-maintenance.ts +30 -21
  199. package/src/memory/graph/bootstrap.ts +8 -1
  200. package/src/memory/graph/capability-seed.ts +7 -3
  201. package/src/memory/graph/conversation-graph-memory.ts +100 -17
  202. package/src/memory/graph/extraction.ts +1 -5
  203. package/src/memory/graph/graph-search.ts +7 -1
  204. package/src/memory/indexer.ts +28 -18
  205. package/src/memory/job-handlers/cleanup.ts +76 -18
  206. package/src/memory/job-handlers/conversation-starters.ts +1 -4
  207. package/src/memory/jobs/embed-pkb-file.ts +6 -1
  208. package/src/memory/jobs-store.ts +14 -0
  209. package/src/memory/jobs-worker.ts +55 -22
  210. package/src/memory/llm-request-log-source-clickhouse.ts +42 -2
  211. package/src/memory/llm-request-log-source-local.ts +7 -0
  212. package/src/memory/llm-request-log-source.ts +9 -2
  213. package/src/memory/llm-request-log-store.ts +43 -1
  214. package/src/memory/llm-usage-store.ts +24 -0
  215. package/src/memory/memory-retrospective-enqueue.ts +8 -1
  216. package/src/memory/memory-retrospective-job.ts +5 -0
  217. package/src/memory/memory-v2-activation-log-store.ts +15 -6
  218. package/src/memory/migrations/260-rename-cleaned-at.ts +44 -0
  219. package/src/memory/migrations/261-llm-usage-add-raw-usage.ts +36 -0
  220. package/src/memory/migrations/262-memory-v3-coactivation.ts +57 -0
  221. package/src/memory/migrations/263-memory-v3-auto-edges.ts +50 -0
  222. package/src/memory/migrations/264-llm-request-log-call-site.ts +29 -0
  223. package/src/memory/migrations/index.ts +17 -0
  224. package/src/memory/migrations/registry.ts +33 -0
  225. package/src/memory/schema/conversations.ts +1 -1
  226. package/src/memory/schema/infrastructure.ts +21 -0
  227. package/src/memory/tool-usage-store.ts +36 -8
  228. package/src/memory/v2/__tests__/consolidation-job.test.ts +1 -0
  229. package/src/memory/v2/__tests__/harness-compare.test.ts +186 -0
  230. package/src/memory/v2/__tests__/harness-metrics.test.ts +74 -0
  231. package/src/memory/v2/__tests__/harness-oracle.test.ts +257 -0
  232. package/src/memory/v2/__tests__/harness-replay-input.test.ts +225 -0
  233. package/src/memory/v2/__tests__/harness-runner.test.ts +109 -0
  234. package/src/memory/v2/__tests__/injection.test.ts +127 -98
  235. package/src/memory/v2/__tests__/qdrant.test.ts +36 -0
  236. package/src/memory/v2/__tests__/router.test.ts +171 -3
  237. package/src/memory/v2/harness/compare.ts +57 -0
  238. package/src/memory/v2/harness/metrics.ts +124 -0
  239. package/src/memory/v2/harness/oracle.ts +145 -0
  240. package/src/memory/v2/harness/replay-input.ts +224 -0
  241. package/src/memory/v2/harness/retriever.ts +74 -0
  242. package/src/memory/v2/harness/router-retriever.ts +43 -0
  243. package/src/memory/v2/harness/runner.ts +106 -0
  244. package/src/memory/v2/harness/trace.ts +58 -0
  245. package/src/memory/v2/injection.ts +21 -15
  246. package/src/memory/v2/prompts/router.ts +26 -1
  247. package/src/memory/v2/qdrant.ts +14 -2
  248. package/src/memory/v2/router.ts +171 -18
  249. package/src/memory/v3/__tests__/coactivation-store.test.ts +422 -0
  250. package/src/memory/v3/__tests__/consolidation-job.test.ts +468 -0
  251. package/src/memory/v3/__tests__/edge-learning-job.test.ts +324 -0
  252. package/src/memory/v3/__tests__/edges.test.ts +563 -0
  253. package/src/memory/v3/__tests__/filter.test.ts +512 -0
  254. package/src/memory/v3/__tests__/gate.test.ts +574 -0
  255. package/src/memory/v3/__tests__/index-composition.test.ts +233 -0
  256. package/src/memory/v3/__tests__/loop.test.ts +530 -0
  257. package/src/memory/v3/__tests__/retriever.test.ts +226 -0
  258. package/src/memory/v3/__tests__/scouts.test.ts +440 -0
  259. package/src/memory/v3/__tests__/shadow-middleware.test.ts +312 -0
  260. package/src/memory/v3/__tests__/system-prompts.test.ts +154 -0
  261. package/src/memory/v3/__tests__/traversal.test.ts +469 -0
  262. package/src/memory/v3/__tests__/tree-index.test.ts +280 -0
  263. package/src/memory/v3/__tests__/tree-store.test.ts +529 -0
  264. package/src/memory/v3/__tests__/tree-walk.test.ts +707 -0
  265. package/src/memory/v3/__tests__/validate.test.ts +245 -0
  266. package/src/memory/v3/auto-edges.ts +223 -0
  267. package/src/memory/v3/coactivation-store.ts +124 -0
  268. package/src/memory/v3/consolidation-job.ts +323 -0
  269. package/src/memory/v3/edge-learning-job.ts +160 -0
  270. package/src/memory/v3/edges.ts +249 -0
  271. package/src/memory/v3/filter.ts +281 -0
  272. package/src/memory/v3/gate.ts +334 -0
  273. package/src/memory/v3/index-composition.ts +113 -0
  274. package/src/memory/v3/llm-capture.ts +46 -0
  275. package/src/memory/v3/loop.ts +382 -0
  276. package/src/memory/v3/maintenance.ts +144 -0
  277. package/src/memory/v3/prompt-context.ts +33 -0
  278. package/src/memory/v3/prompts/consolidation.ts +458 -0
  279. package/src/memory/v3/prompts/system-prompts.ts +196 -0
  280. package/src/memory/v3/retriever.ts +33 -0
  281. package/src/memory/v3/scouts.ts +420 -0
  282. package/src/memory/v3/shadow-middleware.ts +305 -0
  283. package/src/memory/v3/traversal.ts +206 -0
  284. package/src/memory/v3/tree-index.ts +237 -0
  285. package/src/memory/v3/tree-store.ts +394 -0
  286. package/src/memory/v3/tree-walk.ts +351 -0
  287. package/src/memory/v3/types.ts +65 -0
  288. package/src/memory/v3/validate.ts +300 -0
  289. package/src/notifications/adapters/macos.ts +18 -1
  290. package/src/notifications/adapters/platform.ts +1 -1
  291. package/src/notifications/decision-engine.ts +1 -4
  292. package/src/notifications/emit-signal.ts +29 -49
  293. package/src/permissions/prompter.ts +3 -3
  294. package/src/permissions/question-prompter.ts +5 -2
  295. package/src/permissions/secret-prompter.ts +2 -2
  296. package/src/plugin-api/index.ts +4 -0
  297. package/src/plugin-api/types.ts +7 -33
  298. package/src/plugins/defaults/index.ts +6 -0
  299. package/src/plugins/defaults/injectors.ts +18 -11
  300. package/src/plugins/external-plugin-loader.ts +5 -68
  301. package/src/plugins/types.ts +11 -16
  302. package/src/proactive-artifact/aux-message-injector.ts +17 -4
  303. package/src/prompts/__tests__/task-progress-hint-section.test.ts +3 -9
  304. package/src/prompts/persona-resolver.ts +36 -21
  305. package/src/prompts/sections.ts +39 -7
  306. package/src/prompts/system-prompt.ts +50 -185
  307. package/src/prompts/templates/BOOTSTRAP.md +2 -2
  308. package/src/prompts/templates/system-sections.ts +230 -8
  309. package/src/providers/__tests__/connection-model-compat.test.ts +234 -0
  310. package/src/providers/__tests__/retry-callsite.test.ts +85 -5
  311. package/src/providers/anthropic/client.ts +32 -66
  312. package/src/providers/call-site-routing.ts +14 -2
  313. package/src/providers/connection-model-compat.ts +38 -0
  314. package/src/providers/connection-resolution.ts +16 -2
  315. package/src/providers/gemini/client.ts +49 -6
  316. package/src/providers/inference/adapter-factory.ts +3 -0
  317. package/src/providers/minimax/client.ts +106 -0
  318. package/src/providers/model-catalog.ts +43 -0
  319. package/src/providers/model-intents.ts +1 -1
  320. package/src/providers/openai/chat-completions-provider.ts +6 -3
  321. package/src/providers/openai/codex-models.ts +18 -0
  322. package/src/providers/openai/responses-provider.ts +78 -21
  323. package/src/providers/provider-send-message.ts +7 -1
  324. package/src/providers/retry.ts +34 -3
  325. package/src/providers/thinking-config.ts +26 -1
  326. package/src/providers/usage-tracking.ts +2 -0
  327. package/src/runtime/AGENTS.md +2 -2
  328. package/src/runtime/agent-wake.ts +1 -0
  329. package/src/runtime/assistant-event-hub.ts +76 -6
  330. package/src/runtime/auth/route-policy.ts +36 -0
  331. package/src/runtime/btw-sidechain.ts +0 -6
  332. package/src/runtime/http-types.ts +0 -2
  333. package/src/runtime/migrations/vbundle-builder.ts +10 -3
  334. package/src/runtime/pending-interactions.ts +0 -1
  335. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +106 -0
  336. package/src/runtime/routes/__tests__/memory-v2-simulate-route.test.ts +25 -6
  337. package/src/runtime/routes/__tests__/plugins-routes.test.ts +512 -0
  338. package/src/runtime/routes/acp-routes.test.ts +255 -6
  339. package/src/runtime/routes/acp-routes.ts +8 -1
  340. package/src/runtime/routes/avatar-routes.ts +10 -10
  341. package/src/runtime/routes/background-wake-routes.ts +188 -0
  342. package/src/runtime/routes/browser-tabs-routes.ts +200 -0
  343. package/src/runtime/routes/btw-routes.ts +0 -6
  344. package/src/runtime/routes/conversation-cli-routes.ts +1 -1
  345. package/src/runtime/routes/conversation-list-routes.ts +12 -4
  346. package/src/runtime/routes/conversation-management-routes.ts +77 -20
  347. package/src/runtime/routes/conversation-query-routes.ts +142 -36
  348. package/src/runtime/routes/conversation-routes.ts +252 -410
  349. package/src/runtime/routes/conversation-starter-routes.ts +6 -3
  350. package/src/runtime/routes/disk-pressure-routes.ts +1 -1
  351. package/src/runtime/routes/domain-routes.ts +60 -10
  352. package/src/runtime/routes/email-routes.ts +5 -2
  353. package/src/runtime/routes/events-routes.ts +54 -10
  354. package/src/runtime/routes/group-routes.ts +24 -8
  355. package/src/runtime/routes/host-browser-routes.ts +10 -2
  356. package/src/runtime/routes/host-cu-routes.ts +2 -2
  357. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +96 -3
  358. package/src/runtime/routes/index.ts +8 -0
  359. package/src/runtime/routes/inference-profile-session-handler.ts +22 -12
  360. package/src/runtime/routes/inference-profile-session-routes.ts +7 -1
  361. package/src/runtime/routes/llm-call-sites-routes.ts +32 -5
  362. package/src/runtime/routes/memory-item-routes.ts +8 -3
  363. package/src/runtime/routes/memory-v2-routes.ts +215 -5
  364. package/src/runtime/routes/memory-v3-routes.ts +316 -0
  365. package/src/runtime/routes/migration-routes.ts +21 -24
  366. package/src/runtime/routes/plugins-routes.ts +337 -0
  367. package/src/runtime/routes/rename-conversation-routes.ts +6 -2
  368. package/src/runtime/routes/secret-routes.ts +25 -5
  369. package/src/runtime/routes/settings-routes.ts +12 -11
  370. package/src/runtime/routes/slack-channel-routes.ts +5 -4
  371. package/src/runtime/routes/workspace-routes.ts +25 -10
  372. package/src/runtime/sync/resource-sync-events.ts +106 -38
  373. package/src/runtime/sync/sync-publisher.test.ts +49 -0
  374. package/src/runtime/sync/sync-publisher.ts +2 -1
  375. package/src/runtime/verification-outbound-actions.ts +73 -1
  376. package/src/telemetry/types.ts +12 -0
  377. package/src/telemetry/usage-telemetry-reporter.test.ts +48 -0
  378. package/src/telemetry/usage-telemetry-reporter.ts +1 -0
  379. package/src/tools/acp/spawn.test.ts +119 -0
  380. package/src/tools/acp/spawn.ts +15 -2
  381. package/src/tools/apps/definitions.ts +2 -8
  382. package/src/tools/ask-question/ask-question-tool.test.ts +3 -3
  383. package/src/tools/ask-question/ask-question-tool.ts +38 -45
  384. package/src/tools/browser/__tests__/pinned-tabs.test.ts +70 -0
  385. package/src/tools/browser/browser-execution.ts +16 -3
  386. package/src/tools/browser/cdp-client/__tests__/browser-tabs-factory.test.ts +402 -0
  387. package/src/tools/browser/cdp-client/__tests__/types.test.ts +3 -0
  388. package/src/tools/browser/cdp-client/cdp-inspect-client.ts +12 -0
  389. package/src/tools/browser/cdp-client/extension-cdp-client.ts +27 -1
  390. package/src/tools/browser/cdp-client/factory.ts +100 -17
  391. package/src/tools/browser/cdp-client/local-cdp-client.ts +12 -0
  392. package/src/tools/browser/cdp-client/types.ts +65 -0
  393. package/src/tools/browser/pinned-tabs.ts +96 -40
  394. package/src/tools/computer-use/definitions.ts +22 -78
  395. package/src/tools/credential-execution/make-authenticated-request.ts +3 -9
  396. package/src/tools/credential-execution/manage-secure-command-tool.ts +3 -9
  397. package/src/tools/credential-execution/run-authenticated-command.ts +3 -9
  398. package/src/tools/credentials/vault.ts +3 -9
  399. package/src/tools/document/document-tool.ts +59 -0
  400. package/src/tools/execution-target.ts +21 -23
  401. package/src/tools/executor.ts +6 -1
  402. package/src/tools/filesystem/edit.ts +3 -9
  403. package/src/tools/filesystem/list.ts +3 -9
  404. package/src/tools/filesystem/read.ts +3 -9
  405. package/src/tools/filesystem/write.ts +3 -9
  406. package/src/tools/host-filesystem/edit.ts +3 -9
  407. package/src/tools/host-filesystem/read.ts +3 -9
  408. package/src/tools/host-filesystem/transfer.ts +3 -9
  409. package/src/tools/host-filesystem/write.ts +3 -9
  410. package/src/tools/host-terminal/host-shell.ts +3 -9
  411. package/src/tools/mcp/mcp-tool-factory.ts +1 -8
  412. package/src/tools/memory/register.test.ts +1 -1
  413. package/src/tools/memory/register.ts +4 -9
  414. package/src/tools/network/web-fetch.ts +3 -9
  415. package/src/tools/network/web-search.ts +25 -32
  416. package/src/tools/registry.ts +7 -23
  417. package/src/tools/schema-transforms.ts +1 -1
  418. package/src/tools/skills/execute.ts +3 -9
  419. package/src/tools/skills/load.ts +3 -9
  420. package/src/tools/skills/skill-tool-factory.ts +1 -8
  421. package/src/tools/subagent/notify-parent.ts +3 -9
  422. package/src/tools/system/request-permission.ts +3 -9
  423. package/src/tools/terminal/shell.ts +3 -9
  424. package/src/tools/tool-defaults.ts +94 -0
  425. package/src/tools/types.ts +27 -98
  426. package/src/tools/ui-surface/definitions.ts +6 -22
  427. package/src/usage/pricing.ts +23 -0
  428. package/src/usage/types.ts +12 -0
  429. package/src/util/logger.ts +16 -7
  430. package/src/util/platform.ts +7 -2
  431. package/src/util/sqlite3-runtime.ts +65 -0
  432. package/src/workspace/migrations/086-revert-stale-gemini-mis-rewrites.ts +1 -0
  433. package/src/workspace/migrations/089-move-memory-tree-out-of-v3.ts +86 -0
  434. package/src/workspace/migrations/registry.ts +2 -0
  435. package/src/__tests__/compaction-strip-metadata-clear.test.ts +0 -206
  436. package/src/__tests__/message-complete-display-id.test.ts +0 -175
  437. package/src/daemon/query-complexity-router.ts +0 -75
  438. package/src/prompts/cache-boundary.ts +0 -8
@@ -0,0 +1,33 @@
1
+ /**
2
+ * v3 retriever — the multi-lane bounded-descent retrieval loop
3
+ * ({@link runRetrievalLoop}) adapted to the harness {@link Retriever}
4
+ * interface.
5
+ *
6
+ * This is the offline, zero-production-risk shadow path: the comparison harness
7
+ * replays historical oracle turns and scores v3's selection against the v2
8
+ * router's logged picks (recall@k). Nothing here runs on a live injection turn
9
+ * — the loop reads the DB handle for its hot lane but never mutates production
10
+ * state, matching the {@link Retriever} contract.
11
+ */
12
+
13
+ import type { DrizzleDb } from "../db-connection.js";
14
+ import type {
15
+ RetrievalInput,
16
+ RetrievalOutput,
17
+ Retriever,
18
+ } from "../v2/harness/retriever.js";
19
+ import { runRetrievalLoop } from "./loop.js";
20
+
21
+ /**
22
+ * Wrap the v3 retrieval loop as a named harness {@link Retriever}.
23
+ *
24
+ * @param db handle threaded to {@link runRetrievalLoop} for the scout hot lane.
25
+ */
26
+ export function createV3Retriever(db: DrizzleDb): Retriever {
27
+ return {
28
+ name: "v3",
29
+ retrieve(input: RetrievalInput): Promise<RetrievalOutput> {
30
+ return runRetrievalLoop(input, { db });
31
+ },
32
+ };
33
+ }
@@ -0,0 +1,420 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Memory v3 — Always-on scout lanes (hot / sparse / dense)
3
+ // ---------------------------------------------------------------------------
4
+ //
5
+ // The v3 retrieval loop opens each pass by fanning out a small set of cheap,
6
+ // always-on "scout" lanes over the v2 read-substrate. Scouts surface candidate
7
+ // concept-page slugs from three complementary signals before any LLM judging
8
+ // (the dense judge lives in a later PR) or tree descent runs:
9
+ //
10
+ // - hot: corpus-global access-frequency EMA via `computeInjectionScores`.
11
+ // Retriever-agnostic — v2 keeps writing `memory_v2_injection_events`,
12
+ // so a page the user has been touching is "hot" regardless of which
13
+ // retriever surfaced it. Hits are seeded as ordinary **candidates**
14
+ // (not sticky) so the query-aware downstream gate can still drop a
15
+ // recency page that doesn't bear on the turn.
16
+ // - sparse: BM25 keyword match. Near-exact (high-score) hits are both
17
+ // **sticky** and **tree-bypass** — a literal keyword hit is a strong
18
+ // enough signal that we shouldn't make the slug earn its place by
19
+ // walking the tree.
20
+ // - dense: embedding-similarity match, then an asymmetric per-subtree quota
21
+ // (generous active-domain slice, thin off-domain slice) plus MMR for
22
+ // diversity so a single dominant subtree can't crowd out the slate.
23
+ //
24
+ // Each lane is individually toggleable via `config.memory.v3.lanes`. This module
25
+ // performs **no** LLM calls and writes nothing — it is a pure read over the v2
26
+ // substrate. A later PR composes `runScouts` into the full descent loop.
27
+
28
+ import type { AssistantConfig } from "../../config/types.js";
29
+ import { applyCorrectionIfCalibrated } from "../anisotropy.js";
30
+ import type { DrizzleDb } from "../db-connection.js";
31
+ import { embedWithBackend } from "../embedding-backend.js";
32
+ import type { RetrievalInput } from "../v2/harness/retriever.js";
33
+ import type { ScoutResult } from "../v2/harness/trace.js";
34
+ import { computeInjectionScores } from "../v2/injection-events.js";
35
+ import { getPageIndex } from "../v2/page-index.js";
36
+ import { hybridQueryConceptPages } from "../v2/qdrant.js";
37
+ import { generateBm25QueryEmbedding } from "../v2/sparse-bm25.js";
38
+
39
+ /** Result of running the always-on scout fanout for one pass. */
40
+ export interface RunScoutsResult {
41
+ /** Per-lane contributions, one entry per *enabled* lane that produced hits. */
42
+ scouts: ScoutResult[];
43
+ /**
44
+ * Slugs the downstream gate should keep in the running regardless of later
45
+ * scoring — near-exact sparse hits. Hot-lane hits are deliberately excluded:
46
+ * they contribute candidates but must earn their place through the gate.
47
+ */
48
+ sticky: Set<string>;
49
+ /**
50
+ * Slugs strong enough (near-exact sparse) to skip the tree-descent gate
51
+ * entirely. A subset of `sticky`.
52
+ */
53
+ bypass: Set<string>;
54
+ }
55
+
56
+ /** Substrate dependencies injected for testability. */
57
+ export interface ScoutDeps {
58
+ db: DrizzleDb;
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Tunables
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * Per-lane hit cap before quota/diversity post-processing. The lanes are
67
+ * always-on and run every pass, so a generous-but-bounded cap keeps the dense
68
+ * Qdrant round-trip and the per-lane bookkeeping cheap while still giving the
69
+ * quota/MMR step enough raw candidates to choose from.
70
+ */
71
+ const LANE_QUERY_LIMIT = 100;
72
+
73
+ /**
74
+ * Sparse score at or above which a hit is treated as **near-exact** — sticky
75
+ * and tree-bypass. BM25 scores are unbounded above and corpus-relative, so the
76
+ * threshold is taken relative to the top sparse hit in the same pass rather
77
+ * than as a fixed magnitude: a hit within this fraction of the best sparse
78
+ * score for the query is "near-exact". A lone strong hit (it is its own max)
79
+ * always qualifies.
80
+ */
81
+ const SPARSE_NEAR_EXACT_FRACTION = 0.9;
82
+
83
+ /**
84
+ * MMR trade-off: `λ · relevance − (1 − λ) · redundancy`. Closer to 1 favors
85
+ * raw dense relevance; lower values push harder for subtree diversity. 0.7
86
+ * keeps relevance in the driver's seat while still breaking up runs of
87
+ * same-subtree hits.
88
+ */
89
+ const DENSE_MMR_LAMBDA = 0.7;
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Public entry point
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /**
96
+ * Run the always-on scout lanes for one retrieval pass.
97
+ *
98
+ * The dense lane embeds `queryText` (the last user turn joined with
99
+ * `input.nowText`, the same shape the v2 router/activation path embeds). The
100
+ * sparse lane keys off `userText` (the user turn alone) so NOW context can't
101
+ * make whatever it mentions a near-exact sticky hit on every turn. Disabled
102
+ * lanes (per `config.memory.v3.lanes`) are skipped entirely: no substrate call,
103
+ * no `ScoutResult` entry.
104
+ *
105
+ * Honors `input.signal` — aborts between lanes and around the dense embed.
106
+ */
107
+ export async function runScouts(
108
+ input: RetrievalInput,
109
+ deps: ScoutDeps,
110
+ ): Promise<RunScoutsResult> {
111
+ const { config, signal } = input;
112
+ const lanes = config.memory.v3.lanes;
113
+ const queryText = deriveQueryText(input);
114
+ const userText = deriveUserText(input);
115
+
116
+ const scouts: ScoutResult[] = [];
117
+ const sticky = new Set<string>();
118
+ const bypass = new Set<string>();
119
+
120
+ // Hot lane — corpus-global EMA over the full slug universe. Cheap (single
121
+ // SQL pass) so it runs first. Hot hits are seeded as ordinary candidates but
122
+ // NOT sticky: the EMA ranks recency/frequency, not query relevance, so
123
+ // force-keeping the top-N recency pages would dominate every turn with
124
+ // operationally-frequent pages instead of the pages that bear on the query.
125
+ // Letting them pass through the query-aware gate lets irrelevant ones drop.
126
+ if (lanes.hot) {
127
+ signal?.throwIfAborted();
128
+ const hot = await runHotLane(input, deps);
129
+ if (hot) scouts.push(hot);
130
+ }
131
+
132
+ // Sparse lane — BM25 keyword match on the user's words ONLY (not the NOW
133
+ // context). NOW is ambient standing text; folding it into a keyword query
134
+ // makes whatever pages NOW happens to mention score near-exact on every
135
+ // turn, and near-exact hits become sticky + tree-bypass — so NOW-referenced
136
+ // pages would be force-injected into every selection regardless of the
137
+ // query. Keying sparse off the user turn keeps lexical match, sticky, and
138
+ // bypass tied to what the user actually asked. (Dense still embeds NOW below;
139
+ // semantic context legitimately helps there.)
140
+ if (lanes.sparse && userText.length > 0) {
141
+ signal?.throwIfAborted();
142
+ const sparse = await runSparseLane(userText, signal);
143
+ if (sparse) {
144
+ scouts.push(sparse.result);
145
+ for (const slug of sparse.nearExact) {
146
+ sticky.add(slug);
147
+ bypass.add(slug);
148
+ }
149
+ }
150
+ }
151
+
152
+ // Dense lane — embedding similarity, then per-subtree quota + MMR.
153
+ if (lanes.dense && queryText.length > 0) {
154
+ signal?.throwIfAborted();
155
+ const dense = await runDenseLane(queryText, config, signal);
156
+ if (dense) scouts.push(dense);
157
+ }
158
+
159
+ return { scouts, sticky, bypass };
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Query-text derivation
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /**
167
+ * The just-arrived user turn's text — the last `recentTurnPairs` entry's
168
+ * `userMessage`. This is the keyword target for the sparse lane and the basis
169
+ * for near-exact sticky/bypass, which must reflect what the user actually
170
+ * asked rather than the ambient NOW context.
171
+ */
172
+ function deriveUserText(input: RetrievalInput): string {
173
+ const lastPair = input.recentTurnPairs[input.recentTurnPairs.length - 1];
174
+ return (lastPair?.userMessage ?? "").trim();
175
+ }
176
+
177
+ /**
178
+ * Build the dense-lane query text from the just-arrived user turn plus the NOW
179
+ * context. Mirrors the v2 activation path (`selectCandidates`): join the
180
+ * non-empty channels with a newline. NOW is included here because semantic
181
+ * embedding benefits from standing context; the sparse lane deliberately omits
182
+ * it (see {@link deriveUserText}).
183
+ */
184
+ function deriveQueryText(input: RetrievalInput): string {
185
+ return [deriveUserText(input), input.nowText]
186
+ .filter((s) => s.trim().length > 0)
187
+ .join("\n")
188
+ .trim();
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Hot lane
193
+ // ---------------------------------------------------------------------------
194
+
195
+ async function runHotLane(
196
+ input: RetrievalInput,
197
+ deps: ScoutDeps,
198
+ ): Promise<ScoutResult | null> {
199
+ const index = await getPageIndex(input.workspaceDir);
200
+ const allSlugs = index.entries.map((e) => e.slug);
201
+ if (allSlugs.length === 0) return null;
202
+
203
+ const now = Date.now();
204
+ const scores = computeInjectionScores(deps.db, allSlugs, now);
205
+ if (scores.size === 0) return null;
206
+
207
+ // Slugs with no events in the read window are omitted by
208
+ // `computeInjectionScores`, so every entry here has score > 0. Cap to the
209
+ // top `hotLimit` by EMA: on a mature corpus — where nearly every page has
210
+ // been injected at some point — an uncapped lane would flood the candidate
211
+ // set with the entire corpus, so keep only the strongest recency signals.
212
+ const ranked = [...scores.entries()]
213
+ .sort((a, b) => sortByScoreDesc(a, b))
214
+ .slice(0, input.config.memory.v3.hotLimit);
215
+ const slugs = ranked.map(([slug]) => slug);
216
+ const scoreBySlug = Object.fromEntries(ranked);
217
+ return { lane: "hot", slugs, scoreBySlug };
218
+ }
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Sparse lane
222
+ // ---------------------------------------------------------------------------
223
+
224
+ async function runSparseLane(
225
+ queryText: string,
226
+ signal: AbortSignal | undefined,
227
+ ): Promise<{ result: ScoutResult; nearExact: string[] } | null> {
228
+ const sparse = generateBm25QueryEmbedding(queryText);
229
+ if (sparse.indices.length === 0) return null;
230
+
231
+ // Dense channel intentionally empty — this lane is BM25-only. `skipSparse:
232
+ // false` keeps the sparse round-trip on; we read `sparseScore` and ignore
233
+ // any dense scores the query happens to surface.
234
+ const hits = await hybridQueryConceptPages(
235
+ [],
236
+ sparse,
237
+ LANE_QUERY_LIMIT,
238
+ undefined,
239
+ {
240
+ skipSparse: false,
241
+ },
242
+ );
243
+ signal?.throwIfAborted();
244
+
245
+ const scored = hits
246
+ .map((hit) => ({ slug: hit.slug, score: hit.sparseScore }))
247
+ .filter((h): h is { slug: string; score: number } => h.score !== undefined)
248
+ .sort((a, b) => b.score - a.score);
249
+ if (scored.length === 0) return null;
250
+
251
+ const slugs = scored.map((h) => h.slug);
252
+ const scoreBySlug = Object.fromEntries(scored.map((h) => [h.slug, h.score]));
253
+
254
+ // Near-exact: within SPARSE_NEAR_EXACT_FRACTION of the top sparse score.
255
+ const topScore = scored[0].score;
256
+ const threshold = topScore * SPARSE_NEAR_EXACT_FRACTION;
257
+ const nearExact = scored
258
+ .filter((h) => topScore > 0 && h.score >= threshold)
259
+ .map((h) => h.slug);
260
+
261
+ return { result: { lane: "sparse", slugs, scoreBySlug }, nearExact };
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // Dense lane
266
+ // ---------------------------------------------------------------------------
267
+
268
+ async function runDenseLane(
269
+ queryText: string,
270
+ config: AssistantConfig,
271
+ signal: AbortSignal | undefined,
272
+ ): Promise<ScoutResult | null> {
273
+ // Embed + apply anisotropy correction, mirroring v2 activation's read path.
274
+ const embedded = await embedWithBackend(config, [queryText], { signal });
275
+ const dense = await applyCorrectionIfCalibrated(
276
+ embedded.vectors[0],
277
+ embedded.provider,
278
+ embedded.model,
279
+ );
280
+ signal?.throwIfAborted();
281
+
282
+ const sparse = generateBm25QueryEmbedding(queryText);
283
+ const hits = await hybridQueryConceptPages(dense, sparse, LANE_QUERY_LIMIT);
284
+ signal?.throwIfAborted();
285
+
286
+ const scored = hits
287
+ .map((hit) => ({ slug: hit.slug, score: hit.denseScore }))
288
+ .filter((h): h is { slug: string; score: number } => h.score !== undefined)
289
+ .sort((a, b) => b.score - a.score);
290
+ if (scored.length === 0) return null;
291
+
292
+ const selected = applyQuotaAndMmr(scored, config.memory.v3);
293
+ if (selected.length === 0) return null;
294
+
295
+ const slugs = selected.map((h) => h.slug);
296
+ const scoreBySlug = Object.fromEntries(
297
+ selected.map((h) => [h.slug, h.score]),
298
+ );
299
+ return { lane: "dense", slugs, scoreBySlug };
300
+ }
301
+
302
+ interface ScoredSlug {
303
+ slug: string;
304
+ score: number;
305
+ }
306
+
307
+ /**
308
+ * Apply the asymmetric per-subtree quota then MMR re-ranking to the dense hits.
309
+ *
310
+ * Quota: the conversation's **active domain** is the top-path segment of the
311
+ * single highest-scoring dense hit. That domain gets a generous slice
312
+ * (`denseQuota.activeDomain`); every other (off-)domain shares a thin slice
313
+ * (`denseQuota.offDomain`) so exploratory hits aren't fully starved but can't
314
+ * dominate either. Quotas are per-domain caps applied in score-descending
315
+ * order.
316
+ *
317
+ * MMR: re-rank the quota-passing pool by `λ · relevance − (1 − λ) · redundancy`
318
+ * where redundancy is how represented the candidate's subtree already is in the
319
+ * selected slate. Without per-page embeddings we use subtree co-membership as
320
+ * the diversity signal — same subtree ⇒ maximally redundant. This breaks up
321
+ * runs of same-subtree hits without an extra Qdrant round-trip.
322
+ */
323
+ function applyQuotaAndMmr(
324
+ scored: readonly ScoredSlug[],
325
+ v3: AssistantConfig["memory"]["v3"],
326
+ ): ScoredSlug[] {
327
+ if (scored.length === 0) return [];
328
+
329
+ const activeDomain = domainOf(scored[0].slug);
330
+ const { activeDomain: activeQuota, offDomain: offQuota } = v3.denseQuota;
331
+
332
+ // Per-subtree quota: active domain gets activeQuota slots; all off-domain
333
+ // hits compete for a shared offQuota pool. Walk in score-desc order so the
334
+ // strongest hits claim each quota first.
335
+ const perDomainCount = new Map<string, number>();
336
+ let offDomainCount = 0;
337
+ const quotaPassing: ScoredSlug[] = [];
338
+ for (const hit of scored) {
339
+ const domain = domainOf(hit.slug);
340
+ if (domain === activeDomain) {
341
+ const used = perDomainCount.get(domain) ?? 0;
342
+ if (used >= activeQuota) continue;
343
+ perDomainCount.set(domain, used + 1);
344
+ } else {
345
+ if (offDomainCount >= offQuota) continue;
346
+ offDomainCount += 1;
347
+ }
348
+ quotaPassing.push(hit);
349
+ }
350
+
351
+ return mmrReorder(quotaPassing, DENSE_MMR_LAMBDA);
352
+ }
353
+
354
+ /**
355
+ * Greedy MMR over a score-ranked pool using subtree co-membership as the
356
+ * redundancy signal. Each pick maximizes
357
+ * `λ · normalizedScore − (1 − λ) · subtreeShareInSelected`, so once a subtree
358
+ * is well-represented its remaining members are deprioritized in favor of
359
+ * fresh subtrees of comparable relevance. Pure / deterministic.
360
+ */
361
+ function mmrReorder(pool: readonly ScoredSlug[], lambda: number): ScoredSlug[] {
362
+ if (pool.length <= 1) return [...pool];
363
+
364
+ // Normalize relevance to [0, 1] by the pool max so it shares a scale with the
365
+ // redundancy term (also [0, 1]). All-zero scores collapse to pure diversity.
366
+ const maxScore = pool[0].score;
367
+ const relevance = (hit: ScoredSlug): number =>
368
+ maxScore > 0 ? hit.score / maxScore : 0;
369
+
370
+ const remaining = [...pool];
371
+ const selected: ScoredSlug[] = [];
372
+ const selectedDomainCount = new Map<string, number>();
373
+
374
+ while (remaining.length > 0) {
375
+ let bestIdx = 0;
376
+ let bestMmr = -Infinity;
377
+ for (let i = 0; i < remaining.length; i++) {
378
+ const hit = remaining[i];
379
+ const domain = domainOf(hit.slug);
380
+ const share =
381
+ selected.length === 0
382
+ ? 0
383
+ : (selectedDomainCount.get(domain) ?? 0) / selected.length;
384
+ const mmr = lambda * relevance(hit) - (1 - lambda) * share;
385
+ if (mmr > bestMmr) {
386
+ bestMmr = mmr;
387
+ bestIdx = i;
388
+ }
389
+ }
390
+ const [pick] = remaining.splice(bestIdx, 1);
391
+ selected.push(pick);
392
+ const domain = domainOf(pick.slug);
393
+ selectedDomainCount.set(domain, (selectedDomainCount.get(domain) ?? 0) + 1);
394
+ }
395
+
396
+ return selected;
397
+ }
398
+
399
+ // ---------------------------------------------------------------------------
400
+ // Helpers
401
+ // ---------------------------------------------------------------------------
402
+
403
+ /**
404
+ * The "domain" (subtree) of a page slug — its top path segment. Slugs are
405
+ * path-relative with `/` separators (e.g. `people/alice` → `people`); a flat
406
+ * slug (`essentials`) is its own domain.
407
+ */
408
+ function domainOf(slug: string): string {
409
+ const slash = slug.indexOf("/");
410
+ return slash === -1 ? slug : slug.slice(0, slash);
411
+ }
412
+
413
+ /** Score-desc with a stable slug-ASCII tiebreak. */
414
+ function sortByScoreDesc(
415
+ a: readonly [string, number],
416
+ b: readonly [string, number],
417
+ ): number {
418
+ if (b[1] !== a[1]) return b[1] - a[1];
419
+ return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0;
420
+ }