@vellumai/assistant 0.12.3 → 0.12.4-dev.202609231620.a15f321

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 (209) hide show
  1. package/Dockerfile +7 -0
  2. package/README.md +12 -12
  3. package/docker/mousepad/90-vellum.gschema.override +4 -0
  4. package/node_modules/@vellumai/avatar-manifest/src/__tests__/notification-avatar.test.ts +48 -4
  5. package/node_modules/@vellumai/avatar-manifest/src/notification-avatar.ts +12 -8
  6. package/openapi.yaml +97 -4
  7. package/package.json +1 -1
  8. package/src/__tests__/approval-routes-http.test.ts +4 -2
  9. package/src/__tests__/call-routes-http.test.ts +4 -2
  10. package/src/__tests__/channel-readiness-discord.test.ts +8 -0
  11. package/src/__tests__/channel-readiness-slack-remote.test.ts +8 -0
  12. package/src/__tests__/config-loader-backfill.test.ts +1 -0
  13. package/src/__tests__/conversation-pairing.test.ts +10 -0
  14. package/src/__tests__/conversation-resurface-done.test.ts +289 -0
  15. package/src/__tests__/default-profile-catalog-fallback.test.ts +1 -0
  16. package/src/__tests__/gemini-provider.test.ts +31 -0
  17. package/src/__tests__/inference-profile-validation-jev.test.ts +17 -0
  18. package/src/__tests__/job-handler-registration-guard.test.ts +1 -0
  19. package/src/__tests__/llm-catalog-parity.test.ts +4 -0
  20. package/src/__tests__/llm-resolver-override-or-default.test.ts +43 -0
  21. package/src/__tests__/openai-responses-prompt-cache.test.ts +3 -1
  22. package/src/__tests__/platform-callback-registration.test.ts +46 -1
  23. package/src/__tests__/plugin-api-model-profiles.test.ts +7 -0
  24. package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
  25. package/src/__tests__/pricing.test.ts +33 -0
  26. package/src/__tests__/provider-error-scenarios.test.ts +46 -0
  27. package/src/__tests__/provider-platform-proxy-integration.test.ts +45 -15
  28. package/src/__tests__/retry-fallback-escalation.test.ts +22 -0
  29. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  30. package/src/__tests__/scaffold-managed-skill-tool.test.ts +116 -66
  31. package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -0
  32. package/src/__tests__/send-endpoint-busy.test.ts +4 -2
  33. package/src/__tests__/skill-load-inline-command.test.ts +35 -0
  34. package/src/__tests__/skill-load-tool.test.ts +42 -11
  35. package/src/__tests__/skill-tool-factory.test.ts +50 -2
  36. package/src/__tests__/steer-on-enqueue-question.test.ts +265 -34
  37. package/src/__tests__/validate-input.test.ts +73 -0
  38. package/src/__tests__/workspace-migration-159-rename-colliding-jev-profile-name.test.ts +197 -0
  39. package/src/api/ask-question-tool.ts +50 -0
  40. package/src/api/events/tool-result.ts +11 -0
  41. package/src/api/index.ts +7 -0
  42. package/src/api/requests/dictation.ts +2 -2
  43. package/src/api/responses/home.ts +27 -0
  44. package/src/avatar/notification-avatar.test.ts +42 -34
  45. package/src/avatar/notification-avatar.ts +1 -0
  46. package/src/calls/__tests__/voice-session-bridge.test.ts +63 -1
  47. package/src/calls/voice-escalation-judge.ts +6 -5
  48. package/src/calls/voice-screen-annotation.ts +44 -0
  49. package/src/calls/voice-session-bridge.ts +22 -0
  50. package/src/cli/commands/__tests__/plugins.test.ts +29 -1
  51. package/src/cli/commands/channel-verification-sessions.help.ts +12 -2
  52. package/src/cli/commands/platform/index.help.ts +2 -1
  53. package/src/cli/commands/platform/index.ts +8 -2
  54. package/src/cli/commands/plugins.ts +16 -0
  55. package/src/cli/lib/__tests__/inspect-plugin.test.ts +29 -0
  56. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +92 -2
  57. package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +15 -0
  58. package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +66 -2
  59. package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +21 -0
  60. package/src/cli/lib/__tests__/plugin-catalog-visibility.test.ts +65 -0
  61. package/src/cli/lib/__tests__/plugin-details.test.ts +43 -0
  62. package/src/cli/lib/__tests__/plugins-install-offline.test.ts +26 -0
  63. package/src/cli/lib/bundled-marketplace.json +4 -4
  64. package/src/cli/lib/inspect-plugin.ts +5 -0
  65. package/src/cli/lib/plugin-catalog-cache.ts +41 -4
  66. package/src/cli/lib/plugin-catalog-local.ts +9 -0
  67. package/src/cli/lib/plugin-catalog-platform.ts +48 -9
  68. package/src/cli/lib/plugin-catalog-visibility.ts +41 -0
  69. package/src/cli/lib/plugin-details.ts +10 -7
  70. package/src/cli/lib/search-plugins.ts +2 -0
  71. package/src/config/__tests__/default-profile-catalog.test.ts +61 -1
  72. package/src/config/__tests__/jev-managed-profile.test.ts +82 -0
  73. package/src/config/__tests__/sync-gated-profiles.test.ts +71 -0
  74. package/src/config/bundled-skills/screen-annotation/SKILL.md +6 -3
  75. package/src/config/call-site-defaults.ts +17 -11
  76. package/src/config/default-profile-catalog.ts +118 -19
  77. package/src/config/default-profile-names.ts +69 -0
  78. package/src/config/feature-flag-registry.json +25 -9
  79. package/src/config/inference-profile-validation.ts +7 -6
  80. package/src/config/llm-resolver.ts +16 -0
  81. package/src/config/profile-text-generation.test.ts +9 -0
  82. package/src/config/schemas/call-site-catalog.ts +8 -1
  83. package/src/config/schemas/llm.ts +70 -9
  84. package/src/config/sidebar-done-gate.ts +27 -0
  85. package/src/config/sync-gated-profiles.ts +96 -44
  86. package/src/daemon/__tests__/auto-profile-router.test.ts +228 -0
  87. package/src/daemon/auto-profile-router.ts +220 -0
  88. package/src/daemon/conversation-agent-loop.ts +77 -4
  89. package/src/daemon/conversation-slash.ts +2 -2
  90. package/src/daemon/conversation-turn-finalize.ts +12 -0
  91. package/src/daemon/dictation-text-processing.ts +4 -4
  92. package/src/daemon/handlers/__tests__/conversation-history-search.test.ts +61 -0
  93. package/src/daemon/handlers/config-channels.ts +3 -1
  94. package/src/daemon/handlers/conversation-history.ts +11 -1
  95. package/src/daemon/handlers/conversations.ts +118 -10
  96. package/src/daemon/pkb-reminder-builder.test.ts +10 -3
  97. package/src/daemon/pkb-reminder-builder.ts +1 -1
  98. package/src/desktop/desktop-browser-endpoint.ts +1 -0
  99. package/src/home/feed-types.ts +2 -0
  100. package/src/inbound/platform-callback-registration.ts +19 -6
  101. package/src/ipc/routes/__tests__/conversation-sync-ipc-routes.test.ts +49 -1
  102. package/src/ipc/routes/conversation-sync-ipc-routes.ts +38 -2
  103. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +27 -0
  104. package/src/live-voice/__tests__/protocol.test.ts +22 -0
  105. package/src/live-voice/continuation-judge.ts +1 -1
  106. package/src/live-voice/live-voice-session.ts +7 -6
  107. package/src/live-voice/protocol.ts +16 -5
  108. package/src/messaging/providers/gmail/client.ts +4 -0
  109. package/src/notifications/README.md +11 -3
  110. package/src/notifications/__tests__/home-feed-side-effect.test.ts +87 -3
  111. package/src/notifications/conversation-pairing.ts +23 -8
  112. package/src/notifications/home-feed-mirror.ts +39 -0
  113. package/src/notifications/home-feed-side-effect.ts +46 -19
  114. package/src/oauth/AGENTS.md +7 -1
  115. package/src/permissions/question-prompter.ts +14 -4
  116. package/src/persistence/conversation-crud.ts +114 -3
  117. package/src/persistence/conversation-queries.ts +48 -3
  118. package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +228 -32
  119. package/src/persistence/embeddings/embedding-backend-shutdown.test.ts +47 -0
  120. package/src/persistence/embeddings/embedding-backend.ts +23 -41
  121. package/src/persistence/embeddings/embedding-gemini.test.ts +17 -0
  122. package/src/persistence/embeddings/embedding-gemini.ts +3 -1
  123. package/src/persistence/embeddings/embedding-local.ts +72 -17
  124. package/src/persistence/embeddings/embedding-types.ts +0 -19
  125. package/src/persistence/jobs-store.ts +229 -0
  126. package/src/persistence/migrations/383-normalize-opencode-host-connections.ts +44 -0
  127. package/src/persistence/migrations/__tests__/383-normalize-opencode-host-connections.test.ts +102 -0
  128. package/src/persistence/steps.ts +12 -0
  129. package/src/platform/sync-avatar.test.ts +29 -0
  130. package/src/plugin-api/model-profiles.ts +5 -3
  131. package/src/plugins/defaults/memory/AGENTS.md +5 -3
  132. package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +2 -0
  133. package/src/plugins/defaults/memory/__tests__/skill-update-receipt-boundary-guard.test.ts +45 -0
  134. package/src/plugins/defaults/memory/__tests__/skill-update-receipt-job.test.ts +580 -0
  135. package/src/plugins/defaults/memory/graph/tools.ts +9 -5
  136. package/src/plugins/defaults/memory/hooks/conversation-deleted.ts +4 -0
  137. package/src/plugins/defaults/memory/job-handlers.ts +8 -2
  138. package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +5 -0
  139. package/src/plugins/defaults/memory/skill-update-receipt-job.ts +334 -0
  140. package/src/plugins/defaults/memory/substrate/skill-content.ts +1 -1
  141. package/src/plugins/defaults/memory/tools.test.ts +17 -0
  142. package/src/plugins/defaults/memory/v3/__tests__/render-injection.test.ts +3 -0
  143. package/src/providers/__tests__/connection-model-compat.test.ts +2 -0
  144. package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
  145. package/src/providers/anthropic/client.ts +2 -2
  146. package/src/providers/gemini/client.ts +17 -1
  147. package/src/providers/inference/auth.ts +25 -0
  148. package/src/providers/jev/ask.test.ts +66 -0
  149. package/src/{calls/typesafe-noul.ts → providers/jev/ask.ts} +75 -36
  150. package/src/providers/malformed-tool-call.ts +55 -0
  151. package/src/providers/model-catalog.ts +257 -4
  152. package/src/providers/openai/__tests__/chat-completions-malformed-tool-call.test.ts +105 -0
  153. package/src/providers/openai/chat-completions-provider.ts +30 -0
  154. package/src/providers/openai/codex-models.ts +2 -0
  155. package/src/providers/platform-proxy/constants.ts +5 -0
  156. package/src/providers/retry.ts +68 -36
  157. package/src/providers/search-provider-catalog.ts +10 -0
  158. package/src/providers/vellum-model-routing.test.ts +1 -0
  159. package/src/runtime/__tests__/agent-wake-archived-gate.test.ts +90 -0
  160. package/src/runtime/__tests__/question-resolution.test.ts +32 -0
  161. package/src/runtime/agent-wake.ts +9 -2
  162. package/src/runtime/auth/__tests__/middleware.test.ts +138 -0
  163. package/src/runtime/auth/middleware.ts +15 -11
  164. package/src/runtime/middleware/auth.ts +7 -1
  165. package/src/runtime/pending-interactions.ts +8 -8
  166. package/src/runtime/question-resolution.ts +54 -8
  167. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +146 -0
  168. package/src/runtime/routes/__tests__/dictation-processing.test.ts +174 -0
  169. package/src/runtime/routes/__tests__/global-search-routes.test.ts +74 -1
  170. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +60 -0
  171. package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +16 -1
  172. package/src/runtime/routes/__tests__/plugins-routes.test.ts +8 -0
  173. package/src/runtime/routes/conversation-list-routes.ts +16 -14
  174. package/src/runtime/routes/conversation-query-routes.ts +4 -0
  175. package/src/runtime/routes/conversation-routes.ts +4 -2
  176. package/src/runtime/routes/diagnostics-routes.ts +66 -229
  177. package/src/runtime/routes/global-search-routes.ts +22 -7
  178. package/src/runtime/routes/inference-provider-connection-routes.ts +25 -8
  179. package/src/runtime/routes/llm-call-sites-routes.ts +19 -10
  180. package/src/runtime/routes/platform-routes.ts +6 -1
  181. package/src/runtime/routes/plugins-routes.ts +5 -2
  182. package/src/runtime/sync/documents-sidecar-publish.test.ts +50 -4
  183. package/src/runtime/sync/resource-sync-events.ts +11 -0
  184. package/src/runtime/sync/worker-daemon-notify.test.ts +30 -0
  185. package/src/runtime/sync/worker-daemon-notify.ts +44 -0
  186. package/src/runtime/verification-outbound-actions.ts +2 -2
  187. package/src/skills/validate-input.ts +75 -11
  188. package/src/telegram/__tests__/webhook-health.test.ts +8 -0
  189. package/src/tools/ask-question/ask-question-tool.test.ts +38 -0
  190. package/src/tools/ask-question/ask-question-tool.ts +12 -1
  191. package/src/tools/network/__tests__/web-fetch-firecrawl.test.ts +28 -1
  192. package/src/tools/network/__tests__/web-fetch-metadata.test.ts +34 -0
  193. package/src/tools/network/__tests__/web-fetch-tinyfish.test.ts +35 -1
  194. package/src/tools/network/__tests__/web-search.test.ts +96 -0
  195. package/src/tools/network/web-fetch.ts +30 -9
  196. package/src/tools/network/web-search.ts +235 -4
  197. package/src/tools/skills/execute.ts +1 -1
  198. package/src/tools/skills/format-input-schema.ts +15 -0
  199. package/src/tools/skills/load.ts +15 -43
  200. package/src/tools/skills/scaffold-managed.ts +86 -71
  201. package/src/tools/skills/skill-tool-factory.ts +2 -1
  202. package/src/tools/workflows/manage-workflows.ts +4 -2
  203. package/src/util/worker-pipe.test.ts +146 -0
  204. package/src/util/worker-pipe.ts +49 -0
  205. package/src/workspace/migrations/158-repoint-opencode-host-provider-fragments.ts +163 -0
  206. package/src/workspace/migrations/159-rename-colliding-jev-profile-name.ts +344 -0
  207. package/src/workspace/migrations/__tests__/158-repoint-opencode-host-provider-fragments.test.ts +188 -0
  208. package/src/workspace/migrations/registry.ts +4 -0
  209. package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +0 -107
