@vellumai/assistant 0.12.2 → 0.12.3-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 (298) hide show
  1. package/.env.example +2 -0
  2. package/AGENTS.md +1 -1
  3. package/docs/architecture/integrations.md +7 -0
  4. package/docs/architecture/memory.md +17 -2
  5. package/docs/credential-execution-service.md +1 -1
  6. package/docs/desktop-browser-cli.md +7 -3
  7. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  9. package/node_modules/@vellumai/environments/src/shell.test.ts +21 -0
  10. package/node_modules/@vellumai/environments/src/shell.ts +24 -0
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  12. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  13. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +8 -2
  14. package/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  15. package/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  16. package/openapi.yaml +54 -5
  17. package/package.json +1 -1
  18. package/scripts/smoke-desktop-browser-cli.ts +1 -0
  19. package/scripts/sync-llm-catalog.ts +3 -0
  20. package/scripts/voice-ttft-spike.ts +2 -2
  21. package/src/__tests__/agent-loop.test.ts +259 -0
  22. package/src/__tests__/agent-wake-delegation-prompt.test.ts +64 -2
  23. package/src/__tests__/anthropic-provider.test.ts +55 -0
  24. package/src/__tests__/approval-interception-trust-gates.test.ts +40 -0
  25. package/src/__tests__/attachments-store.test.ts +22 -3
  26. package/src/__tests__/channel-approval.test.ts +9 -14
  27. package/src/__tests__/channel-reply-delivery.test.ts +56 -0
  28. package/src/__tests__/chat-credential-redaction.test.ts +23 -0
  29. package/src/__tests__/computer-use-screenshot-attachments.test.ts +411 -0
  30. package/src/__tests__/computer-use-screenshot-selection.test.ts +50 -0
  31. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +2 -0
  32. package/src/__tests__/conversation-agent-loop-overflow.test.ts +2 -0
  33. package/src/__tests__/conversation-agent-loop.test.ts +257 -6
  34. package/src/__tests__/conversation-attachments.test.ts +106 -0
  35. package/src/__tests__/conversation-fork-crud.test.ts +191 -0
  36. package/src/__tests__/conversation-rendered-delegation-state.test.ts +276 -0
  37. package/src/__tests__/credential-execution-client.test.ts +70 -53
  38. package/src/__tests__/db-conversation-tool-surface.test.ts +221 -0
  39. package/src/__tests__/events-tail-route.test.ts +33 -0
  40. package/src/__tests__/history-repair.test.ts +53 -0
  41. package/src/__tests__/inference-profile-session-handler.test.ts +26 -0
  42. package/src/__tests__/list-messages-tool-merge.test.ts +85 -1
  43. package/src/__tests__/llm-catalog-parity.test.ts +27 -4
  44. package/src/__tests__/oauth-apps-routes.test.ts +1 -0
  45. package/src/__tests__/oauth-commands-routes.test.ts +114 -101
  46. package/src/__tests__/oauth-connect-orchestrator.test.ts +2 -0
  47. package/src/__tests__/oauth-provider-serializer.test.ts +1 -0
  48. package/src/__tests__/oauth-providers-routes.test.ts +2 -0
  49. package/src/__tests__/persist-media-references.test.ts +50 -0
  50. package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
  51. package/src/__tests__/run-conversation-turn-persistence.test.ts +138 -1
  52. package/src/__tests__/schedule-routes.test.ts +20 -0
  53. package/src/__tests__/scheduler-result-notification.test.ts +23 -4
  54. package/src/__tests__/script-proxy-certs.test.ts +1 -1
  55. package/src/__tests__/secret-routes-platform-proxy.test.ts +86 -31
  56. package/src/__tests__/secret-routes-scrub.test.ts +22 -27
  57. package/src/__tests__/secret-scanner.test.ts +20 -0
  58. package/src/__tests__/secure-keys-managed-failover.test.ts +8 -0
  59. package/src/__tests__/secure-keys.test.ts +7 -3
  60. package/src/__tests__/server-tool-pairing.test.ts +107 -0
  61. package/src/__tests__/skills.test.ts +5 -4
  62. package/src/__tests__/subagent-tool-gate-mode.test.ts +226 -0
  63. package/src/__tests__/terminal-tools.test.ts +8 -0
  64. package/src/__tests__/tool-result-follow-up.test.ts +99 -0
  65. package/src/__tests__/tool-result-metadata-plumbing.test.ts +63 -0
  66. package/src/__tests__/unicode.test.ts +36 -0
  67. package/src/agent/loop.ts +37 -12
  68. package/src/agent/tool-result-follow-up.ts +70 -0
  69. package/src/api/attachment-provenance.test.ts +68 -0
  70. package/src/api/computer-use-tool.test.ts +55 -0
  71. package/src/api/computer-use-tool.ts +27 -0
  72. package/src/api/events/assistant-outbound-attachment.ts +3 -0
  73. package/src/api/events/desktop-activity-changed.ts +10 -0
  74. package/src/api/events/question-request.ts +1 -0
  75. package/src/api/index.ts +11 -0
  76. package/src/api/responses/conversation-message.ts +3 -0
  77. package/src/approvals/approval-primitive.ts +5 -2
  78. package/src/approvals/scoped-approval-grants.ts +6 -2
  79. package/src/browser/virtual-desktop-target.ts +1 -2
  80. package/src/cli/commands/__tests__/cli-test-harness.ts +21 -3
  81. package/src/cli/commands/__tests__/plugins.test.ts +20 -1
  82. package/src/cli/commands/__tests__/schedules.test.ts +14 -0
  83. package/src/cli/commands/bash.help.ts +4 -3
  84. package/src/cli/commands/browser.help.ts +5 -1
  85. package/src/cli/commands/credentials.help.ts +3 -3
  86. package/src/cli/commands/oauth/index.help.ts +10 -0
  87. package/src/cli/commands/oauth/providers.ts +12 -0
  88. package/src/cli/commands/oauth/request.test.ts +179 -2
  89. package/src/cli/commands/oauth/request.ts +41 -10
  90. package/src/cli/commands/plugins.ts +12 -5
  91. package/src/cli/commands/schedules.ts +2 -0
  92. package/src/cli/lib/__tests__/inspect-plugin.test.ts +54 -0
  93. package/src/cli/lib/__tests__/install-from-github.test.ts +41 -0
  94. package/src/cli/lib/__tests__/local-plugin-upgrade.test.ts +133 -51
  95. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +8 -2
  96. package/src/cli/lib/bundled-marketplace.json +855 -0
  97. package/src/cli/lib/bundled-plugin-packages.json +783 -1
  98. package/src/cli/lib/inspect-plugin.ts +11 -4
  99. package/src/cli/lib/upgrade-plugin.ts +19 -11
  100. package/src/cli/output.ts +18 -4
  101. package/src/config/__tests__/default-provider.test.ts +4 -0
  102. package/src/config/bundled-skills/schedule/SKILL.md +6 -6
  103. package/src/config/loader.ts +9 -17
  104. package/src/config/platform-identity.ts +8 -8
  105. package/src/config/profile-text-generation.test.ts +51 -0
  106. package/src/config/profile-text-generation.ts +51 -0
  107. package/src/config/schemas/__tests__/memory-v3.test.ts +12 -0
  108. package/src/config/schemas/llm.ts +7 -2
  109. package/src/config/schemas/mcp.ts +5 -1
  110. package/src/config/schemas/memory-v3.ts +12 -0
  111. package/src/credential-execution/ces-runtime.ts +2 -2
  112. package/src/credential-execution/executable-discovery.ts +36 -134
  113. package/src/credential-execution/process-manager.test.ts +20 -24
  114. package/src/credential-execution/process-manager.ts +24 -19
  115. package/src/daemon/__tests__/conversation-tool-setup.test.ts +43 -0
  116. package/src/daemon/__tests__/turn-tail-deleted-conversation.test.ts +36 -0
  117. package/src/daemon/assistant-attachments.ts +20 -12
  118. package/src/daemon/chat-credential-redaction.ts +16 -1
  119. package/src/daemon/conversation-agent-loop-handlers.ts +74 -3
  120. package/src/daemon/conversation-agent-loop.ts +12 -0
  121. package/src/daemon/conversation-attachments.ts +93 -12
  122. package/src/daemon/conversation-tool-setup.ts +71 -3
  123. package/src/daemon/conversation-turn-finalize.ts +26 -13
  124. package/src/daemon/conversation.ts +72 -17
  125. package/src/daemon/daemon-control.ts +3 -7
  126. package/src/daemon/lifecycle.ts +4 -4
  127. package/src/daemon/mcp-reload-service.ts +1 -1
  128. package/src/daemon/message-types/shared.ts +1 -0
  129. package/src/daemon/orphan-reaper.ts +4 -3
  130. package/src/daemon/persist-media-references.ts +20 -3
  131. package/src/daemon/tool-setup-types.ts +6 -0
  132. package/src/daemon/wake-conversation-ops.ts +50 -15
  133. package/src/desktop/desktop-automation-lease.test.ts +230 -0
  134. package/src/desktop/desktop-automation-lease.ts +90 -6
  135. package/src/desktop/desktop-help.ts +15 -0
  136. package/src/desktop/desktop-stream-bridge.test.ts +65 -10
  137. package/src/desktop/desktop-stream-bridge.ts +3 -3
  138. package/src/desktop/desktop-wallpaper-renderer.ts +132 -0
  139. package/src/desktop/desktop-wallpaper-worker.ts +17 -0
  140. package/src/desktop/desktop-wallpaper.test.ts +48 -7
  141. package/src/desktop/desktop-wallpaper.ts +34 -102
  142. package/src/desktop/virtual-desktop-feature.ts +1 -1
  143. package/src/mcp/__tests__/manager-tool-caps.test.ts +111 -0
  144. package/src/mcp/__tests__/startup.test.ts +30 -7
  145. package/src/mcp/__tests__/tool-caps.test.ts +107 -0
  146. package/src/mcp/manager.ts +168 -101
  147. package/src/mcp/startup.ts +33 -13
  148. package/src/mcp/tool-caps.ts +176 -0
  149. package/src/messaging/provider-message-metadata.ts +3 -3
  150. package/src/notifications/__tests__/copy-composer.test.ts +70 -0
  151. package/src/notifications/copy-composer.ts +11 -3
  152. package/src/notifications/schedule-result-producer.ts +4 -6
  153. package/src/oauth/AGENTS.md +2 -0
  154. package/src/oauth/__tests__/identity-verifier.test.ts +36 -1
  155. package/src/oauth/identity-verifier.ts +24 -0
  156. package/src/oauth/oauth-store.ts +11 -1
  157. package/src/oauth/provider-serializer.ts +1 -0
  158. package/src/oauth/seed-providers.ts +12 -0
  159. package/src/permissions/confirmation-guardian-request.test.ts +16 -0
  160. package/src/permissions/confirmation-guardian-request.ts +2 -3
  161. package/src/permissions/question-prompter.test.ts +31 -0
  162. package/src/permissions/question-prompter.ts +2 -0
  163. package/src/persistence/attachments-store.ts +84 -44
  164. package/src/persistence/conversation-crud.ts +111 -20
  165. package/src/persistence/conversation-plugin-facade.ts +30 -0
  166. package/src/persistence/conversation-tool-surface.ts +135 -0
  167. package/src/persistence/conversation-types.test.ts +32 -0
  168. package/src/persistence/conversation-types.ts +26 -4
  169. package/src/persistence/migrations/378-create-conversation-tool-surfaces.test.ts +78 -0
  170. package/src/persistence/migrations/378-create-conversation-tool-surfaces.ts +29 -0
  171. package/src/persistence/migrations/379-oauth-providers-response-ok-field.test.ts +92 -0
  172. package/src/persistence/migrations/379-oauth-providers-response-ok-field.ts +32 -0
  173. package/src/persistence/migrations/380-conversation-tool-surfaces-delegate-independent-tasks.test.ts +90 -0
  174. package/src/persistence/migrations/380-conversation-tool-surfaces-delegate-independent-tasks.ts +34 -0
  175. package/src/persistence/schema/conversation-tool-surfaces.ts +32 -0
  176. package/src/persistence/schema/index.ts +1 -0
  177. package/src/persistence/schema/oauth.ts +1 -0
  178. package/src/persistence/steps.ts +24 -3
  179. package/src/plugin-api/conversation-turn.ts +31 -7
  180. package/src/plugin-api/index.ts +11 -1
  181. package/src/plugin-api/plugin-channel-turn-trust.test.ts +133 -0
  182. package/src/plugin-api/plugin-channel-turn-trust.ts +71 -0
  183. package/src/plugins/defaults/memory/AGENTS.md +39 -5
  184. package/src/plugins/defaults/memory/__tests__/buffer-file.test.ts +365 -0
  185. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +43 -0
  186. package/src/plugins/defaults/memory/__tests__/conversation-memory-purge.test.ts +1 -0
  187. package/src/plugins/defaults/memory/__tests__/db-memory-attach.test.ts +2 -0
  188. package/src/plugins/defaults/memory/__tests__/fixtures/buffer-appender.ts +17 -0
  189. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +74 -0
  190. package/src/plugins/defaults/memory/__tests__/memory-run-evidence.test.ts +161 -0
  191. package/src/plugins/defaults/memory/__tests__/relocated-memory-test-rows.ts +10 -0
  192. package/src/plugins/defaults/memory/buffer-file.ts +354 -0
  193. package/src/plugins/defaults/memory/buffer-format.ts +40 -0
  194. package/src/plugins/defaults/memory/context-search/agent-runner.ts +1 -2
  195. package/src/plugins/defaults/memory/context-search/format.ts +2 -1
  196. package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +2 -1
  197. package/src/plugins/defaults/memory/context-search/sources/workspace.ts +2 -1
  198. package/src/plugins/defaults/memory/conversation-memory-purge.ts +4 -0
  199. package/src/plugins/defaults/memory/graph/capability-seed.ts +1 -2
  200. package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -42
  201. package/src/plugins/defaults/memory/host-utils.ts +0 -10
  202. package/src/plugins/defaults/memory/injectors.ts +4 -3
  203. package/src/plugins/defaults/memory/memory-retrospective-job.ts +72 -183
  204. package/src/plugins/defaults/memory/memory-run-evidence.ts +213 -0
  205. package/src/plugins/defaults/memory/src/memory-item-routes.test.ts +1 -1
  206. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +407 -99
  207. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +10 -0
  208. package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +107 -7
  209. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +307 -86
  210. package/src/plugins/defaults/memory/substrate/consolidation-tool-surface.ts +34 -0
  211. package/src/plugins/defaults/memory/substrate/page-index.ts +2 -1
  212. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +89 -49
  213. package/src/plugins/defaults/memory/substrate/sweep-job.ts +1 -1
  214. package/src/plugins/defaults/memory/tools.ts +1 -1
  215. package/src/plugins/defaults/memory/v1/graph/consolidation.ts +2 -2
  216. package/src/plugins/defaults/memory/v1/graph/extraction.ts +2 -1
  217. package/src/plugins/defaults/memory/v1/graph/retriever.ts +1 -1
  218. package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +5 -0
  219. package/src/plugins/defaults/memory/v2/__tests__/reranker.test.ts +5 -2
  220. package/src/plugins/defaults/memory/v2/reranker.ts +2 -1
  221. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +81 -1
  222. package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +87 -0
  223. package/src/plugins/defaults/memory/v3/__tests__/plugin-schema.test.ts +12 -0
  224. package/src/plugins/defaults/memory/v3/__tests__/pool-log-store.test.ts +183 -4
  225. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +103 -2
  226. package/src/plugins/defaults/memory/v3/card.ts +2 -1
  227. package/src/plugins/defaults/memory/v3/injector.ts +212 -178
  228. package/src/plugins/defaults/memory/v3/orchestrate.ts +112 -25
  229. package/src/plugins/defaults/memory/v3/plugin-schema.ts +54 -4
  230. package/src/plugins/defaults/memory/v3/pool-log-store.ts +253 -1
  231. package/src/plugins/defaults/memory/v3/pool-select.test.ts +35 -2
  232. package/src/plugins/defaults/memory/v3/pool-select.ts +31 -19
  233. package/src/plugins/defaults/memory/v3/sections.ts +2 -1
  234. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +41 -14
  235. package/src/plugins/defaults/tool-error/hooks/post-tool-use.ts +4 -1
  236. package/src/plugins/defaults/tool-result-truncate/terminal.ts +1 -46
  237. package/src/prompts/__tests__/parallel-tasks-section.test.ts +25 -0
  238. package/src/prompts/delegation-gate.ts +57 -0
  239. package/src/prompts/system-prompt.ts +14 -32
  240. package/src/providers/inference/adapter-factory.ts +6 -0
  241. package/src/providers/jev/client.test.ts +260 -0
  242. package/src/providers/jev/client.ts +518 -0
  243. package/src/providers/model-catalog.ts +58 -3
  244. package/src/providers/server-tool-pairing.ts +16 -7
  245. package/src/runtime/AGENTS.md +2 -2
  246. package/src/runtime/__tests__/agent-wake.test.ts +94 -1
  247. package/src/runtime/agent-wake.ts +31 -4
  248. package/src/runtime/guardian-action-service.ts +2 -17
  249. package/src/runtime/guardian-reply-router.ts +1 -8
  250. package/src/runtime/http-server.ts +2 -2
  251. package/src/runtime/migrations/__tests__/vbundle-import-policy.test.ts +89 -0
  252. package/src/runtime/migrations/vbundle-import-policy.ts +33 -11
  253. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +24 -2
  254. package/src/runtime/routes/channel-route-shared.ts +1 -9
  255. package/src/runtime/routes/conversation-routes.ts +29 -4
  256. package/src/runtime/routes/desktop-setup-routes.test.ts +2 -2
  257. package/src/runtime/routes/desktop-setup-routes.ts +8 -5
  258. package/src/runtime/routes/guardian-approval-interception.ts +24 -0
  259. package/src/runtime/routes/inbound-message-handler.ts +2 -3
  260. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +1 -1
  261. package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -4
  262. package/src/runtime/routes/inference-profile-session-handler.ts +11 -1
  263. package/src/runtime/routes/inference-profiles-routes.ts +9 -0
  264. package/src/runtime/routes/oauth-commands-routes.ts +57 -54
  265. package/src/runtime/routes/oauth-providers.ts +4 -0
  266. package/src/runtime/routes/oauth-request-hints.test.ts +246 -0
  267. package/src/runtime/routes/oauth-request-hints.ts +149 -0
  268. package/src/runtime/routes/schedule-routes.ts +2 -0
  269. package/src/runtime/routes/secret-routes.ts +99 -119
  270. package/src/schedule/__tests__/worker-mcp-bootstrap.test.ts +107 -0
  271. package/src/schedule/__tests__/worker-mcp-readiness.test.ts +173 -0
  272. package/src/schedule/__tests__/worker-mcp-tools.test.ts +13 -47
  273. package/src/schedule/run-script.ts +2 -2
  274. package/src/schedule/scheduler.ts +47 -11
  275. package/src/schedule/tool-surface-readiness.ts +65 -0
  276. package/src/schedule/worker-mcp.ts +85 -0
  277. package/src/schedule/worker.ts +9 -31
  278. package/src/security/secure-keys.ts +60 -48
  279. package/src/tools/ask-question/ask-question-tool.test.ts +167 -5
  280. package/src/tools/ask-question/ask-question-tool.ts +119 -23
  281. package/src/tools/browser/browser-execution.ts +7 -1
  282. package/src/tools/host-terminal/host-shell.ts +12 -6
  283. package/src/tools/shared/filesystem/file-ops-service.ts +1 -31
  284. package/src/tools/shared/shell-output.test.ts +10 -0
  285. package/src/tools/shared/shell-output.ts +14 -2
  286. package/src/tools/skills/sandbox-runner.ts +13 -2
  287. package/src/tools/skills/scaffold-managed.ts +2 -1
  288. package/src/tools/terminal/__tests__/safe-env.test.ts +33 -4
  289. package/src/tools/terminal/__tests__/sanitized-bash.test.ts +0 -14
  290. package/src/tools/terminal/safe-env.ts +35 -24
  291. package/src/tools/terminal/sanitized-bash.ts +15 -2
  292. package/src/tools/terminal/shell-launch.test.ts +162 -0
  293. package/src/tools/terminal/shell.test.ts +29 -0
  294. package/src/tools/terminal/shell.ts +13 -7
  295. package/src/util/browser-human-verification.ts +14 -0
  296. package/src/util/host-process.test.ts +17 -1
  297. package/src/util/host-process.ts +24 -0
  298. package/src/util/unicode.ts +29 -0
