@vellumai/assistant 0.12.5 → 0.12.6-dev.202609292116.41f7caa

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 (295) hide show
  1. package/.env.example +2 -0
  2. package/ARCHITECTURE.md +26 -3
  3. package/docs/automatic-done.md +32 -0
  4. package/docs/desktop-browser-cli.md +3 -2
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/mcp-oauth.test.ts +53 -0
  7. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/mcp-oauth.ts +60 -0
  9. package/node_modules/@vellumai/environments/src/shell.test.ts +13 -1
  10. package/node_modules/@vellumai/environments/src/shell.ts +22 -1
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  12. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/mcp-oauth.test.ts +53 -0
  13. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  14. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/mcp-oauth.ts +60 -0
  15. package/node_modules/@vellumai/gateway-client/src/__tests__/status-contracts.test.ts +39 -0
  16. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +25 -0
  17. package/node_modules/@vellumai/service-contracts/package.json +2 -1
  18. package/node_modules/@vellumai/service-contracts/src/__tests__/mcp-oauth.test.ts +53 -0
  19. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  20. package/node_modules/@vellumai/service-contracts/src/mcp-oauth.ts +60 -0
  21. package/openapi.yaml +270 -10
  22. package/package.json +1 -1
  23. package/src/__tests__/agent-loop.test.ts +76 -0
  24. package/src/__tests__/anthropic-provider.test.ts +2 -0
  25. package/src/__tests__/byok-default-profile-ensure.test.ts +7 -0
  26. package/src/__tests__/config-get-vision-flag.test.ts +1 -1
  27. package/src/__tests__/config-loader-validation-reset-notice.test.ts +2 -2
  28. package/src/__tests__/conversation-agent-loop-overflow.test.ts +160 -154
  29. package/src/__tests__/conversation-evictor.test.ts +54 -0
  30. package/src/__tests__/conversation-pairing.test.ts +112 -0
  31. package/src/__tests__/document-append-idempotency.test.ts +13 -0
  32. package/src/__tests__/document-create-dedupe.test.ts +13 -0
  33. package/src/__tests__/document-find-replace.test.ts +13 -0
  34. package/src/__tests__/document-revision-history.test.ts +390 -0
  35. package/src/__tests__/document-revision.test.ts +425 -0
  36. package/src/__tests__/document-tool-security.test.ts +13 -0
  37. package/src/__tests__/document-update-default-surface.test.ts +13 -0
  38. package/src/__tests__/emit-signal-routing-intent.test.ts +270 -0
  39. package/src/__tests__/gateway-flag-listener.test.ts +59 -6
  40. package/src/__tests__/headless-browser-navigate.test.ts +20 -30
  41. package/src/__tests__/host-cu-proxy.test.ts +79 -0
  42. package/src/__tests__/host-cu-routes-targeted.test.ts +14 -5
  43. package/src/__tests__/host-shell-tool.test.ts +6 -2
  44. package/src/__tests__/identity-routes.test.ts +4 -3
  45. package/src/__tests__/list-messages-client-message-id.test.ts +43 -0
  46. package/src/__tests__/llm-catalog-parity.test.ts +4 -4
  47. package/src/__tests__/llm-schema.test.ts +1 -1
  48. package/src/__tests__/notification-platform-adapter.test.ts +83 -1
  49. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  50. package/src/__tests__/openai-provider.test.ts +38 -11
  51. package/src/__tests__/pricing.test.ts +11 -0
  52. package/src/__tests__/public-ingress-urls.test.ts +26 -0
  53. package/src/__tests__/retry-thinking-tool-choice.test.ts +47 -0
  54. package/src/__tests__/skill-execute-input.test.ts +11 -0
  55. package/src/__tests__/tool-approval-handler.test.ts +4 -0
  56. package/src/__tests__/tool-preview-lifecycle.test.ts +26 -0
  57. package/src/__tests__/usage-attribution.test.ts +31 -0
  58. package/src/__tests__/workspace-migration-133-collapse-provider-connections.test.ts +3 -3
  59. package/src/__tests__/workspace-migration-160-repair-retired-fireworks-model-ids.test.ts +262 -0
  60. package/src/__tests__/workspace-migration-161-repair-retired-fireworks-glm-5p2-model-id.test.ts +233 -0
  61. package/src/acp/agent-process.ts +6 -1
  62. package/src/acp/client-handler.ts +6 -1
  63. package/src/agent/loop-unparseable-tool-retry.test.ts +333 -0
  64. package/src/agent/loop.ts +161 -17
  65. package/src/api/events/document-editor-show.ts +5 -0
  66. package/src/api/events/document-editor-update.ts +10 -0
  67. package/src/api/events/tool-result.ts +1 -0
  68. package/src/calls/__tests__/call-controller.test.ts +273 -1
  69. package/src/calls/__tests__/media-stream-server-integration.test.ts +40 -0
  70. package/src/calls/__tests__/media-stream-stt-session.test.ts +33 -0
  71. package/src/calls/__tests__/voice-session-bridge.test.ts +54 -0
  72. package/src/calls/call-constants.ts +10 -0
  73. package/src/calls/call-controller.ts +359 -25
  74. package/src/calls/channel-admission-reader.ts +15 -9
  75. package/src/calls/media-stream-server.ts +18 -0
  76. package/src/calls/media-stream-stt-session.ts +30 -9
  77. package/src/calls/twilio-provider.ts +14 -31
  78. package/src/calls/voice-session-bridge.ts +18 -1
  79. package/src/cli/commands/browser.ts +1 -2
  80. package/src/cli/lib/__tests__/plugin-artifact.test.ts +23 -0
  81. package/src/cli/lib/__tests__/plugin-catalog-visibility.test.ts +2 -0
  82. package/src/cli/lib/__tests__/plugin-details.test.ts +16 -0
  83. package/src/cli/lib/bundled-marketplace.json +54 -2
  84. package/src/cli/lib/inspect-plugin.ts +15 -15
  85. package/src/cli/lib/list-installed-plugins.ts +29 -28
  86. package/src/cli/lib/plugin-artifact.ts +74 -63
  87. package/src/cli/lib/plugin-catalog-visibility.ts +1 -0
  88. package/src/cli/lib/plugin-details.ts +27 -42
  89. package/src/cli/lib/plugin-marketplace.ts +6 -8
  90. package/src/cli/lib/plugin-surfaces.ts +8 -7
  91. package/src/cli.ts +1 -3
  92. package/src/config/__tests__/chat-settings.test.ts +121 -0
  93. package/src/config/assistant-feature-flags.ts +28 -23
  94. package/src/config/bundled-skills/computer-use/SKILL.md +20 -0
  95. package/src/config/bundled-skills/computer-use/TOOLS.json +11 -11
  96. package/src/config/chat-settings.ts +100 -0
  97. package/src/config/default-profile-catalog.ts +1 -1
  98. package/src/config/feature-flag-cache.ts +7 -25
  99. package/src/config/feature-flag-registry.json +9 -9
  100. package/src/config/loader.ts +5 -1
  101. package/src/config/profile-text-generation.ts +25 -21
  102. package/src/config/schemas/conversations.ts +14 -0
  103. package/src/config/schemas/llm.ts +1 -0
  104. package/src/config/schemas/notifications.ts +8 -1
  105. package/src/contacts/gateway-channel-read.ts +1 -1
  106. package/src/conversations/__tests__/auto-archive-activity.test.ts +136 -0
  107. package/src/conversations/__tests__/auto-archive.test.ts +304 -0
  108. package/src/conversations/auto-archive-activity.ts +61 -0
  109. package/src/conversations/auto-archive.ts +201 -0
  110. package/src/daemon/computer-use-observation.test.ts +81 -0
  111. package/src/daemon/computer-use-observation.ts +23 -0
  112. package/src/daemon/conversation-agent-loop-handlers.ts +18 -2
  113. package/src/daemon/conversation-agent-loop.ts +14 -6
  114. package/src/daemon/conversation-evictor.ts +50 -16
  115. package/src/daemon/conversation-messaging.ts +4 -0
  116. package/src/daemon/conversation-process.ts +15 -0
  117. package/src/daemon/conversation-queue-manager.ts +2 -0
  118. package/src/daemon/conversation.ts +2 -0
  119. package/src/daemon/doordash-steps.ts +1 -3
  120. package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +43 -1
  121. package/src/daemon/handlers/__tests__/config-channels.test.ts +1 -1
  122. package/src/daemon/handlers/config-a2a.ts +75 -2
  123. package/src/daemon/handlers/shared.ts +13 -2
  124. package/src/daemon/host-cu-proxy.ts +34 -3
  125. package/src/daemon/lifecycle.ts +15 -2
  126. package/src/daemon/process-message.ts +5 -0
  127. package/src/daemon/shutdown-handlers.ts +3 -0
  128. package/src/daemon/turn-finalization.ts +4 -0
  129. package/src/desktop/desktop-browser-endpoint.ts +11 -2
  130. package/src/desktop/desktop-chrome-policy.test.ts +1 -0
  131. package/src/desktop/desktop-chrome-policy.ts +7 -1
  132. package/src/desktop/desktop-computer-use.test.ts +14 -4
  133. package/src/desktop/desktop-session-manager.ts +5 -3
  134. package/src/documents/document-revisions-store.ts +153 -0
  135. package/src/documents/document-store.ts +471 -133
  136. package/src/followups/followup-store.ts +0 -4
  137. package/src/i18n/__tests__/i18n.test.ts +25 -0
  138. package/src/i18n/assistant-text.ts +18 -0
  139. package/src/i18n/index.ts +1 -0
  140. package/src/i18n/messages.ts +11 -0
  141. package/src/inbound/__tests__/oauth-callback-url.test.ts +25 -2
  142. package/src/inbound/oauth-callback-url.ts +30 -1
  143. package/src/inbound/public-ingress-urls.ts +12 -2
  144. package/src/ipc/gateway-client.test.ts +40 -0
  145. package/src/ipc/gateway-client.ts +12 -29
  146. package/src/ipc/gateway-flag-listener.ts +7 -10
  147. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +71 -0
  148. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +83 -0
  149. package/src/live-voice/__tests__/protocol.test.ts +77 -0
  150. package/src/live-voice/__tests__/share-targets.test.ts +163 -0
  151. package/src/live-voice/live-voice-session.ts +23 -6
  152. package/src/live-voice/protocol.ts +22 -0
  153. package/src/live-voice/share-targets.ts +225 -0
  154. package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +144 -3
  155. package/src/mcp/__tests__/mcp-oauth-provider.test.ts +2 -0
  156. package/src/mcp/client.ts +1 -19
  157. package/src/mcp/mcp-oauth-provider.ts +66 -49
  158. package/src/media/openrouter-image-service.ts +39 -22
  159. package/src/monitoring/__tests__/oom-inheritance-sweep.test.ts +86 -0
  160. package/src/monitoring/__tests__/oom-kill-reporter.test.ts +374 -0
  161. package/src/monitoring/control.ts +2 -1
  162. package/src/monitoring/oom-inheritance-sweep.ts +96 -0
  163. package/src/monitoring/oom-kill-reporter.ts +469 -0
  164. package/src/monitoring/resource-sampler.ts +30 -0
  165. package/src/notifications/README.md +53 -4
  166. package/src/notifications/__tests__/assistant-initiated-thread.test.ts +254 -0
  167. package/src/notifications/__tests__/broadcaster.test.ts +558 -6
  168. package/src/notifications/__tests__/completion-policy.test.ts +37 -0
  169. package/src/notifications/__tests__/from-me-alert-integration.test.ts +192 -0
  170. package/src/notifications/adapters/platform.ts +14 -0
  171. package/src/notifications/assistant-initiated-thread.ts +101 -0
  172. package/src/notifications/broadcaster.ts +94 -16
  173. package/src/notifications/chat-reply-policy.ts +11 -0
  174. package/src/notifications/completion-policy.ts +12 -2
  175. package/src/notifications/conversation-pairing.ts +15 -70
  176. package/src/notifications/emit-signal.ts +34 -3
  177. package/src/notifications/types.ts +2 -0
  178. package/src/oauth/connect-orchestrator.ts +1 -2
  179. package/src/persistence/__tests__/conversation-auto-archive.test.ts +330 -0
  180. package/src/persistence/conversation-auto-archive.ts +170 -0
  181. package/src/persistence/conversation-crud.ts +2 -1
  182. package/src/persistence/conversation-queries.ts +15 -8
  183. package/src/persistence/embeddings/embed.test.ts +96 -0
  184. package/src/persistence/embeddings/embed.ts +20 -2
  185. package/src/persistence/embeddings/embedding-gemini.test.ts +39 -3
  186. package/src/persistence/embeddings/embedding-gemini.ts +39 -1
  187. package/src/persistence/embeddings/embedding-local.ts +6 -0
  188. package/src/persistence/embeddings/qdrant-manager.ts +5 -0
  189. package/src/persistence/job-utils.ts +10 -0
  190. package/src/persistence/migrations/384-add-document-revision.test.ts +83 -0
  191. package/src/persistence/migrations/384-add-document-revision.ts +21 -0
  192. package/src/persistence/migrations/385-create-document-revisions.test.ts +67 -0
  193. package/src/persistence/migrations/385-create-document-revisions.ts +28 -0
  194. package/src/persistence/migrations/386-conversations-last-reopened-at.ts +10 -0
  195. package/src/persistence/migrations/__tests__/386-conversations-last-reopened-at.test.ts +32 -0
  196. package/src/persistence/schema/conversations.ts +1 -0
  197. package/src/persistence/schema/documents.ts +20 -0
  198. package/src/persistence/steps.ts +6 -0
  199. package/src/platform/managed-speech.ts +42 -46
  200. package/src/plugin-api/vision-support.test.ts +21 -21
  201. package/src/plugins/defaults/empty-response/hooks/post-model-call.ts +11 -6
  202. package/src/providers/__tests__/dispatch-connection-routing.test.ts +1 -1
  203. package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
  204. package/src/providers/__tests__/unparseable-tool-args.test.ts +25 -0
  205. package/src/providers/anthropic/client.ts +1 -1
  206. package/src/providers/fireworks/client.test.ts +168 -0
  207. package/src/providers/fireworks/client.ts +24 -0
  208. package/src/providers/inference/adapter-factory.ts +6 -0
  209. package/src/providers/ionet/client.test.ts +143 -0
  210. package/src/providers/ionet/client.ts +37 -0
  211. package/src/providers/model-catalog.ts +141 -84
  212. package/src/providers/model-intents.ts +4 -4
  213. package/src/providers/openai/chat-completions-provider.ts +33 -2
  214. package/src/providers/retry.ts +56 -4
  215. package/src/providers/search-provider-catalog.ts +10 -0
  216. package/src/providers/types.ts +10 -2
  217. package/src/providers/unparseable-tool-args.ts +29 -5
  218. package/src/providers/usage-tracking.ts +5 -3
  219. package/src/runtime/assistant-event-hub.ts +23 -0
  220. package/src/runtime/auth/__tests__/guard-tests.test.ts +0 -7
  221. package/src/runtime/auth/__tests__/route-policy.test.ts +1 -1
  222. package/src/runtime/channel-reply-delivery.ts +2 -0
  223. package/src/runtime/channel-retry-sweep.ts +1 -2
  224. package/src/runtime/http-types.ts +3 -0
  225. package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +35 -10
  226. package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +48 -0
  227. package/src/runtime/migrations/vbundle-builder.ts +19 -3
  228. package/src/runtime/migrations/vbundle-streaming-importer.ts +14 -19
  229. package/src/runtime/migrations/vbundle-streaming-validator.ts +14 -7
  230. package/src/runtime/migrations/vbundle-validator.ts +54 -1
  231. package/src/runtime/pending-interactions.ts +20 -2
  232. package/src/runtime/routes/__tests__/contact-routes.test.ts +5 -3
  233. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +36 -0
  234. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +375 -2
  235. package/src/runtime/routes/__tests__/inference-send-routes.test.ts +1 -1
  236. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +12 -0
  237. package/src/runtime/routes/contact-routes.ts +2 -2
  238. package/src/runtime/routes/conversation-list-routes.ts +10 -2
  239. package/src/runtime/routes/conversation-query-routes.ts +48 -10
  240. package/src/runtime/routes/conversation-routes.ts +7 -0
  241. package/src/runtime/routes/documents-routes.ts +185 -47
  242. package/src/runtime/routes/events-routes.ts +1 -3
  243. package/src/runtime/routes/host-app-control-routes.ts +1 -0
  244. package/src/runtime/routes/host-bash-routes.ts +1 -0
  245. package/src/runtime/routes/host-browser-routes.ts +15 -0
  246. package/src/runtime/routes/host-cu-routes.ts +4 -1
  247. package/src/runtime/routes/host-file-routes.ts +1 -0
  248. package/src/runtime/routes/host-proxy-result-binding.test.ts +83 -0
  249. package/src/runtime/routes/host-proxy-result-binding.ts +25 -0
  250. package/src/runtime/routes/host-transfer-routes.ts +1 -0
  251. package/src/runtime/routes/identity-routes.ts +3 -3
  252. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -0
  253. package/src/runtime/routes/integrations/a2a.ts +4 -1
  254. package/src/runtime/routes/migration-routes.ts +3 -1
  255. package/src/runtime/routes/notification-routes.ts +1 -2
  256. package/src/runtime/routes/ui-snapshot-routes.test.ts +1 -0
  257. package/src/runtime/routes/user-route-dispatcher.ts +5 -1
  258. package/src/runtime/verification-templates.ts +1 -2
  259. package/src/sequence/analytics.ts +0 -4
  260. package/src/sequence/engine.ts +1 -11
  261. package/src/sequence/guardrails.ts +1 -6
  262. package/src/sequence/importer.ts +0 -8
  263. package/src/sequence/store.ts +0 -10
  264. package/src/signals/user-message.ts +0 -6
  265. package/src/skills/catalog-install.ts +52 -23
  266. package/src/tools/browser/browser-execution.ts +20 -23
  267. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +1 -3
  268. package/src/tools/document/document-tool.ts +13 -11
  269. package/src/tools/executor.ts +1 -2
  270. package/src/tools/host-terminal/host-shell.ts +4 -2
  271. package/src/tools/network/__tests__/web-search.test.ts +105 -0
  272. package/src/tools/network/web-fetch.ts +2 -4
  273. package/src/tools/network/web-search.ts +239 -12
  274. package/src/tools/shared/filesystem/format-diff.ts +1 -2
  275. package/src/tools/shared/shell-output.test.ts +42 -0
  276. package/src/tools/shared/shell-output.ts +27 -4
  277. package/src/tools/skills/execute.ts +10 -7
  278. package/src/tools/terminal/shell.ts +3 -3
  279. package/src/tools/tool-approval-handler.ts +9 -1
  280. package/src/tools/ui-surface/visual-validation.ts +1 -2
  281. package/src/usage/attribution.ts +18 -1
  282. package/src/util/__tests__/oom-priority.test.ts +55 -0
  283. package/src/util/__tests__/worker-memory.test.ts +16 -0
  284. package/src/util/debounce.ts +0 -4
  285. package/src/util/oom-priority.ts +79 -0
  286. package/src/util/platform.ts +0 -2
  287. package/src/util/plugin-manifest.ts +5 -7
  288. package/src/util/pricing.ts +25 -0
  289. package/src/util/worker-memory.ts +9 -3
  290. package/src/util/worker-process.ts +8 -0
  291. package/src/workspace/byok-default-profile-ensure.ts +11 -0
  292. package/src/workspace/hatched-date.ts +11 -4
  293. package/src/workspace/migrations/160-repair-retired-fireworks-model-ids.ts +209 -0
  294. package/src/workspace/migrations/161-repair-retired-fireworks-glm-5p2-model-id.ts +197 -0
  295. package/src/workspace/migrations/registry.ts +4 -0