@@ -0,0 +1,228 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import type { ProfileEntry } from "../../config/schemas/llm.js";
4
+ import type {
5
+ Message,
6
+ Provider,
7
+ ProviderResponse,
8
+ SendMessageOptions,
9
+ } from "../../providers/types.js";
10
+ import {
11
+ AUTO_PROFILE_FALLBACK,
12
+ autoProfileCandidates,
13
+ autoProfileFallback,
14
+ recentConversationForRouter,
15
+ routeAutoProfile,
16
+ } from "../auto-profile-router.js";
17
+
18
+ function text(role: "user" | "assistant", body: string): Message {
19
+ return { role, content: [{ type: "text", text: body }] };
20
+ }
21
+
22
+ function jevResponse(answers: Record<string, unknown>): ProviderResponse {
23
+ return {
24
+ content: [{ type: "text", text: JSON.stringify(answers) }],
25
+ model: "jev-latest",
26
+ usage: { inputTokens: 1, outputTokens: 1 },
27
+ stopReason: "end_turn",
28
+ rawResponse: { model: "jev-latest", answers },
29
+ };
30
+ }
31
+
32
+ function fakeProvider(
33
+ respond: (
34
+ messages: Message[],
35
+ options?: SendMessageOptions,
36
+ ) => Promise<ProviderResponse>,
37
+ ): Provider {
38
+ return { name: "typesafe", sendMessage: respond } as Provider;
39
+ }
40
+
41
+ const managed = (label: string, status?: "disabled"): ProfileEntry => ({
42
+ source: "managed",
43
+ provider: "vellum",
44
+ model: "m",
45
+ label,
46
+ ...(status ? { status } : {}),
47
+ });
48
+
49
+ const profiles: Record<string, ProfileEntry> = {
50
+ auto: managed("Auto"),
51
+ balanced: managed("Balanced"),
52
+ "quality-optimized": managed("Quality"),
53
+ "cost-optimized": managed("Budget"),
54
+ "latency-optimized": managed("Fast"),
55
+ };
56
+
57
+ const base = {
58
+ conversationId: "conv-1",
59
+ history: [text("user", "hi"), text("assistant", "hello")],
60
+ userMessage: "Refactor the auth module and fix the race in token refresh",
61
+ profiles,
62
+ };
63
+
64
+ describe("autoProfileCandidates", () => {
65
+ test("offers the default profiles that exist and are not disabled", () => {
66
+ expect(
67
+ autoProfileCandidates({
68
+ ...profiles,
69
+ "cost-optimized": managed("Budget", "disabled"),
70
+ }),
71
+ ).toEqual(["balanced", "quality-optimized", "latency-optimized"]);
72
+ expect(autoProfileCandidates({ balanced: managed("Balanced") })).toEqual([
73
+ "balanced",
74
+ ]);
75
+ });
76
+ });
77
+
78
+ describe("recentConversationForRouter", () => {
79
+ test("drops injected blocks and the current message, keeps roles", () => {
80
+ const history: Message[] = [
81
+ text("user", "hey"),
82
+ { role: "user", content: [{ type: "text", text: "<memory>x</memory>" }] },
83
+ text("assistant", "Hi there"),
84
+ text("user", "do it"),
85
+ ];
86
+ expect(recentConversationForRouter(history, "do it")).toBe(
87
+ "user: hey\nassistant: Hi there",
88
+ );
89
+ });
90
+ });
91
+
92
+ describe("routeAutoProfile", () => {
93
+ test("pins the turn to Jev's choice and sends the candidates as criteria", async () => {
94
+ let sent: unknown;
95
+ const route = await routeAutoProfile({
96
+ ...base,
97
+ resolveProvider: async () =>
98
+ fakeProvider(async (messages) => {
99
+ const block = messages[0]?.content[0];
100
+ sent = JSON.parse(block?.type === "text" ? block.text : "{}");
101
+ return jevResponse({
102
+ profile: {
103
+ type: "choice",
104
+ choice: "quality-optimized",
105
+ confidence: 0.8,
106
+ probabilities: { "quality-optimized": 0.85, balanced: 0.15 },
107
+ },
108
+ });
109
+ }),
110
+ });
111
+ expect(route).toMatchObject({
112
+ profile: "quality-optimized",
113
+ outcome: "routed",
114
+ confidence: 0.8,
115
+ });
116
+ const request = sent as {
117
+ state: Record<string, string>;
118
+ questions: {
119
+ profile: { type: string; criteria: Record<string, string> };
120
+ };
121
+ };
122
+ expect(request.state.latest_user_message).toBe(base.userMessage);
123
+ expect(request.state.recent_conversation).toBe(
124
+ "user: hi\nassistant: hello",
125
+ );
126
+ expect(request.questions.profile.type).toBe("choice");
127
+ expect(Object.keys(request.questions.profile.criteria)).toEqual([
128
+ "balanced",
129
+ "quality-optimized",
130
+ "cost-optimized",
131
+ "latency-optimized",
132
+ ]);
133
+ expect(
134
+ request.questions.profile.criteria.balanced.startsWith("Balanced: "),
135
+ ).toBe(true);
136
+ });
137
+
138
+ test("falls back to Balanced when the choice names no candidate", async () => {
139
+ const route = await routeAutoProfile({
140
+ ...base,
141
+ resolveProvider: async () =>
142
+ fakeProvider(async () =>
143
+ jevResponse({ profile: { type: "choice", choice: "auto" } }),
144
+ ),
145
+ });
146
+ expect(route).toMatchObject({
147
+ profile: AUTO_PROFILE_FALLBACK,
148
+ outcome: "error",
149
+ });
150
+ });
151
+
152
+ test("falls back to Balanced when no TypeSafe route resolves", async () => {
153
+ const route = await routeAutoProfile({
154
+ ...base,
155
+ resolveProvider: async () => null,
156
+ });
157
+ expect(route).toEqual({
158
+ profile: AUTO_PROFILE_FALLBACK,
159
+ outcome: "unavailable",
160
+ latencyMs: expect.any(Number),
161
+ });
162
+ });
163
+
164
+ test("falls back to Balanced on timeout", async () => {
165
+ const route = await routeAutoProfile({
166
+ ...base,
167
+ timeoutMs: 5,
168
+ resolveProvider: async () =>
169
+ fakeProvider(
170
+ (_messages, options) =>
171
+ new Promise((_resolve, reject) => {
172
+ options?.signal?.addEventListener("abort", () =>
173
+ reject(new Error("aborted")),
174
+ );
175
+ }),
176
+ ),
177
+ });
178
+ expect(route.profile).toBe(AUTO_PROFILE_FALLBACK);
179
+ expect(route.outcome).toBe("timeout");
180
+ });
181
+
182
+ test("does not ask when fewer than two candidates remain", async () => {
183
+ let asked = false;
184
+ const route = await routeAutoProfile({
185
+ ...base,
186
+ profiles: { auto: managed("Auto"), balanced: managed("Balanced") },
187
+ resolveProvider: async () => {
188
+ asked = true;
189
+ return null;
190
+ },
191
+ });
192
+ expect(asked).toBe(false);
193
+ expect(route).toEqual({
194
+ profile: AUTO_PROFILE_FALLBACK,
195
+ outcome: "fallback",
196
+ latencyMs: 0,
197
+ });
198
+ });
199
+
200
+ test("falls back to an enabled candidate when Balanced is disabled", async () => {
201
+ const withoutBalanced = {
202
+ ...profiles,
203
+ balanced: managed("Balanced", "disabled"),
204
+ };
205
+ expect(autoProfileFallback(autoProfileCandidates(withoutBalanced))).toBe(
206
+ "quality-optimized",
207
+ );
208
+ const unavailable = await routeAutoProfile({
209
+ ...base,
210
+ profiles: withoutBalanced,
211
+ resolveProvider: async () => null,
212
+ });
213
+ expect(unavailable.profile).toBe("quality-optimized");
214
+ const single = await routeAutoProfile({
215
+ ...base,
216
+ profiles: {
217
+ auto: managed("Auto"),
218
+ balanced: managed("Balanced", "disabled"),
219
+ "cost-optimized": managed("Budget"),
220
+ },
221
+ });
222
+ expect(single).toMatchObject({
223
+ profile: "cost-optimized",
224
+ outcome: "fallback",
225
+ });
226
+ expect(autoProfileFallback([])).toBe(AUTO_PROFILE_FALLBACK);
227
+ });
228
+ });
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Picks which default profile answers a turn that runs on the Auto profile.
3
+ *
4
+ * The agent loop asks TypeSafe's System One model (Jev) one choice question
5
+ * over the user's latest message, with the recent conversation for context,
6
+ * and pins the turn to the answer. Every other outcome (no managed Jev route,
7
+ * provider error, timeout, an answer naming no candidate) pins the turn to
8
+ * Balanced, which is also the Auto profile's own body, so a turn the router
9
+ * cannot judge runs exactly as a Balanced turn would.
10
+ */
11
+
12
+ import {
13
+ AUTO_PROFILE_ROUTER_CALL_SITE,
14
+ DEFAULT_PROFILE_KEYS,
15
+ type DefaultProfileKey,
16
+ isDefaultProfileKey,
17
+ } from "../config/default-profile-names.js";
18
+ import type { ProfileEntry } from "../config/schemas/llm.js";
19
+ import { askTypesafe } from "../providers/jev/ask.js";
20
+ import type { ContentBlock, Message, Provider } from "../providers/types.js";
21
+ import { getLogger } from "../util/logger.js";
22
+ import { safeStringSlice } from "../util/unicode.js";
23
+
24
+ const log = getLogger("auto-profile-router");
25
+
26
+ export const AUTO_PROFILE_FALLBACK: DefaultProfileKey = "balanced";
27
+
28
+ /**
29
+ * The profile a turn runs on when the router cannot judge it: Balanced while
30
+ * it is a candidate, else the first candidate the user left enabled, else
31
+ * Balanced (the Auto body itself) when nothing is enabled.
32
+ */
33
+ export function autoProfileFallback(
34
+ candidates: readonly DefaultProfileKey[],
35
+ ): DefaultProfileKey {
36
+ if (candidates.includes(AUTO_PROFILE_FALLBACK)) {
37
+ return AUTO_PROFILE_FALLBACK;
38
+ }
39
+ return candidates[0] ?? AUTO_PROFILE_FALLBACK;
40
+ }
41
+
42
+ /**
43
+ * Budget before the turn proceeds on Balanced. The router runs before the
44
+ * turn's first provider call, so a Jev outage costs at most this much
45
+ * time-to-first-token per Auto turn and never blocks the reply. Jev's
46
+ * measured round trip sits well under it (the voice judge runs on 800ms).
47
+ */
48
+ export const AUTO_PROFILE_ROUTER_TIMEOUT_MS = 1_000;
49
+
50
+ const HISTORY_TURNS = 6;
51
+ const HISTORY_TURN_MAX_CHARS = 600;
52
+ const MESSAGE_MAX_CHARS = 4_000;
53
+
54
+ /**
55
+ * What each default profile is for, phrased so the options exclude each
56
+ * other. The picker copy on the profiles describes a tier to a person;
57
+ * these describe the kind of message that belongs on it.
58
+ */
59
+ const ROUTING_CRITERIA: Record<DefaultProfileKey, string> = {
60
+ balanced:
61
+ "Everyday requests: questions, drafting, summaries, ordinary coding, tasks that use tools. The choice when nothing else clearly applies.",
62
+ "quality-optimized":
63
+ "Hard problems: multi-step reasoning, subtle debugging, large or delicate code changes, careful analysis, writing where quality matters most.",
64
+ "cost-optimized":
65
+ "Simple, mechanical work: short factual answers, reformatting, extraction, small edits where a modest model is plenty.",
66
+ "latency-optimized":
67
+ "Quick conversational replies: greetings, acknowledgements, one-line answers where speed matters more than depth.",
68
+ };
69
+
70
+ const ROUTING_INSTRUCTIONS =
71
+ "Which model profile should answer the user's latest message? Judge the work the reply needs, using the recent conversation to interpret short follow-ups. Pick the cheapest profile that will still produce a good answer.";
72
+
73
+ export type AutoProfileRouteOutcome =
74
+ | "routed"
75
+ | "unavailable"
76
+ | "timeout"
77
+ | "error"
78
+ | "fallback";
79
+
80
+ export interface AutoProfileRoute {
81
+ profile: DefaultProfileKey;
82
+ outcome: AutoProfileRouteOutcome;
83
+ /** Jev's confidence in the pick, when it answered. */
84
+ confidence?: number;
85
+ latencyMs: number;
86
+ }
87
+
88
+ /** A message's spoken text: its text blocks minus injected `<...>` blocks. */
89
+ export function plainTextOf(content: readonly ContentBlock[]): string {
90
+ return content
91
+ .map((block) => (block.type === "text" ? block.text : ""))
92
+ .filter(
93
+ (text) => text.trim().length > 0 && !text.trimStart().startsWith("<"),
94
+ )
95
+ .join(" ")
96
+ .replace(/\s+/g, " ")
97
+ .trim();
98
+ }
99
+
100
+ /**
101
+ * The router's view of the conversation: the last few turns as plain text,
102
+ * without injected context blocks, tool traffic, or the current message
103
+ * (which the router receives on its own).
104
+ */
105
+ export function recentConversationForRouter(
106
+ history: readonly Message[],
107
+ userMessage: string,
108
+ ): string {
109
+ const lines: string[] = [];
110
+ for (const message of history) {
111
+ const text = plainTextOf(message.content);
112
+ if (text.length === 0) {
113
+ continue;
114
+ }
115
+ lines.push(
116
+ `${message.role}: ${safeStringSlice(text, 0, HISTORY_TURN_MAX_CHARS)}`,
117
+ );
118
+ }
119
+ const current = `user: ${userMessage.replace(/\s+/g, " ").trim()}`;
120
+ if (lines.length > 0 && lines[lines.length - 1] === current) {
121
+ lines.pop();
122
+ }
123
+ return lines.slice(-HISTORY_TURNS).join("\n");
124
+ }
125
+
126
+ /**
127
+ * The default profiles the router may pick from: the ones the conversation
128
+ * view offers and the user has not disabled.
129
+ */
130
+ export function autoProfileCandidates(
131
+ profiles: Readonly<Record<string, ProfileEntry>>,
132
+ ): DefaultProfileKey[] {
133
+ return DEFAULT_PROFILE_KEYS.filter((key) => {
134
+ const entry = profiles[key];
135
+ return entry != null && entry.status !== "disabled";
136
+ });
137
+ }
138
+
139
+ /**
140
+ * Route one turn. Never rejects: every failure returns the fallback profile.
141
+ */
142
+ export async function routeAutoProfile(args: {
143
+ conversationId: string;
144
+ history: readonly Message[];
145
+ userMessage: string;
146
+ profiles: Readonly<Record<string, ProfileEntry>>;
147
+ signal?: AbortSignal;
148
+ timeoutMs?: number;
149
+ /** Provider resolver, injectable for tests. */
150
+ resolveProvider?: () => Promise<Provider | null>;
151
+ }): Promise<AutoProfileRoute> {
152
+ const userMessage = args.userMessage.trim();
153
+ const candidates = autoProfileCandidates(args.profiles);
154
+ const fallback = autoProfileFallback(candidates);
155
+ // A choice needs two options; with fewer there is nothing to route.
156
+ if (userMessage.length === 0 || candidates.length < 2) {
157
+ return { profile: fallback, outcome: "fallback", latencyMs: 0 };
158
+ }
159
+ const criteria = Object.fromEntries(
160
+ candidates.map((key) => [
161
+ key,
162
+ `${args.profiles[key]?.label ?? key}: ${ROUTING_CRITERIA[key]}`,
163
+ ]),
164
+ );
165
+ const result = await askTypesafe({
166
+ callSite: AUTO_PROFILE_ROUTER_CALL_SITE,
167
+ conversationId: args.conversationId,
168
+ state: {
169
+ recent_conversation:
170
+ recentConversationForRouter(args.history, userMessage) ||
171
+ "(start of conversation)",
172
+ latest_user_message: safeStringSlice(userMessage, 0, MESSAGE_MAX_CHARS),
173
+ },
174
+ questions: {
175
+ profile: {
176
+ type: "choice",
177
+ instructions: ROUTING_INSTRUCTIONS,
178
+ criteria,
179
+ },
180
+ },
181
+ timeoutMs: args.timeoutMs ?? AUTO_PROFILE_ROUTER_TIMEOUT_MS,
182
+ ...(args.signal ? { signal: args.signal } : {}),
183
+ ...(args.resolveProvider ? { resolveProvider: args.resolveProvider } : {}),
184
+ });
185
+ if (result.outcome !== "answered") {
186
+ return {
187
+ profile: fallback,
188
+ outcome: result.outcome,
189
+ latencyMs: result.latencyMs,
190
+ };
191
+ }
192
+ const answer = result.answers?.profile;
193
+ const choice =
194
+ typeof answer === "object" && answer !== null && "choice" in answer
195
+ ? (answer as { choice?: unknown }).choice
196
+ : undefined;
197
+ const confidence =
198
+ typeof answer === "object" && answer !== null && "confidence" in answer
199
+ ? (answer as { confidence?: unknown }).confidence
200
+ : undefined;
201
+ if (
202
+ typeof choice !== "string" ||
203
+ !isDefaultProfileKey(choice) ||
204
+ !candidates.includes(choice)
205
+ ) {
206
+ log.warn(
207
+ { conversationId: args.conversationId, choice },
208
+ "Auto profile router answered with no candidate profile",
209
+ );
210
+ return { profile: fallback, outcome: "error", latencyMs: result.latencyMs };
211
+ }
212
+ // Argmax with no confidence floor. A floor belongs here if flat
213
+ // distributions turn out to send trivial messages to Quality.
214
+ return {
215
+ profile: choice,
216
+ outcome: "routed",
217
+ ...(typeof confidence === "number" ? { confidence } : {}),
218
+ latencyMs: result.latencyMs,
219
+ };
220
+ }
@@ -25,6 +25,8 @@ import type {
25
25
  TurnChannelContext,
26
26
  TurnInterfaceContext,
27
27
  } from "../channels/types.js";