@@ -173,15 +173,22 @@ export function memoryV3TurnMemoSizeForTests(): number {
173
173
  return observedTurns.size;
174
174
  }
175
175
 
176
+ /** What the turn still got when the selector could not run: nothing (the
177
+ * orchestration itself failed) or the stable prefix unjudged. */
178
+ type MemoryV3DegradedScope = "none" | "stable-prefix";
179
+
176
180
  function queueMemoryV3ConversationNotice(
177
181
  err: MemoryV3RetrievalUnavailableError,
178
182
  ctx: TurnContext,
183
+ scope: MemoryV3DegradedScope,
179
184
  ): void {
180
185
  const notice: PendingConversationNotice = err.conversationNotice ?? {
181
186
  source: "memory_v3",
182
187
  code: "UNKNOWN",
183
188
  userMessage:
184
- "Memory is temporarily unavailable, so this response may not use your saved memories. You can retry in a moment.",
189
+ scope === "stable-prefix"
190
+ ? "Memory selection is temporarily unavailable, so this response draws only on your core memories. You can retry in a moment."
191
+ : "Memory is temporarily unavailable, so this response may not use your saved memories. You can retry in a moment.",
185
192
  errorCategory: "memory_v3_degraded",
186
193
  };
187
194
  queueConversationNotice(
@@ -324,7 +331,7 @@ export const memoryV3Injector: Injector = {
324
331
  observed = await observeTurnOnce(ctx.conversationId, ctx.turnIndex);
325
332
  } catch (err) {
326
333
  if (err instanceof MemoryV3RetrievalUnavailableError) {
327
- queueMemoryV3ConversationNotice(err, ctx);
334
+ queueMemoryV3ConversationNotice(err, ctx, "none");
328
335
  log.error(
329
336
  {
330
337
  err: err.message,
@@ -336,194 +343,221 @@ export const memoryV3Injector: Injector = {
336
343
  }
337
344
  return null;
338
345
  }
339
- // Empty selection → return null (attach nothing). The user-prompt-submit
340
- // hook skipped v2 retrieval under live, so a turn with nothing selected
341
- // simply gets no v3 `<memory>` block (prior turns' frozen sections still
342
- // ride history).
343
- if (!observed || observed.selections.length === 0) {
346
+ if (!observed) {
344
347
  return null;
345
348
  }
346
- // `const` so the non-null narrowing survives capture in the `commit`
347
- // closure below (a `let` would re-widen to `OrchestrateResult | null`).
348
- const result = observed;
349
-
350
- try {
351
- // Partition this turn's injection units: each selected section under
352
- // its own key, a page selected with none under `""` (its lead;
353
- // capability content injects whole under `""` too).
354
- // - A pair tombstoned since the turn's first produce is skipped: a
355
- // re-entry never revives what the valve pruned.
356
- // - On a re-entry assembly (`rendered` set by the first produce) an
357
- // entry the first produce rendered is re-emitted from the memo byte
358
- // for byte: the store counts it active, but its only copy rode the
359
- // tail the re-injection strip cleared, so the store alone would
360
- // read it as resident and drop it for the rest of the turn. A
361
- // re-entry block carries no commit (and runtime assembly withholds
362
- // one on a `reinjection` assembly besides), so a turn's sections
363
- // are recorded once, at the first-call site; after a compaction they
364
- // stay unclaimed until the next turn injects them net-new onto its
365
- // own persisted message, and the newest-copy rule
366
- // (`stripPrunedSectionsFromMessages`) retires the re-entry copy.
367
- // - A pair active in the store is resident: a pointer entry, stamped
368
- // with the turn's selection time below. Capability slugs are
369
- // stamped too but left out of the pointer (no `memory/concepts/`
370
- // path to point at).
371
- // - Every other pair renders net-new, including one the first produce
372
- // saw resident whose copy a compaction's store reset has since
373
- // unclaimed (neither active nor tombstoned), in memory only.
374
- // Under a run-messages replacement (`ctx.replacesRunMessages`: the
375
- // Slack chronological transcript, rendered from persisted rows, so it
376
- // carries no earlier turn's block) residency means nothing. The
377
- // store's active set is not consulted, every pair renders or
378
- // re-emits, none is pointed at, and the block carries no commit, so
379
- // nothing is recorded or scheduled and runtime assembly attaches the
380
- // block in memory only as the prompt's single copy. A Slack
381
- // conversation whose transcript injector is absent is not replaced and
382
- // injects as an ordinary committed turn.
383
- const rendered = observedTurn(
384
- ctx.conversationId,
385
- ctx.turnIndex,
386
- )?.rendered;
387
- const firstProduce = rendered === undefined;
388
- const replaced = ctx.replacesRunMessages === true;
389
- const active = replaced ? null : getActiveSections(ctx.conversationId);
390
- const pruned = firstProduce
391
- ? undefined
392
- : getPrunedSections(ctx.conversationId);
393
- const resident: SectionRef[] = [];
394
- const slots: BlockSlot[] = [];
395
- for (const { slug, key, matched } of injectionUnits(result.selections)) {
396
- if (pruned && sectionRefSetHas(pruned, slug, key)) {
397
- continue;
398
- }
399
- const reemitted = rendered?.get(slug)?.get(key);
400
- if (reemitted !== undefined) {
401
- slots.push({ slug, key, matched, text: reemitted });
402
- continue;
403
- }
404
- if (active && sectionRefSetHas(active, slug, key)) {
405
- resident.push({ slug, key });
406
- continue;
407
- }
408
- slots.push({ slug, key, matched, text: undefined });
409
- }
410
- rememberPointerEntries(
411
- ctx.conversationId,
412
- ctx.turnIndex,
413
- resident.filter(({ slug }) => !isCapabilitySlug(slug)),
349
+ const block = await renderSelections(ctx, observed);
350
+ // A selector that could not run left the stable prefix unjudged in
351
+ // `selections`. The person is told what this turn drew on only once the
352
+ // render is known: the prefix when a block carries it (an empty-text
353
+ // block means the sections already ride history), nothing when no block
354
+ // attaches.
355
+ if (observed.selectorFailure) {
356
+ queueMemoryV3ConversationNotice(
357
+ observed.selectorFailure,
358
+ ctx,
359
+ block === null ? "none" : "stable-prefix",
414
360
  );
415
- // The turn's selection time, stamped on the resident pairs NOW, in the
416
- // same synchronous segment as the classification above: the page reads
417
- // below yield to the event loop, and a prune valve queued by an earlier
418
- // turn's commit fires on a timer, so it can run while they are awaited
419
- // and would otherwise rank a pair this turn is about by its stale
420
- // stamp and evict it. The stamp is a recency bump, not a claim, so a
421
- // turn whose block never attaches (or that returns null below because
422
- // every net-new pair rendered empty) bumps harmlessly. Only the first
423
- // produce stamps: a re-entry re-emits the turn's selections, and a
424
- // run-messages replacement claims nothing (`resident` is empty there).
425
- // The net-new pairs take the same time with their record in the commit.
426
- const selectedAt = Date.now();
427
- if (firstProduce) {
428
- touchSelected(ctx.conversationId, resident, selectedAt);
429
- }
430
-
431
- // Render net-new sections (each an independent page read, so in
432
- // parallel), skipping pairs that resolve to no content (deleted pages,
433
- // unresolvable capabilities, empty sections): nothing is attached for
434
- // them, so nothing is recorded either.
435
- const netNew = slots.filter((slot) => slot.text === undefined);
436
- const renderedNow = await Promise.all(
437
- netNew.map(({ slug, matched }) =>
438
- renderV3InjectionEntry(slug, matched),
439
- ),
440
- );
441
- for (const [i, slot] of netNew.entries()) {
442
- slot.text = renderedNow[i]!;
443
- }
444
- const entries: Array<SectionRef & { text: string }> = [];
445
- for (const { slug, key, text } of slots) {
446
- if (text !== undefined && text.trim().length > 0) {
447
- entries.push({ slug, key, text });
448
- }
449
- }
450
- // Every net-new section rendered empty: return null rather than an
451
- // empty-text block. Under live there is no v2 block, so the turn simply
452
- // gets no new memory. Distinct from the all-repeat case (empty
453
- // `netNew`), where the empty block correctly keeps v2 suppressed
454
- // because the sections already ride history.
455
- if (netNew.length > 0 && entries.length === 0) {
456
- return null;
457
- }
458
-
459
- // Empty net-new → empty-text block: assembly attaches no content
460
- // (`applyInjectionBlock` no-ops empty text) but the block's presence
461
- // still marks v3 as this turn's `<memory>` source for v2 suppression.
462
- const inner = renderInjectionBlockInner(entries.map((e) => e.text));
463
- const block: InjectionBlock = {
464
- id: MEMORY_V3_BLOCK_ID,
465
- text: inner.length === 0 ? "" : wrapMemoryBlock(inner),
466
- // Mirror v2's dynamic `<memory>` block placement.
467
- placement: "after-memory-prefix",
468
- };
469
- if (!firstProduce) {
470
- return block;
471
- }
472
-
473
- rememberRendered(ctx.conversationId, ctx.turnIndex, entries);
474
- // A block rendered for a run-messages replacement rides that prompt
475
- // only: no copy of it persists, so the store must not claim its
476
- // sections and the valve has nothing new to account for.
477
- if (replaced) {
478
- return block;
479
- }
480
- // The net-new record and the prune-valve schedule are DEFERRED to this
481
- // commit callback, invoked by runtime assembly at the point where
482
- // attachment is guaranteed (the turn's tail is a user message, the
483
- // same gate as metadata capture). Recording here in `produce()` would
484
- // let a never-attached turn (non-user tail) claim sections in the
485
- // store, suppressing them until compaction. Only the turn's first
486
- // produce carries it: a re-entry block re-emits what this one rendered
487
- // and is never persisted, so it must not record anything. The valve is
488
- // scheduled after the record so the resident accounting, and the
489
- // recency it ranks by, include this turn's sections (the resident
490
- // pairs carry their stamp from the classification above); it evicts by
491
- // recency with no lane exemptions. It runs on a timer, so this turn's
492
- // block may not have folded back into the live history when it strips;
493
- // a section it prunes from this very turn is stripped by assembly Step
494
- // 0 on the next turn, which applies the store's full tombstone set
495
- // every turn.
496
- const commit = (): void => {
497
- recordInjected(
498
- ctx.conversationId,
499
- entries.map(({ slug, key, text }) => ({
500
- slug,
501
- key,
502
- // Capability content (skills / CLI commands) renders with its own
503
- // `# Skill:` / `# CLI command:` header, which the prune valve's
504
- // section grammar can never locate to free. Record it at zero
505
- // bytes so it never inflates the freeable resident accounting
506
- // (the valve would otherwise loop-fire on bytes it cannot free).
507
- bytes: isCapabilitySlug(slug) ? 0 : renderedBytes(text),
508
- })),
509
- selectedAt,
510
- );
511
- schedulePruneValve(ctx.conversationId);
512
- };
513
- return { ...block, meta: { [MEMORY_V3_COMMIT_META_KEY]: commit } };
514
- } catch (err) {
515
361
  log.warn(
516
362
  {
517
- err: err instanceof Error ? err.message : String(err),
363
+ err: observed.selectorFailure.message,
518
364
  conversationId: ctx.conversationId,
365
+ stableCount: observed.selections.length,
366
+ attached: block !== null,
367
+ mode: "live",
519
368
  },
520
- "memory-v3 live render failed (non-fatal) returning null (no v3 block this turn)",
369
+ "memory-v3 selector unavailable; the stable prefix stands in unjudged",
521
370
  );
522
- return null;
523
371
  }
372
+ return block;
524
373
  },
525
374
  };
526
375
 
376
+ /**
377
+ * Render a turn's selections into its `<memory>` block, or `null` when the
378
+ * turn attaches nothing: an empty selection (the user-prompt-submit hook
379
+ * skipped v2 retrieval under live, so the turn simply gets no v3 block while
380
+ * prior turns' frozen sections still ride history), every net-new section
381
+ * rendering empty, or a render failure (non-fatal, logged).
382
+ */
383
+ async function renderSelections(
384
+ ctx: TurnContext,
385
+ result: OrchestrateResult,
386
+ ): Promise<InjectionBlock | null> {
387
+ if (result.selections.length === 0) {
388
+ return null;
389
+ }
390
+ try {
391
+ // Partition this turn's injection units: each selected section under
392
+ // its own key, a page selected with none under `""` (its lead;
393
+ // capability content injects whole under `""` too).
394
+ // - A pair tombstoned since the turn's first produce is skipped: a
395
+ // re-entry never revives what the valve pruned.
396
+ // - On a re-entry assembly (`rendered` set by the first produce) an
397
+ // entry the first produce rendered is re-emitted from the memo byte
398
+ // for byte: the store counts it active, but its only copy rode the
399
+ // tail the re-injection strip cleared, so the store alone would
400
+ // read it as resident and drop it for the rest of the turn. A
401
+ // re-entry block carries no commit (and runtime assembly withholds
402
+ // one on a `reinjection` assembly besides), so a turn's sections
403
+ // are recorded once, at the first-call site; after a compaction they
404
+ // stay unclaimed until the next turn injects them net-new onto its
405
+ // own persisted message, and the newest-copy rule
406
+ // (`stripPrunedSectionsFromMessages`) retires the re-entry copy.
407
+ // - A pair active in the store is resident: a pointer entry, stamped
408
+ // with the turn's selection time below. Capability slugs are
409
+ // stamped too but left out of the pointer (no `memory/concepts/`
410
+ // path to point at).
411
+ // - Every other pair renders net-new, including one the first produce
412
+ // saw resident whose copy a compaction's store reset has since
413
+ // unclaimed (neither active nor tombstoned), in memory only.
414
+ // Under a run-messages replacement (`ctx.replacesRunMessages`: the
415
+ // Slack chronological transcript, rendered from persisted rows, so it
416
+ // carries no earlier turn's block) residency means nothing. The
417
+ // store's active set is not consulted, every pair renders or
418
+ // re-emits, none is pointed at, and the block carries no commit, so
419
+ // nothing is recorded or scheduled and runtime assembly attaches the
420
+ // block in memory only as the prompt's single copy. A Slack
421
+ // conversation whose transcript injector is absent is not replaced and
422
+ // injects as an ordinary committed turn.
423
+ const rendered = observedTurn(ctx.conversationId, ctx.turnIndex)?.rendered;
424
+ const firstProduce = rendered === undefined;
425
+ const replaced = ctx.replacesRunMessages === true;
426
+ const active = replaced ? null : getActiveSections(ctx.conversationId);
427
+ const pruned = firstProduce
428
+ ? undefined
429
+ : getPrunedSections(ctx.conversationId);
430
+ const resident: SectionRef[] = [];
431
+ const slots: BlockSlot[] = [];
432
+ for (const { slug, key, matched } of injectionUnits(result.selections)) {
433
+ if (pruned && sectionRefSetHas(pruned, slug, key)) {
434
+ continue;
435
+ }
436
+ const reemitted = rendered?.get(slug)?.get(key);
437
+ if (reemitted !== undefined) {
438
+ slots.push({ slug, key, matched, text: reemitted });
439
+ continue;
440
+ }
441
+ if (active && sectionRefSetHas(active, slug, key)) {
442
+ resident.push({ slug, key });
443
+ continue;
444
+ }
445
+ slots.push({ slug, key, matched, text: undefined });
446
+ }
447
+ rememberPointerEntries(
448
+ ctx.conversationId,
449
+ ctx.turnIndex,
450
+ resident.filter(({ slug }) => !isCapabilitySlug(slug)),
451
+ );
452
+ // The turn's selection time, stamped on the resident pairs NOW, in the
453
+ // same synchronous segment as the classification above: the page reads
454
+ // below yield to the event loop, and a prune valve queued by an earlier
455
+ // turn's commit fires on a timer, so it can run while they are awaited
456
+ // and would otherwise rank a pair this turn is about by its stale
457
+ // stamp and evict it. The stamp is a recency bump, not a claim, so a
458
+ // turn whose block never attaches (or that returns null below because
459
+ // every net-new pair rendered empty) bumps harmlessly. Only the first
460
+ // produce stamps: a re-entry re-emits the turn's selections, and a
461
+ // run-messages replacement claims nothing (`resident` is empty there).
462
+ // The net-new pairs take the same time with their record in the commit.
463
+ const selectedAt = Date.now();
464
+ if (firstProduce) {
465
+ touchSelected(ctx.conversationId, resident, selectedAt);
466
+ }
467
+
468
+ // Render net-new sections (each an independent page read, so in
469
+ // parallel), skipping pairs that resolve to no content (deleted pages,
470
+ // unresolvable capabilities, empty sections): nothing is attached for
471
+ // them, so nothing is recorded either.
472
+ const netNew = slots.filter((slot) => slot.text === undefined);
473
+ const renderedNow = await Promise.all(
474
+ netNew.map(({ slug, matched }) => renderV3InjectionEntry(slug, matched)),
475
+ );
476
+ for (const [i, slot] of netNew.entries()) {
477
+ slot.text = renderedNow[i]!;
478
+ }
479
+ const entries: Array<SectionRef & { text: string }> = [];
480
+ for (const { slug, key, text } of slots) {
481
+ if (text !== undefined && text.trim().length > 0) {
482
+ entries.push({ slug, key, text });
483
+ }
484
+ }
485
+ // Every net-new section rendered empty: return null rather than an
486
+ // empty-text block. Under live there is no v2 block, so the turn simply
487
+ // gets no new memory. Distinct from the all-repeat case (empty
488
+ // `netNew`), where the empty block correctly keeps v2 suppressed
489
+ // because the sections already ride history.
490
+ if (netNew.length > 0 && entries.length === 0) {
491
+ return null;
492
+ }
493
+
494
+ // Empty net-new → empty-text block: assembly attaches no content
495
+ // (`applyInjectionBlock` no-ops empty text) but the block's presence
496
+ // still marks v3 as this turn's `<memory>` source for v2 suppression.
497
+ const inner = renderInjectionBlockInner(entries.map((e) => e.text));
498
+ const block: InjectionBlock = {
499
+ id: MEMORY_V3_BLOCK_ID,
500
+ text: inner.length === 0 ? "" : wrapMemoryBlock(inner),
501
+ // Mirror v2's dynamic `<memory>` block placement.
502
+ placement: "after-memory-prefix",
503
+ };
504
+ if (!firstProduce) {
505
+ return block;
506
+ }
507
+
508
+ rememberRendered(ctx.conversationId, ctx.turnIndex, entries);
509
+ // A block rendered for a run-messages replacement rides that prompt
510
+ // only: no copy of it persists, so the store must not claim its
511
+ // sections and the valve has nothing new to account for.
512
+ if (replaced) {
513
+ return block;
514
+ }
515
+ // The net-new record and the prune-valve schedule are DEFERRED to this
516
+ // commit callback, invoked by runtime assembly at the point where
517
+ // attachment is guaranteed (the turn's tail is a user message, the
518
+ // same gate as metadata capture). Recording here in `produce()` would
519
+ // let a never-attached turn (non-user tail) claim sections in the
520
+ // store, suppressing them until compaction. Only the turn's first
521
+ // produce carries it: a re-entry block re-emits what this one rendered
522
+ // and is never persisted, so it must not record anything. The valve is
523
+ // scheduled after the record so the resident accounting, and the
524
+ // recency it ranks by, include this turn's sections (the resident
525
+ // pairs carry their stamp from the classification above); it evicts by
526
+ // recency with no lane exemptions. It runs on a timer, so this turn's
527
+ // block may not have folded back into the live history when it strips;
528
+ // a section it prunes from this very turn is stripped by assembly Step
529
+ // 0 on the next turn, which applies the store's full tombstone set
530
+ // every turn.
531
+ const commit = (): void => {
532
+ recordInjected(
533
+ ctx.conversationId,
534
+ entries.map(({ slug, key, text }) => ({
535
+ slug,
536
+ key,
537
+ // Capability content (skills / CLI commands) renders with its own
538
+ // `# Skill:` / `# CLI command:` header, which the prune valve's
539
+ // section grammar can never locate to free. Record it at zero
540
+ // bytes so it never inflates the freeable resident accounting
541
+ // (the valve would otherwise loop-fire on bytes it cannot free).
542
+ bytes: isCapabilitySlug(slug) ? 0 : renderedBytes(text),
543
+ })),
544
+ selectedAt,
545
+ );
546
+ schedulePruneValve(ctx.conversationId);
547
+ };
548
+ return { ...block, meta: { [MEMORY_V3_COMMIT_META_KEY]: commit } };
549
+ } catch (err) {
550
+ log.warn(
551
+ {
552
+ err: err instanceof Error ? err.message : String(err),
553
+ conversationId: ctx.conversationId,
554
+ },
555
+ "memory-v3 live render failed (non-fatal) — returning null (no v3 block this turn)",
556
+ );
557
+ return null;
558
+ }
559
+ }
560
+
527
561
  export const memoryV3PointerInjector: Injector = {
528
562
  name: "memory-v3-pointer",
529
563
  // After the sections injector, whose produce records the resident
@@ -89,7 +89,11 @@ import type {
89
89
  SelectorPool,
90
90
  StableCandidate,
91
91
  } from "./pool-select.js";
92
- import { selectAllPoolCandidates, selectPool } from "./pool-select.js";
92
+ import {
93
+ MemoryV3RetrievalUnavailableError,
94
+ selectAllPoolCandidates,
95
+ selectPool,
96
+ } from "./pool-select.js";
93
97
  import {
94
98
  type RareTermHit,
95
99
  rareTermLane,
@@ -339,12 +343,31 @@ export interface OrchestrateResult {
339
343
  lanes: OrchestrateLanes;
340
344
  /** Whether the selector LLM judged a non-empty pool this turn (the
341
345
  * `selector_ran` telemetry field). False when the pool was empty, when the
342
- * disabled-selector passthrough kept every candidate, and when a closed
343
- * injection gate hard-skipped selection. On that last path `lanes` still
344
- * carries the stable prefix as computed, but no pool was ever assembled,
345
- * so a false value with empty `selections` means the selector was given
346
- * nothing. */
346
+ * disabled-selector passthrough kept every candidate, when a closed
347
+ * injection gate hard-skipped selection, and when the selector's provider
348
+ * failed and the stable prefix was kept unjudged (`selectorFailure`). On
349
+ * the gate path `lanes` still carries the stable prefix as computed, but
350
+ * no pool was ever assembled, so a false value with empty `selections`
351
+ * means the selector was given nothing. */
347
352
  selectorRan: boolean;
353
+ /** Set when the selector could not be run this turn (provider unavailable,
354
+ * or no usable tool call after the re-prompt retries). `selections` then
355
+ * holds the stable prefix unjudged; finder candidates are dropped because
356
+ * they are evidence for a judge, not evidence to inject on their own. The
357
+ * live injector queues a notice so the person knows this turn drew on
358
+ * core memories only. */
359
+ selectorFailure?: MemoryV3RetrievalUnavailableError;
360
+ /** The pool as the selector was given it, in one numbering: the
361
+ * stable-prefix cards, then the finder lines. Absent when no pool was
362
+ * assembled this turn (a closed gate's hard skip). Read by the pool input
363
+ * capture (`buildPoolInput` in `pool-log-store.ts`). */
364
+ pool?: SelectorPool;
365
+ /** Whether the selector's recall-safe keep-all fallback fired
366
+ * (`selectPool`'s `keptAll`). Absent when no pool was assembled. */
367
+ keptAll?: boolean;
368
+ /** The injection gate's reason code for this turn. Absent when the gate
369
+ * did not run. */
370
+ gateReason?: string;
348
371
  }
349
372
 
350
373
  /** Stable-order de-duplication preserving first occurrence. */
@@ -402,19 +425,55 @@ export async function orchestrate(
402
425
  // selector's recall-safe fallback ONLY — the disabled passthrough is not a
403
426
  // selector judgment, so it reports `false` (its turns are excluded from any
404
427
  // relevance read by `selector_ran` anyway).
428
+ //
429
+ // A selector that cannot run at all (provider unavailable, or no usable
430
+ // tool call after the re-prompt retries) keeps the stable prefix unjudged
431
+ // and drops the finder candidates: the stable cards are
432
+ // conversation-independent and already sit in every turn's pool, while a
433
+ // finder line is evidence for a judge, not evidence to inject on its own.
434
+ // The failure rides the result so the injector can tell the person this
435
+ // turn drew on core memories only.
405
436
  const runSelection = (
406
437
  pool: SelectorPool,
407
- ): Promise<{ selections: SelectedPage[]; keptAll: boolean }> =>
438
+ ): Promise<{
439
+ selections: SelectedPage[];
440
+ keptAll: boolean;
441
+ failure?: MemoryV3RetrievalUnavailableError;
442
+ }> =>
408
443
  timeLatencySubSpan("v3_selection", "Memory selection", async () => {
409
444
  if (deps.selectorEnabled === false) {
410
445
  return { selections: selectAllPoolCandidates(pool), keptAll: false };
411
446
  }
412
- const { pages, keptAll } = await selectPool(
413
- pool,
414
- turn,
415
- deps.selectorPrompt,
416
- );
417
- return { selections: pages, keptAll };
447
+ try {
448
+ const { pages, keptAll } = await selectPool(
449
+ pool,
450
+ turn,
451
+ deps.selectorPrompt,
452
+ );
453
+ return { selections: pages, keptAll };
454
+ } catch (err) {
455
+ if (!(err instanceof MemoryV3RetrievalUnavailableError)) {
456
+ throw err;
457
+ }
458
+ log.warn(
459
+ {
460
+ conversationId: turn.conversationId,
461
+ turnNumber: turn.turnNumber,
462
+ stableCount: pool.stable.length,
463
+ finderCount: pool.finder.length,
464
+ err: err.message,
465
+ },
466
+ "memory-v3 selector unavailable; keeping the stable prefix unjudged",
467
+ );
468
+ return {
469
+ selections: selectAllPoolCandidates({
470
+ stable: pool.stable,
471
+ finder: [],
472
+ }),
473
+ keptAll: false,
474
+ failure: err,
475
+ };
476
+ }
418
477
  });
419
478
 
420
479
  // Step 1: needle (sync BM25) and the enabled dense lane (async embed +
@@ -760,17 +819,22 @@ export async function orchestrate(
760
819
  // Omitted when the caller did not supply `isResident` (tests,
761
820
  // shadow-less paths). The count reads the same units the injector will
762
821
  // (a closed gate's selections carry no sections, so it counts leads).
763
- const selectorRanOver = (poolSize: number): boolean =>
764
- deps.selectorEnabled !== false && poolSize > 0;
822
+ const selectorRanOver = (
823
+ poolSize: number,
824
+ failure?: MemoryV3RetrievalUnavailableError,
825
+ ): boolean =>
826
+ deps.selectorEnabled !== false && poolSize > 0 && failure === undefined;
765
827
  const recordSelection = (
766
828
  selections: SelectedPage[],
767
829
  poolSize: number,
768
830
  keptAll: boolean,
831
+ failure?: MemoryV3RetrievalUnavailableError,
769
832
  ): void => {
770
833
  const detail: Record<string, unknown> = {
771
834
  gate_reason: gateOutcome?.reason ?? null,
772
835
  gate_pass: gateOutcome?.pass ?? null,
773
- selector_ran: selectorRanOver(poolSize),
836
+ selector_ran: selectorRanOver(poolSize, failure),
837
+ selector_failed: failure !== undefined,
774
838
  selector_kept_all: keptAll,
775
839
  selected_count: selections.length,
776
840
  pool_size: poolSize,
@@ -845,10 +909,15 @@ export async function orchestrate(
845
909
  const closed = (
846
910
  selections: SelectedPage[],
847
911
  selectorRan: boolean,
912
+ selectorFailure?: MemoryV3RetrievalUnavailableError,
913
+ judged?: { pool: SelectorPool; keptAll: boolean },
848
914
  ): OrchestrateResult => ({
849
915
  selections,
850
916
  lanes: { core, hot, fresh, always, finder: [] },
851
917
  selectorRan,
918
+ gateReason: gate.reason,
919
+ ...(judged ? { pool: judged.pool, keptAll: judged.keptAll } : {}),
920
+ ...(selectorFailure ? { selectorFailure } : {}),
852
921
  });
853
922
  if (deps.gateConfig.bypassForCore) {
854
923
  // Select over the stable prefix only. `runSelection` mirrors the
@@ -860,13 +929,27 @@ export async function orchestrate(
860
929
  // explicitly configured with `selectorEnabled: false` AND
861
930
  // `denseK > 0` (the dense-gated gate only runs with dense hits; the
862
931
  // new-user profile sets `denseK: 0`, so the gate never runs for it).
863
- const stableOnly = buildStable();
864
- const { selections: bypassed, keptAll } = await runSelection({
865
- stable: stableOnly,
932
+ const stableOnly: SelectorPool = {
933
+ stable: buildStable(),
866
934
  finder: [],
867
- });
868
- recordSelection(bypassed, stableOnly.length, keptAll);
869
- return closed(bypassed, selectorRanOver(stableOnly.length));
935
+ };
936
+ const {
937
+ selections: bypassed,
938
+ keptAll,
939
+ failure,
940
+ } = await runSelection(stableOnly);
941
+ recordSelection(
942
+ bypassed,
943
+ stableOnly.stable.length,
944
+ keptAll,
945
+ failure,
946
+ );
947
+ return closed(
948
+ bypassed,
949
+ selectorRanOver(stableOnly.stable.length, failure),
950
+ failure,
951
+ { pool: stableOnly, keptAll },
952
+ );
870
953
  }
871
954
  // Hard skip: the selector is never consulted, so this is a zero
872
955
  // selection BY CONSTRUCTION, not a judgment that nothing was relevant.
@@ -972,13 +1055,17 @@ export async function orchestrate(
972
1055
  Date.now() - expandStartedAt,
973
1056
  );
974
1057
  const poolSize = stable.length + finderTail.length;
975
- const { selections, keptAll } = await runSelection(pool);
976
- recordSelection(selections, poolSize, keptAll);
1058
+ const { selections, keptAll, failure } = await runSelection(pool);
1059
+ recordSelection(selections, poolSize, keptAll, failure);
977
1060
 
978
1061
  return {
979
1062
  selections,
980
1063
  lanes: { core, hot, fresh, always, finder },
981
- selectorRan: selectorRanOver(poolSize),
1064
+ selectorRan: selectorRanOver(poolSize, failure),
1065
+ pool,
1066
+ keptAll,
1067
+ ...(gateOutcome ? { gateReason: gateOutcome.reason } : {}),
1068
+ ...(failure ? { selectorFailure: failure } : {}),
982
1069
  };
983
1070
  }
984
1071