@@ -0,0 +1,170 @@
1
+ import {
2
+ and,
3
+ asc,
4
+ eq,
5
+ gt,
6
+ inArray,
7
+ isNull,
8
+ lte,
9
+ notExists,
10
+ notInArray,
11
+ or,
12
+ sql,
13
+ } from "drizzle-orm";
14
+
15
+ import { ACP_LIVE_STATUSES } from "../acp/types.js";
16
+ import { TERMINAL_STATUSES } from "../subagent/types.js";
17
+ import { unseenAttentionStateConditions } from "./conversation-attention-store.js";
18
+ import { ensureGroupMigration } from "./conversation-group-migration.js";
19
+ import {
20
+ MEMORY_V2_CONSOLIDATION_SOURCE,
21
+ NATIVE_ORIGIN_CHANNEL,
22
+ PINNED_GROUP_ID,
23
+ } from "./conversation-types.js";
24
+ import { getDb } from "./db-connection.js";
25
+ import { rawChanges } from "./raw-query.js";
26
+ import {
27
+ acpSessionHistory,
28
+ conversationAssistantAttentionState,
29
+ conversationModeSessions,
30
+ conversations,
31
+ externalConversationBindings,
32
+ workflowRuns,
33
+ } from "./schema/index.js";
34
+
35
+ const activityAt = sql<number>`max(coalesce(${conversations.lastMessageAt}, ${conversations.createdAt}), coalesce(${conversations.lastReopenedAt}, ${conversations.createdAt}))`;
36
+
37
+ export interface AutoArchiveCandidate {
38
+ id: string;
39
+ createdAt: number;
40
+ lastMessageAt: number | null;
41
+ lastReopenedAt: number | null;
42
+ activityAt: number;
43
+ }
44
+
45
+ function eligibleForAutoArchive(cutoff: number) {
46
+ const db = getDb();
47
+ const origin = sql`coalesce((select ${externalConversationBindings.sourceChannel} from ${externalConversationBindings} where ${externalConversationBindings.conversationId} = ${conversations.id}), ${conversations.originChannel}, ${NATIVE_ORIGIN_CHANNEL})`;
48
+ return and(
49
+ isNull(conversations.archivedAt),
50
+ isNull(conversations.processingStartedAt),
51
+ eq(conversations.conversationType, "standard"),
52
+ notInArray(conversations.source, [
53
+ "subagent",
54
+ "system",
55
+ MEMORY_V2_CONSOLIDATION_SOURCE,
56
+ ]),
57
+ isNull(conversations.parentConversationId),
58
+ isNull(conversations.scheduleJobId),
59
+ sql`coalesce(group_id, 'system:all') not in (${PINNED_GROUP_ID}, 'system:background', 'system:scheduled')`,
60
+ or(eq(origin, NATIVE_ORIGIN_CHANNEL), sql`${origin} like 'notification:%'`),
61
+ lte(activityAt, cutoff),
62
+ notExists(
63
+ db
64
+ .select({ id: conversationAssistantAttentionState.conversationId })
65
+ .from(conversationAssistantAttentionState)
66
+ .where(
67
+ and(
68
+ eq(
69
+ conversationAssistantAttentionState.conversationId,
70
+ conversations.id,
71
+ ),
72
+ ...unseenAttentionStateConditions(),
73
+ ),
74
+ ),
75
+ ),
76
+ notExists(
77
+ db
78
+ .select({ id: acpSessionHistory.id })
79
+ .from(acpSessionHistory)
80
+ .where(
81
+ and(
82
+ eq(acpSessionHistory.parentConversationId, conversations.id),
83
+ inArray(acpSessionHistory.status, [...ACP_LIVE_STATUSES]),
84
+ ),
85
+ ),
86
+ ),
87
+ notExists(
88
+ db
89
+ .select({ id: workflowRuns.id })
90
+ .from(workflowRuns)
91
+ .where(
92
+ and(
93
+ eq(workflowRuns.conversationId, conversations.id),
94
+ eq(workflowRuns.status, "running"),
95
+ ),
96
+ ),
97
+ ),
98
+ notExists(
99
+ db
100
+ .select({ id: conversationModeSessions.id })
101
+ .from(conversationModeSessions)
102
+ .where(
103
+ and(
104
+ eq(conversationModeSessions.conversationId, conversations.id),
105
+ eq(conversationModeSessions.status, "active"),
106
+ ),
107
+ ),
108
+ ),
109
+ sql`not exists (select 1 from subagents where parent_conversation_id = ${conversations.id} and status not in (${sql.join(
110
+ [...TERMINAL_STATUSES].map((status) => sql`${status}`),
111
+ sql`, `,
112
+ )}))`,
113
+ );
114
+ }
115
+
116
+ export function listAutoArchiveCandidates(options: {
117
+ cutoff: number;
118
+ limit: number;
119
+ after?: Pick<AutoArchiveCandidate, "activityAt" | "id">;
120
+ }): AutoArchiveCandidate[] {
121
+ ensureGroupMigration();
122
+ return getDb()
123
+ .select({
124
+ id: conversations.id,
125
+ createdAt: conversations.createdAt,
126
+ lastMessageAt: conversations.lastMessageAt,
127
+ lastReopenedAt: conversations.lastReopenedAt,
128
+ activityAt,
129
+ })
130
+ .from(conversations)
131
+ .where(
132
+ and(
133
+ eligibleForAutoArchive(options.cutoff),
134
+ options.after
135
+ ? or(
136
+ gt(activityAt, options.after.activityAt),
137
+ and(
138
+ eq(activityAt, options.after.activityAt),
139
+ gt(conversations.id, options.after.id),
140
+ ),
141
+ )
142
+ : undefined,
143
+ ),
144
+ )
145
+ .orderBy(asc(activityAt), asc(conversations.id))
146
+ .limit(options.limit)
147
+ .all();
148
+ }
149
+
150
+ /** The sampled cursors and current eligibility are checked by the write itself. */
151
+ export function archiveInactiveConversation(
152
+ candidate: AutoArchiveCandidate,
153
+ cutoff: number,
154
+ archivedAt: number,
155
+ ): boolean {
156
+ getDb()
157
+ .update(conversations)
158
+ .set({ archivedAt, updatedAt: archivedAt })
159
+ .where(
160
+ and(
161
+ eq(conversations.id, candidate.id),
162
+ eq(conversations.createdAt, candidate.createdAt),
163
+ sql`${conversations.lastMessageAt} is ${candidate.lastMessageAt}`,
164
+ sql`${conversations.lastReopenedAt} is ${candidate.lastReopenedAt}`,
165
+ eligibleForAutoArchive(cutoff),
166
+ ),
167
+ )
168
+ .run();
169
+ return rawChanges() > 0;
170
+ }
@@ -3594,7 +3594,8 @@ export function unarchiveConversation(id: string): boolean {
3594
3594
  const now = Date.now();
3595
3595
  rawRun(
3596
3596
  "conversation:unarchive",
3597
- "UPDATE conversations SET archived_at = NULL, updated_at = ? WHERE id = ?",
3597
+ "UPDATE conversations SET last_reopened_at = CASE WHEN archived_at IS NOT NULL THEN ? ELSE last_reopened_at END, archived_at = NULL, updated_at = ? WHERE id = ?",
3598
+ now,
3598
3599
  now,
3599
3600
  id,
3600
3601
  );
@@ -137,6 +137,19 @@ function archiveStatusClause(status: ArchiveStatusFilter) {
137
137
  }
138
138
  }