28
+ import { getConversationProfilesForProvider } from "../config/default-profile-catalog.js";
29
+ import { AUTO_PROFILE_KEY } from "../config/default-profile-names.js";
28
30
  import {
29
31
  contextWindowConfigFromEffective,
30
32
  type EffectiveContextWindow,
@@ -99,6 +101,11 @@ import {
99
101
  DEFAULT_TURN_COMMIT_MAX_WAIT_MS,
100
102
  } from "./abort-watchdog.js";
101
103
  import { cleanAssistantContent } from "./assistant-attachments.js";
104
+ import {
105
+ type AutoProfileRoute,
106
+ plainTextOf,
107
+ routeAutoProfile,
108
+ } from "./auto-profile-router.js";
102
109
  import { conversationSupportsDynamicUi } from "./channel-ui-capability.js";
103
110
  import type { Conversation } from "./conversation.js";
104
111
  import {
@@ -647,16 +654,82 @@ export async function runAgentLoopImpl(
647
654
  // live conversation (hydrated on load, kept current by the HTTP setters and
648
655
  // the expiry reaper), so the derivation reads `ctx` rather than re-fetching
649
656
  // the row.
650
- const userExplicitOverride =
651
- options?.overrideProfile ?? resolveOverrideProfile(ctx);
652
-
653
657
  const config = getConfig();
654
658
 
659
+ // With no explicit override, the resolver's own chain (the active profile
660
+ // for the main agent, then the call site's pin) may still select the Auto
661
+ // profile. That winner is named here so the router below covers every path
662
+ // that would otherwise dispatch the Auto body.
663
+ const readRequestedOverrideProfile = (): string | undefined => {
664
+ const explicit = options?.overrideProfile ?? resolveOverrideProfile(ctx);
665
+ if (explicit !== undefined) {
666
+ return explicit;
667
+ }
668
+ const winner = selectWinningProfile(inferenceCallSite, config.llm, {
669
+ selectionSeed: ctx.conversationId,
670
+ }).profileName;
671
+ return winner === AUTO_PROFILE_KEY ? AUTO_PROFILE_KEY : undefined;
672
+ };
673
+
674
+ // The Auto profile is a selection over the default profiles, made once per
675
+ // turn: the routed profile stands in for the Auto name on every read, so
676
+ // the whole turn (and any subagent inheriting the override) runs on one
677
+ // profile. A user profile that shadows the managed name is a profile of
678
+ // its own and is not routed. Nothing here may throw: the turn's processing
679
+ // state is already held and its release lives in the loop's `finally`
680
+ // further down, so a routing failure degrades to the Auto body instead.
681
+ const routeAutoProfileForTurn =
682
+ async (): Promise<AutoProfileRoute | null> => {
683
+ try {
684
+ const profiles = getConversationProfilesForProvider(
685
+ config.llm.profiles,
686
+ config.llm.defaultProvider ?? null,
687
+ );
688
+ if (profiles[AUTO_PROFILE_KEY]?.source !== "managed") {
689
+ return null;
690
+ }
691
+ const route = await routeAutoProfile({
692
+ conversationId: ctx.conversationId,
693
+ history: ctx.messages,
694
+ userMessage: plainTextOf(
695
+ getMessageById(userMessageId)?.content ?? [],
696
+ ),
697
+ profiles,
698
+ signal: abortController.signal,
699
+ });
700
+ rlog.info(
701
+ {
702
+ outcome: route.outcome,
703
+ profile: route.profile,
704
+ confidence: route.confidence ?? null,
705
+ latencyMs: route.latencyMs,
706
+ },
707
+ "Auto profile routed the turn",
708
+ );
709
+ return route;
710
+ } catch (err) {
711
+ rlog.warn(
712
+ { err },
713
+ "Auto profile routing failed; running the Auto body",
714
+ );
715
+ return null;
716
+ }
717
+ };
718
+ const requestedOverrideProfile = readRequestedOverrideProfile();
719
+ const autoRoute =
720
+ requestedOverrideProfile === AUTO_PROFILE_KEY
721
+ ? await routeAutoProfileForTurn()
722
+ : null;
723
+ const withAutoRoute = (profile: string | undefined): string | undefined =>
724
+ profile === AUTO_PROFILE_KEY && autoRoute ? autoRoute.profile : profile;
725
+
726
+ const userExplicitOverride = withAutoRoute(requestedOverrideProfile);
727
+
655
728
  const turnOverrideProfile = userExplicitOverride;
656
729
  const forceOverrideProfile = options?.forceOverrideProfile === true;
657
730
 
658
731
  const readCurrentOverrideProfile = (): string | undefined =>
659
- options?.overrideProfile ?? resolveOverrideProfile(ctx);
732
+ withAutoRoute(readRequestedOverrideProfile());
660
733
 
661
734
  // Best-effort attribution for error classification: names the resolved
662
735
  // connection and profile so credential/connection errors point at the
@@ -1,5 +1,5 @@
1
1
  import type { InterfaceId } from "../channels/types.js";
2
- import { getUserSelectableProfilesForProvider } from "../config/default-profile-catalog.js";
2
+ import { getConversationProfilesForProvider } from "../config/default-profile-catalog.js";
3
3
  import { resolveEffectiveContextWindow } from "../config/llm-context-resolution.js";
4
4
  import { resolveCallSiteConfig } from "../config/llm-resolver.js";
5
5
  import {
@@ -148,7 +148,7 @@ async function resolveModelCommand(
148
148
  parse: ModelCommandParse,
149
149
  ): Promise<SlashResolution> {
150
150
  const config = getConfig();
151
- const profiles = getUserSelectableProfilesForProvider(
151
+ const profiles = getConversationProfilesForProvider(
152
152
  config.llm.profiles,
153
153
  config.llm.defaultProvider ?? null,
154
154
  );
@@ -40,6 +40,7 @@ import {
40
40
  getConversation,
41
41
  getMessageById,
42
42
  parseMessageMetadata,
43
+ resurfaceArchivedConversation,
43
44
  } from "../persistence/conversation-crud.js";
44
45
  import { getResolvedConversationDirPath } from "../persistence/conversation-directories.js";
45
46
  import { syncMessageToDisk } from "../persistence/conversation-disk-view.js";
@@ -150,6 +151,17 @@ export function buildDeferredFinalizeEffect(params: {
150
151
  "Failed to project assistant message for attention tracking (non-fatal)",
151
152
  );
152
153
  }
154
+ // The row this turn reserved was empty at insert, so this is where its
155
+ // content becomes readable and a Done conversation earns its way back to
156
+ // the list. A run that never got this far produced nothing to read.
157
+ try {
158
+ resurfaceArchivedConversation(conversationId, finalizedRow.createdAt);
159
+ } catch (err) {
160
+ rlog.warn(
161
+ { err, conversationId, messageId: assistantMessageId },
162
+ "Failed to resurface Done conversation after finalize (non-fatal)",
163
+ );
164
+ }
153
165
  };
154
166
  }
155
167
 
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Pure text-processing functions for dictation profiles.
3
3
  *
4
- * - expandSnippets: pre-LLM, dictation mode only
5
- * - applyDictionary: post-LLM, dictation + command modes (including fallback paths)
4
+ * - expandSnippets: dictation mode only
5
+ * - applyDictionary: dictation and selected-text edits (including fallback paths)
6
6
  */
7
7
 
8
8
  import type {
@@ -33,7 +33,7 @@ function wrapWordBoundary(
33
33
  }
34
34
 
35
35
  /**
36
- * Expand snippet triggers in text before sending to the LLM.
36
+ * Expand user-configured snippet triggers in dictated text.
37
37
  *
38
38
  * - Only enabled snippets are considered
39
39
  * - Sorted by trigger length descending (longest match wins)
@@ -80,7 +80,7 @@ export function expandSnippets(
80
80
  }
81
81
 
82
82
  /**
83
- * Apply dictionary normalization to text after LLM processing (or on raw fallback).
83
+ * Apply user-configured dictionary replacements to dictated or edited text.
84
84
  *
85
85
  * - Sorted by spoken length descending
86
86
  * - Respects wholeWord (default true) and caseSensitive (default false) per entry
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Tests for the `includeArchived` option on the agent-facing conversation
3
+ * history search.
4
+ *
5
+ * The `"*"` wildcard takes its own branch (FTS reads `*` as a literal), so it
6
+ * has to honor the option through the list read rather than through
7
+ * `searchConversations`, which has its own coverage in
8
+ * `persistence/__tests__/conversation-queries-search.test.ts`.
9
+ */
10
+
11
+ import { beforeEach, describe, expect, test } from "bun:test";
12
+
13
+ import { createConversation } from "../../../persistence/conversation-crud.js";
14
+ import { getDb } from "../../../persistence/db-connection.js";
15
+ import { initializeDb } from "../../../persistence/db-init.js";
16
+ import { rawRun } from "../../../persistence/raw-query.js";
17
+ import { conversations } from "../../../persistence/schema/index.js";
18
+ import { performConversationSearch } from "../conversation-history.js";
19
+
20
+ await initializeDb();
21
+
22
+ function seedArchived(title: string): string {
23
+ const conv = createConversation({ title });
24
+ rawRun(
25
+ "test:archiveConversation",
26
+ "UPDATE conversations SET archived_at = ? WHERE id = ?",
27
+ Date.now(),
28
+ conv.id,
29
+ );
30
+ return conv.id;
31
+ }
32
+
33
+ describe("performConversationSearch · wildcard listing", () => {
34
+ beforeEach(() => {
35
+ getDb().delete(conversations).run();
36
+ });
37
+
38
+ test("omits archived conversations by default", async () => {
39
+ createConversation("live-1");
40
+ seedArchived("archived-1");
41
+
42
+ const results = await performConversationSearch({ query: "*" });
43
+
44
+ expect(results.map((r) => r.conversationTitle)).toEqual(["live-1"]);
45
+ });
46
+
47
+ test("includeArchived reaches archived conversations", async () => {
48
+ createConversation("live-1");
49
+ seedArchived("archived-1");
50
+
51
+ const results = await performConversationSearch({
52
+ query: "*",
53
+ includeArchived: true,
54
+ });
55
+
56
+ expect(results.map((r) => r.conversationTitle).sort()).toEqual([
57
+ "archived-1",
58
+ "live-1",
59
+ ]);
60
+ });
61
+ });