139
139
 
140
+ /**
141
+ * A conversation's last activity, the key every list read is ordered on: its
142
+ * latest message (its last update, for a row with no messages yet) or the
143
+ * moment it was marked done, whichever is later. Marking a chat done is
144
+ * something the user did to it, so a done chat files under the day it was
145
+ * marked done rather than the day it was last written to. An active row has
146
+ * no `archived_at`, so for every active listing this is plain message recency.
147
+ *
148
+ * SQLite's multi-argument `MAX` is NULL when any argument is, hence the
149
+ * `COALESCE` on each side.
150
+ */
151
+ const lastActivitySql = sql`MAX(COALESCE(${conversations.lastMessageAt}, ${conversations.updatedAt}), COALESCE(${conversations.archivedAt}, 0))`;
152
+
140
153
  /**
141
154
  * Raw SQL predicate for "visible in the standard (Recents) listing".
142
155
  *
@@ -532,9 +545,7 @@ export function listConversations(
532
545
  ensureGroupMigration();
533
546
  const db = getDb();
534
547
  const { limit, offset = 0, ...filter } = query;
535
- const recency = desc(
536
- sql`COALESCE(${conversations.lastMessageAt}, ${conversations.updatedAt})`,
537
- );
548
+ const recency = desc(lastActivitySql);
538
549
  // `id` closes the ordering so the sort is a total order. Rows tied on every
539
550
  // preceding key would otherwise have no defined relative order, which
540
551
  // becomes duplicated and skipped rows once a cursor pages across a tie
@@ -654,11 +665,7 @@ export function listPinnedConversations(
654
665
  ...(archiveCond ? [archiveCond] : []),
655
666
  ),
656
667
  )
657
- .orderBy(
658
- desc(
659
- sql`COALESCE(${conversations.lastMessageAt}, ${conversations.updatedAt})`,
660
- ),
661
- );
668
+ .orderBy(desc(lastActivitySql));
662
669
  return query.all().map(parseConversation);
663
670
  }
664
671
 
@@ -0,0 +1,96 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import { GeminiEmbedError } from "./embedding-gemini.js";
4
+
5
+ // Capture delays passed to abortableSleep so tests can assert retry behavior
6
+ // without depending on real clock.
7
+ const sleepCalls: number[] = [];
8
+
9
+ mock.module("../../util/retry.js", () => ({
10
+ abortableSleep: async (ms: number) => {
11
+ sleepCalls.push(ms);
12
+ },
13
+ computeRetryDelay: (attempt: number, base: number) =>
14
+ base * Math.pow(2, attempt),
15
+ isRetryableNetworkError: () => false,
16
+ }));
17
+
18
+ const mockEmbedWithBackend = mock();
19
+ mock.module("./embedding-backend.js", () => ({
20
+ embedWithBackend: mockEmbedWithBackend,
21
+ }));
22
+
23
+ import { EMBED_BASE_DELAY_MS, embedWithRetry } from "./embed.js";
24
+
25
+ const STUB_CONFIG = {} as Parameters<typeof embedWithRetry>[0];
26
+
27
+ describe("embedWithRetry: Retry-After header", () => {
28
+ beforeEach(() => {
29
+ sleepCalls.length = 0;
30
+ mockEmbedWithBackend.mockClear();
31
+ });
32
+
33
+ afterEach(() => {
34
+ mockEmbedWithBackend.mockRestore();
35
+ });
36
+
37
+ test("uses retryAfterMs from error as the sleep delay when present", async () => {
38
+ const retryAfterMs = 5000;
39
+ mockEmbedWithBackend
40
+ .mockRejectedValueOnce(
41
+ new GeminiEmbedError("rate limited", 429, retryAfterMs),
42
+ )
43
+ .mockResolvedValueOnce({
44
+ provider: "gemini",
45
+ model: "m",
46
+ vectors: [[0.1, 0.2]],
47
+ });
48
+
49
+ await embedWithRetry(STUB_CONFIG, ["hello"]);
50
+
51
+ expect(sleepCalls).toHaveLength(1);
52
+ expect(sleepCalls[0]).toBe(retryAfterMs);
53
+ });
54
+
55
+ test("falls back to exponential backoff when retryAfterMs is absent", async () => {
56
+ mockEmbedWithBackend
57
+ .mockRejectedValueOnce(new GeminiEmbedError("rate limited", 429))
58
+ .mockResolvedValueOnce({
59
+ provider: "gemini",
60
+ model: "m",
61
+ vectors: [[0.1, 0.2]],
62
+ });
63
+
64
+ await embedWithRetry(STUB_CONFIG, ["hello"]);
65
+
66
+ expect(sleepCalls).toHaveLength(1);
67
+ expect(sleepCalls[0]).toBe(EMBED_BASE_DELAY_MS);
68
+ });
69
+
70
+ test("retries up to EMBED_MAX_RETRIES times before throwing", async () => {
71
+ const error = new GeminiEmbedError("rate limited", 429, 1);
72
+ mockEmbedWithBackend.mockRejectedValue(error);
73
+
74
+ await expect(embedWithRetry(STUB_CONFIG, ["hello"])).rejects.toThrow(
75
+ "rate limited",
76
+ );
77
+ expect(sleepCalls).toHaveLength(3);
78
+ });
79
+
80
+ test("returns result without retrying on success", async () => {
81
+ mockEmbedWithBackend.mockResolvedValueOnce({
82
+ provider: "gemini",
83
+ model: "m",
84
+ vectors: [[0.5, 0.6]],
85
+ });
86
+
87
+ const result = await embedWithRetry(STUB_CONFIG, ["hello"]);
88
+
89
+ expect(result).toEqual({
90
+ provider: "gemini",
91
+ model: "m",
92
+ vectors: [[0.5, 0.6]],
93
+ });
94
+ expect(sleepCalls).toHaveLength(0);
95
+ });
96
+ });
@@ -38,9 +38,16 @@ export async function embedWithRetry(
38
38
  if (!isTransient || attempt === EMBED_MAX_RETRIES) {
39
39
  throw err;
40
40
  }
41
- const delay = computeRetryDelay(attempt, EMBED_BASE_DELAY_MS);
41
+ const retryAfterMs = getErrorRetryAfterMs(err);
42
+ const delay =
43
+ retryAfterMs ?? computeRetryDelay(attempt, EMBED_BASE_DELAY_MS);
42
44
  log.warn(
43
- { err, attempt: attempt + 1, delayMs: Math.round(delay) },
45
+ {
46
+ err,
47
+ attempt: attempt + 1,
48
+ delayMs: Math.round(delay),
49
+ retryAfterHeader: retryAfterMs !== undefined,
50
+ },
44
51
  "Transient embedding failure, retrying",
45
52
  );
46
53
  await abortableSleep(delay, opts?.signal);
@@ -87,6 +94,17 @@ function getErrorStatusCode(err: Error): unknown {
87
94
  return undefined;
88
95
  }
89
96
 
97
+ function getErrorRetryAfterMs(err: unknown): number | undefined {
98
+ if (!(err instanceof Error)) {
99
+ return undefined;
100
+ }
101
+ const candidate = (err as { retryAfterMs?: unknown }).retryAfterMs;
102
+ if (typeof candidate === "number" && candidate >= 0) {
103
+ return candidate;
104
+ }
105
+ return undefined;
106
+ }
107
+
90
108
  function isHttpStatusError(err: unknown): boolean {
91
109
  if (!(err instanceof Error)) {
92
110
  return false;
@@ -1,6 +1,9 @@
1
1
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
- import { GeminiEmbeddingBackend } from "./embedding-gemini.js";
3
+ import {
4
+ GeminiEmbeddingBackend,
5
+ GeminiEmbedError,
6
+ } from "./embedding-gemini.js";
4
7
 
5
8
  function makeSuccessResponse(values: number[]) {
6
9
  return new Response(JSON.stringify({ embedding: { values } }), {
@@ -186,18 +189,51 @@ describe("GeminiEmbeddingBackend", () => {
186
189
  });
187
190
 
188
191
  describe("error handling", () => {
189
- test("throws on non-OK response", async () => {
192
+ test("throws GeminiEmbedError on non-OK response", async () => {
190
193
  mockFetch = mock(() =>
191
194
  Promise.resolve(new Response("Internal Server Error", { status: 500 })),
192
195
  );
193
196
  globalThis.fetch = mockFetch as unknown as typeof fetch;
194
197
 
195
198
  const backend = new GeminiEmbeddingBackend("test-key", "test-model");
196
- await expect(backend.embed(["hello"])).rejects.toThrow(
199
+ const err = await backend.embed(["hello"]).catch((e: unknown) => e);
200
+ expect(err).toBeInstanceOf(GeminiEmbedError);
201
+ expect((err as GeminiEmbedError).status).toBe(500);
202
+ expect((err as GeminiEmbedError).message).toContain(
197
203
  "Gemini embeddings request failed (500): Internal Server Error",
198
204
  );
199
205
  });
200
206
 
207
+ test("attaches retryAfterMs from Retry-After delta-seconds header on 429", async () => {
208
+ mockFetch = mock(() =>
209
+ Promise.resolve(
210
+ new Response("rate limited", {
211
+ status: 429,
212
+ headers: { "Retry-After": "30" },
213
+ }),
214
+ ),
215
+ );
216
+ globalThis.fetch = mockFetch as unknown as typeof fetch;
217
+
218
+ const backend = new GeminiEmbeddingBackend("test-key", "test-model");
219
+ const err = await backend.embed(["hello"]).catch((e: unknown) => e);
220
+ expect(err).toBeInstanceOf(GeminiEmbedError);
221
+ expect((err as GeminiEmbedError).status).toBe(429);
222
+ expect((err as GeminiEmbedError).retryAfterMs).toBe(30000);
223
+ });
224
+
225
+ test("retryAfterMs is undefined on 429 without Retry-After header", async () => {
226
+ mockFetch = mock(() =>
227
+ Promise.resolve(new Response("rate limited", { status: 429 })),
228
+ );
229
+ globalThis.fetch = mockFetch as unknown as typeof fetch;
230
+
231
+ const backend = new GeminiEmbeddingBackend("test-key", "test-model");
232
+ const err = await backend.embed(["hello"]).catch((e: unknown) => e);
233
+ expect(err).toBeInstanceOf(GeminiEmbedError);
234
+ expect((err as GeminiEmbedError).retryAfterMs).toBeUndefined();
235
+ });
236
+
201
237
  test("throws when response is missing embedding values", async () => {
202
238
  mockFetch = mock(() =>
203
239
  Promise.resolve(
@@ -10,6 +10,17 @@ import { normalizeEmbeddingInput } from "./embedding-types.js";
10
10
 
11
11
  const log = getLogger("memory-embeddings");
12
12
 
13
+ export class GeminiEmbedError extends Error {
14
+ readonly status: number;
15
+ readonly retryAfterMs?: number;
16
+ constructor(message: string, status: number, retryAfterMs?: number) {
17
+ super(message);
18
+ this.name = "GeminiEmbedError";
19
+ this.status = status;
20
+ this.retryAfterMs = retryAfterMs;
21
+ }
22
+ }
23
+
13
24
  interface GeminiEmbedResponse {
14
25
  embedding?: {
15
26
  values?: number[];
@@ -277,8 +288,13 @@ export class GeminiEmbeddingBackend implements EmbeddingBackend {
277
288
  });
278
289
  if (!response.ok) {
279
290
  const responseBody = await response.text();
280
- throw new Error(
291
+ const retryAfterMs = parseRetryAfterHeader(
292
+ response.headers.get("Retry-After"),
293
+ );
294
+ throw new GeminiEmbedError(
281
295
  `Gemini embeddings request failed (${response.status}): ${responseBody}`,
296
+ response.status,
297
+ retryAfterMs,
282
298
  );
283
299
  }
284
300
  const payload = (await response.json()) as GeminiEmbedResponse;
@@ -322,3 +338,25 @@ export class GeminiEmbeddingBackend implements EmbeddingBackend {
322
338
  ];
323
339
  }
324
340
  }
341
+
342
+ /**
343
+ * Parse the value of a `Retry-After` response header into milliseconds.
344
+ * Accepts a delta-seconds integer ("30") or an HTTP date string.
345
+ * Returns undefined when the value is absent, unparseable, or in the past.
346
+ */
347
+ function parseRetryAfterHeader(value: string | null): number | undefined {
348
+ if (!value) {
349
+ return undefined;
350
+ }
351
+ const trimmed = value.trim();
352
+ const seconds = Number(trimmed);
353
+ if (!Number.isNaN(seconds) && seconds >= 0) {
354
+ return Math.ceil(seconds * 1000);
355
+ }
356
+ const date = new Date(trimmed);
357
+ if (!isNaN(date.getTime())) {
358
+ const ms = date.getTime() - Date.now();
359
+ return ms > 0 ? ms : undefined;
360
+ }
361
+ return undefined;
362
+ }
@@ -11,6 +11,10 @@ import type { Subprocess } from "bun";
11
11
 
12
12
  import { getIsContainerized } from "../../config/env-registry.js";
13
13
  import { getLogger } from "../../util/logger.js";
14
+ import {
15
+ setOomScoreAdj,
16
+ WORKER_OOM_SCORE_ADJ,
17
+ } from "../../util/oom-priority.js";
14
18
  import {
15
19
  getEmbeddingModelsDir,
16
20
  getEmbedWorkerPidPath,
@@ -395,6 +399,8 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
395
399
  cwd: embeddingModelsDir,
396
400
  });
397
401
 
402
+ setOomScoreAdj(WORKER_OOM_SCORE_ADJ, proc.pid);
403
+
398
404
  // Type-compatible assignment
399
405
  this.workerProc = proc;
400
406
 
@@ -17,6 +17,10 @@ import type { Subprocess } from "bun";
17
17
 
18
18
  import { getQdrantReadyzTimeoutMs, getQdrantUrlEnv } from "../../config/env.js";
19
19
  import { getLogger } from "../../util/logger.js";
20
+ import {
21
+ setOomScoreAdj,
22
+ WORKER_OOM_SCORE_ADJ,
23
+ } from "../../util/oom-priority.js";
20
24
  import { getDataDir } from "../../util/platform.js";
21
25
 
22
26
  const log = getLogger("qdrant-manager");
@@ -288,6 +292,7 @@ export class QdrantManager {
288
292
  } catch (err) {
289
293
  throw this.startError(err, "spawn");
290
294
  }
295
+ setOomScoreAdj(WORKER_OOM_SCORE_ADJ, proc.pid);
291
296
  this.process = proc;
292
297
  this.drainOutputFrom(proc.stdout, proc.stderr);
293
298
 
@@ -54,6 +54,16 @@ export const RETRY_MAX_ATTEMPTS = 8;
54
54
  * Fatal: 400 bad request, 401 auth, 403 permission, other 4xx client errors.
55
55
  */
56
56
  export function classifyError(err: unknown): ErrorCategory {
57
+ // LOCAL PATCH (juno, 2026-09-26): SQLite write contention is transient, retry it.
58
+ if (
59
+ err instanceof Error &&
60
+ (/database is locked|SQLITE_BUSY/i.test(err.message) ||
61
+ /^SQLITE_(BUSY|IOERR)/.test(
62
+ String((err as Error & { code?: string }).code ?? ""),
63
+ ))
64
+ ) {
65
+ return "retryable";
66
+ }
57
67
  // Timeout errors from our own Promise.race wrappers
58
68
  if (err instanceof Error && err.message.includes("timeout")) {
59
69
  return "retryable";
@@ -0,0 +1,83 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { describe, expect, test } from "bun:test";
3
+
4
+ import { drizzle } from "drizzle-orm/bun-sqlite";
5
+
6
+ import * as schema from "../schema.js";
7
+ import { migrateAddDocumentWorkspacePath } from "./360-add-document-workspace-path.js";
8
+ import { migrateAddDocumentRevision } from "./384-add-document-revision.js";
9
+
10
+ /** Pre-384 shape: the documents table as core tables and migration 360 left it. */
11
+ function createTestDb() {
12
+ const sqlite = new Database(":memory:");
13
+ sqlite.exec(/*sql*/ `
14
+ CREATE TABLE conversations (
15
+ id TEXT PRIMARY KEY,
16
+ created_at INTEGER NOT NULL
17
+ );
18
+
19
+ CREATE TABLE documents (
20
+ surface_id TEXT PRIMARY KEY,
21
+ conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
22
+ title TEXT NOT NULL,
23
+ content TEXT NOT NULL,
24
+ word_count INTEGER NOT NULL DEFAULT 0,
25
+ created_at INTEGER NOT NULL,
26
+ updated_at INTEGER NOT NULL
27
+ );
28
+
29
+ INSERT INTO conversations (id, created_at) VALUES ('conv-1', 1000);
30
+ INSERT INTO documents (surface_id, conversation_id, title, content, word_count, created_at, updated_at)
31
+ VALUES ('doc-1', 'conv-1', 'Doc', 'body', 1, 1000, 1000);
32
+ `);
33
+ const db = drizzle(sqlite, { schema });
34
+ migrateAddDocumentWorkspacePath(db);
35
+ return { sqlite, db };
36
+ }
37
+
38
+ function revisionColumn(sqlite: Database) {
39
+ return (
40
+ sqlite.query("PRAGMA table_info(documents)").all() as Array<{
41
+ name: string;
42
+ notnull: number;
43
+ dflt_value: string | null;
44
+ }>
45
+ ).find((c) => c.name === "revision");
46
+ }
47
+
48
+ describe("migration 384: documents.revision", () => {
49
+ test("adds a NOT NULL column defaulting to 0", () => {
50
+ const { sqlite, db } = createTestDb();
51
+ expect(revisionColumn(sqlite)).toBeUndefined();
52
+
53
+ migrateAddDocumentRevision(db);
54
+
55
+ const column = revisionColumn(sqlite);
56
+ expect(column?.notnull).toBe(1);
57
+ expect(column?.dflt_value).toBe("0");
58
+ });
59
+
60
+ test("existing rows read revision 0", () => {
61
+ const { sqlite, db } = createTestDb();
62
+
63
+ migrateAddDocumentRevision(db);
64
+
65
+ const row = sqlite
66
+ .query("SELECT revision FROM documents WHERE surface_id = 'doc-1'")
67
+ .get() as { revision: number };
68
+ expect(row.revision).toBe(0);
69
+ });
70
+
71
+ test("is idempotent", () => {
72
+ const { sqlite, db } = createTestDb();
73
+
74
+ migrateAddDocumentRevision(db);
75
+ sqlite.run("UPDATE documents SET revision = 3 WHERE surface_id = 'doc-1'");
76
+ expect(() => migrateAddDocumentRevision(db)).not.toThrow();
77
+
78
+ const row = sqlite
79
+ .query("SELECT revision FROM documents WHERE surface_id = 'doc-1'")
80
+ .get() as { revision: number };
81
+ expect(row.revision).toBe(3);
82
+ });
83
+ });
@@ -0,0 +1,21 @@
1
+ import type { DrizzleDb } from "../db-connection.js";
2
+ import { tableHasColumn } from "./schema-introspection.js";
3
+
4
+ const COLUMN_NAME = "revision";
5
+ const COLUMN_DEFINITION = "revision INTEGER NOT NULL DEFAULT 0";
6
+
7
+ /**
8
+ * Add `revision` to `documents`: a counter every write bumps by one, which
9
+ * lets a client make its save conditional on the revision it last saw.
10
+ *
11
+ * Existing rows start at 0 with no backfill; their next write moves them to 1.
12
+ *
13
+ * Idempotent: guarded with `tableHasColumn` so a crash between the `ALTER
14
+ * TABLE` and the checkpoint write does not raise a duplicate-column error on
15
+ * the next boot.
16
+ */
17
+ export function migrateAddDocumentRevision(database: DrizzleDb): void {
18
+ if (!tableHasColumn(database, "documents", COLUMN_NAME)) {
19
+ database.run(`ALTER TABLE documents ADD COLUMN ${COLUMN_DEFINITION}`);
20
+ }
21
+ }