@vellumai/assistant 0.12.4-staging.1 → 0.12.4-staging.3

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 (51) hide show
  1. package/openapi.yaml +3 -1
  2. package/package.json +1 -1
  3. package/src/__tests__/config-loader-backfill.test.ts +1 -0
  4. package/src/__tests__/default-profile-catalog-fallback.test.ts +1 -0
  5. package/src/__tests__/inference-profile-validation-jev.test.ts +17 -0
  6. package/src/__tests__/llm-catalog-parity.test.ts +4 -0
  7. package/src/__tests__/llm-resolver-override-or-default.test.ts +43 -0
  8. package/src/__tests__/openai-responses-prompt-cache.test.ts +3 -1
  9. package/src/__tests__/plugin-api-model-profiles.test.ts +7 -0
  10. package/src/__tests__/pricing.test.ts +33 -0
  11. package/src/__tests__/skill-load-tool.test.ts +42 -11
  12. package/src/__tests__/skill-tool-factory.test.ts +50 -2
  13. package/src/__tests__/validate-input.test.ts +73 -0
  14. package/src/__tests__/workspace-migration-159-rename-colliding-jev-profile-name.test.ts +197 -0
  15. package/src/api/requests/dictation.ts +2 -2
  16. package/src/calls/__tests__/typesafe-noul.test.ts +66 -0
  17. package/src/calls/typesafe-noul.ts +8 -2
  18. package/src/calls/voice-escalation-judge.ts +5 -4
  19. package/src/config/__tests__/default-profile-catalog.test.ts +33 -1
  20. package/src/config/__tests__/jev-managed-profile.test.ts +82 -0
  21. package/src/config/call-site-defaults.ts +11 -11
  22. package/src/config/default-profile-catalog.ts +88 -18
  23. package/src/config/default-profile-names.ts +35 -0
  24. package/src/config/inference-profile-validation.ts +7 -6
  25. package/src/config/llm-resolver.ts +16 -0
  26. package/src/config/profile-text-generation.test.ts +9 -0
  27. package/src/config/schemas/call-site-catalog.ts +1 -1
  28. package/src/config/schemas/llm.ts +54 -9
  29. package/src/daemon/conversation-slash.ts +2 -2
  30. package/src/daemon/dictation-text-processing.ts +4 -4
  31. package/src/plugin-api/model-profiles.ts +5 -3
  32. package/src/plugins/defaults/memory/substrate/skill-content.ts +1 -1
  33. package/src/plugins/defaults/memory/v3/__tests__/render-injection.test.ts +3 -0
  34. package/src/providers/__tests__/connection-model-compat.test.ts +2 -0
  35. package/src/providers/anthropic/client.ts +2 -2
  36. package/src/providers/model-catalog.ts +257 -4
  37. package/src/providers/openai/codex-models.ts +2 -0
  38. package/src/runtime/routes/__tests__/dictation-processing.test.ts +174 -0
  39. package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +16 -1
  40. package/src/runtime/routes/conversation-routes.ts +4 -2
  41. package/src/runtime/routes/diagnostics-routes.ts +66 -229
  42. package/src/runtime/routes/llm-call-sites-routes.ts +19 -10
  43. package/src/skills/validate-input.ts +75 -11
  44. package/src/tools/skills/execute.ts +1 -1
  45. package/src/tools/skills/format-input-schema.ts +15 -0
  46. package/src/tools/skills/load.ts +2 -26
  47. package/src/tools/skills/skill-tool-factory.ts +2 -1
  48. package/src/tools/workflows/manage-workflows.ts +4 -2
  49. package/src/workspace/migrations/159-rename-colliding-jev-profile-name.ts +344 -0
  50. package/src/workspace/migrations/registry.ts +2 -0
  51. package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +0 -107
@@ -3,10 +3,11 @@
3
3
  *
4
4
  * Deliberate strict subset of JSON Schema: it covers only the keywords
5
5
  * actually used by `assistant/src/config/bundled-skills/**\/TOOLS.json`
6
- * (required / type / enum / items.type) plus unknown-key detection. Anything
7
- * else (`$ref`, `oneOf`, `anyOf`, `allOf`, `format`, `pattern`, `minimum`,
8
- * `maximum`, object-shape `additionalProperties`, etc.) is silently skipped so
9
- * a richer schema can never cause us to reject a legitimate call.
6
+ * (required / type / enum / nested properties and items / required-only oneOf)
7
+ * plus unknown-key detection. Anything else (`$ref`, richer `oneOf`, `anyOf`,
8
+ * `allOf`, `format`, `pattern`, `minimum`, `maximum`, object-shape
9
+ * `additionalProperties`, etc.) is skipped. Nested objects allow extra keys
10
+ * unless their schema explicitly closes them.
10
11
  *
11
12
  * Each error message is written to be agent-readable and self-correcting
12
13
  * (e.g. `surface_id is required`, `mode must be one of "replace", "append"`).
@@ -313,6 +314,15 @@ export function validateInputAgainstSchema(
313
314
  _toolName: string,
314
315
  input: Record<string, unknown>,
315
316
  schema: Record<string, unknown> | undefined,
317
+ ): InputValidationResult {
318
+ return validateObjectInput(input, schema, "", true);
319
+ }
320
+
321
+ function validateObjectInput(
322
+ input: Record<string, unknown>,
323
+ schema: Record<string, unknown> | undefined,
324
+ path: string,
325
+ rejectUnknown: boolean,
316
326
  ): InputValidationResult {
317
327
  // Skip when there's no schema or no properties block — matches today's
318
328
  // lenient behaviour for tools that declare only `{ type: "object" }`.
@@ -338,17 +348,46 @@ export function validateInputAgainstSchema(
338
348
  continue;
339
349
  }
340
350
  if (!(key in input)) {
341
- errors.push(`${key} is required`);
351
+ errors.push(`${path}${key} is required`);
342
352
  }
343
353
  }
344
354
  }
345
355
 
356
+ // Only enforce alternatives whose complete contract is field presence.
357
+ // Richer unions stay permissive because unsupported keywords can decide
358
+ // which branch matches.
359
+ const alternatives = schema.oneOf;
360
+ if (
361
+ Array.isArray(alternatives) &&
362
+ alternatives.length > 0 &&
363
+ alternatives.every(
364
+ (branch) =>
365
+ isPlainObject(branch) &&
366
+ Object.keys(branch).every(
367
+ (key) => key === "required" || key === "description",
368
+ ) &&
369
+ Array.isArray(branch.required) &&
370
+ branch.required.every((key: unknown) => typeof key === "string"),
371
+ )
372
+ ) {
373
+ const choices = alternatives as { required: string[] }[];
374
+ if (
375
+ choices.filter((branch) => branch.required.every((key) => key in input))
376
+ .length !== 1
377
+ ) {
378
+ errors.push(
379
+ `${path ? path.slice(0, -1) : "input"} must match exactly one required field set: ${choices.map((branch) => quoteList(branch.required)).join(" or ")}`,
380
+ );
381
+ }
382
+ }
383
+
346
384
  // 2. Per-property checks: type, enum, items.type.
347
- for (const [key, rawSubSchema] of Object.entries(properties)) {
348
- if (!(key in input)) {
385
+ for (const [property, rawSubSchema] of Object.entries(properties)) {
386
+ if (!(property in input)) {
349
387
  continue;
350
388
  }
351
- const value = input[key];
389
+ const key = `${path}${property}`;
390
+ const value = input[property];
352
391
  // Skip type-checking for absent values; presence is enforced by the
353
392
  // `required` check above. Note: `null` IS a present value and is
354
393
  // type-checked below — only schemas that explicitly opt in to null via
@@ -381,13 +420,25 @@ export function validateInputAgainstSchema(
381
420
  if (!matchesType(value, type)) {
382
421
  errors.push(
383
422
  type === "boolean" && typeof value === "string"
384
- ? `${key} must be a boolean — pass true or false as a JSON boolean, not a string`
423
+ ? `${key} must be a boolean: pass true or false as a JSON boolean, not a string`
385
424
  : `${key} must be ${typeArticle(type)} ${type}`,
386
425
  );
387
426
  // No point checking enum/items if the base type is wrong.
388
427
  continue;
389
428
  }
390
429
 
430
+ if (type === "object" && isPlainObject(value)) {
431
+ const nested = validateObjectInput(
432
+ value,
433
+ rawSubSchema,
434
+ `${key}.`,
435
+ rawSubSchema.additionalProperties === false,
436
+ );
437
+ if (!nested.ok) {
438
+ errors.push(...nested.errors);
439
+ }
440
+ }
441
+
391
442
  // 2a. Enum (string enums are the only shape used today).
392
443
  const enumValues = rawSubSchema.enum;
393
444
  if (Array.isArray(enumValues) && enumValues.length > 0) {
@@ -412,6 +463,17 @@ export function validateInputAgainstSchema(
412
463
  errors.push(
413
464
  `${key}[${index}] must be ${typeArticle(itemTypeName)} ${itemTypeName}`,
414
465
  );
466
+ } else if (itemTypeName === "object" && isPlainObject(element)) {
467
+ const itemSchema = rawSubSchema.items as Record<string, unknown>;
468
+ const nested = validateObjectInput(
469
+ element,
470
+ itemSchema,
471
+ `${key}[${index}].`,
472
+ itemSchema.additionalProperties === false,
473
+ );
474
+ if (!nested.ok) {
475
+ errors.push(...nested.errors);
476
+ }
415
477
  }
416
478
  });
417
479
  }
@@ -420,10 +482,12 @@ export function validateInputAgainstSchema(
420
482
  }
421
483
 
422
484
  // 3. Unknown keys — parity with the previous `validateNoUnknownParams`.
423
- const unknownKeys = Object.keys(input).filter((k) => !knownKeySet.has(k));
485
+ const unknownKeys = rejectUnknown
486
+ ? Object.keys(input).filter((k) => !knownKeySet.has(k))
487
+ : [];
424
488
  for (const key of unknownKeys) {
425
489
  errors.push(
426
- `Unknown parameter "${key}". Supported: ${quoteList(knownKeys)}`,
490
+ `Unknown parameter "${path}${key}". Supported: ${quoteList(knownKeys)}`,
427
491
  );
428
492
  }
429
493
 
@@ -178,7 +178,7 @@ export function augmentSkillExecuteError(
178
178
  export const skillExecuteTool = {
179
179
  name: "skill_execute",
180
180
  description:
181
- "Execute a tool provided by a loaded skill. Use this instead of calling skill tools directly. The skill's instructions (from skill_load) describe available tools and their parameters. For browser automation, use the `assistant browser` CLI commands instead.",
181
+ "Execute a tool provided by a loaded skill. When skill_load is available, call it for the owning skill before first use, even if a capability card says it is available. Use the current instructions and input schema over remembered arguments. Use this instead of calling skill tools directly. For browser automation, use the `assistant browser` CLI commands instead.",
182
182
  category: "skills",
183
183
  executionTarget: "sandbox",
184
184
  defaultRiskLevel: RiskLevel.Low,
@@ -0,0 +1,15 @@
1
+ import { getWorkspaceDirDisplay } from "../../util/platform.js";
2
+
3
+ /** Preserve nested fields and alternatives when disclosing a skill's input. */
4
+ export function formatSkillInputSchema(schema: object): string {
5
+ const workspaceDir = getWorkspaceDirDisplay();
6
+ const json = JSON.stringify(
7
+ schema,
8
+ (_key, value: unknown) =>
9
+ typeof value === "string"
10
+ ? value.replaceAll("{workspaceDir}", workspaceDir)
11
+ : value,
12
+ 2,
13
+ );
14
+ return `Input schema for \`skill_execute.input\`:\n\`\`\`json\n${json}\n\`\`\``;
15
+ }
@@ -35,6 +35,7 @@ import type {
35
35
  ToolDefinition,
36
36
  ToolExecutionResult,
37
37
  } from "../types.js";
38
+ import { formatSkillInputSchema } from "./format-input-schema.js";
38
39
 
39
40
  /** Skill sources eligible for inline command expansion in v1. */
40
41
  const INLINE_COMMAND_ELIGIBLE_SOURCES = new Set([
@@ -123,32 +124,7 @@ function formatToolSchemas(
123
124
  tool.description.replaceAll("{workspaceDir}", getWorkspaceDirDisplay()),
124
125
  );
125
126
 
126
- const schema = tool.input_schema;
127
- const properties = schema.properties as
128
- | Record<string, Record<string, unknown>>
129
- | undefined;
130
- if (properties && Object.keys(properties).length > 0) {
131
- const requiredSet = new Set<string>(
132
- Array.isArray(schema.required) ? (schema.required as string[]) : [],
133
- );
134
-
135
- lines.push("Parameters:");
136
- for (const [paramName, paramDef] of Object.entries(properties)) {
137
- const paramType =
138
- typeof paramDef.type === "string" ? paramDef.type : "any";
139
- const requiredLabel = requiredSet.has(paramName)
140
- ? "required"
141
- : "optional";
142
- const descPart =
143
- typeof paramDef.description === "string"
144
- ? `: ${paramDef.description.replaceAll("{workspaceDir}", getWorkspaceDirDisplay())}`
145
- : "";
146
- lines.push(
147
- `- ${paramName} (${paramType}, ${requiredLabel})${descPart}`,
148
- );
149
- }
150
- }
151
-
127
+ lines.push(formatSkillInputSchema(tool.input_schema));
152
128
  lines.push("");
153
129
  }
154
130
 
@@ -15,6 +15,7 @@ import { bundledToolInputMisuseMessage } from "../shared/input-misuse.js";
15
15
  import { bundledToolInputRepairs } from "../shared/input-repairs.js";
16
16
  import type { ExecutionTarget } from "../tool-types.js";
17
17
  import type { Tool, ToolContext, ToolExecutionResult } from "../types.js";
18
+ import { formatSkillInputSchema } from "./format-input-schema.js";
18
19
  import { runSkillToolScript } from "./skill-script-runner.js";
19
20
 
20
21
  const riskMap: Record<SkillToolEntry["risk"], RiskLevel> = {
@@ -94,7 +95,7 @@ export function createSkillTool(
94
95
  return {
95
96
  content:
96
97
  misuse ??
97
- `Invalid input for tool "${entry.name}": ${validation.errors.join("; ")}. Fix the arguments and retry.`,
98
+ `Invalid input for tool "${entry.name}": ${validation.errors.join("; ")}. If skill_load is available, load the owning skill for its current instructions. Retry with arguments matching this schema.\n\n${formatSkillInputSchema(entry.input_schema)}`,
98
99
  isError: true,
99
100
  };
100
101
  }
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { z } from "zod";
17
17
 
18
- import { getUserSelectableProfilesForProvider } from "../../config/default-profile-catalog.js";
18
+ import { getConversationProfilesForProvider } from "../../config/default-profile-catalog.js";
19
19
  import { loadConfig } from "../../config/loader.js";
20
20
  import { callerOwnsWorkflowRun } from "../../workflows/capabilities.js";
21
21
  import type { WorkflowRun } from "../../workflows/journal-store.js";
@@ -199,7 +199,9 @@ export async function executeManageWorkflows(
199
199
  // workspace-wide active profile. Read-only — leaves use this to pick a
200
200
  // valid `profile` for `run_workflow` (an unknown profile throws).
201
201
  const { llm } = loadConfig();
202
- const profiles = getUserSelectableProfilesForProvider(
202
+ // A workflow leaf runs chat turns, so this is the conversation view,
203
+ // which leaves out the managed Jev profile.
204
+ const profiles = getConversationProfilesForProvider(
203
205
  llm?.profiles,
204
206
  llm?.defaultProvider ?? null,
205
207
  );
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Workspace migration `159-rename-colliding-jev-profile-name`.
3
+ *
4
+ * `jev-managed` became a reserved code-owned profile name (the managed Jev
5
+ * profile). From this release on, that name resolves to the code body no
6
+ * matter what `llm.profiles` holds, and the schema rejects it as
7
+ * `activeProfile` or `advisorProfile`, so a user-created profile that
8
+ * happens to carry the name would be silently replaced in the effective
9
+ * catalog and its selections stranded.
10
+ *
11
+ * This migration runs before the reservation can take effect (workspace
12
+ * migrations run ahead of the first config load and the profile seeder) and
13
+ * gives such a profile a fresh key, rewriting every reference to it so the
14
+ * user keeps both the profile and its wiring:
15
+ *
16
+ * - `llm.profiles` key (insertion order preserved)
17
+ * - `llm.activeProfile`, `llm.advisorProfile`
18
+ * - `llm.profileOrder` entries
19
+ * - `llm.callSites.<site>.profile` pins
20
+ * - `llm.profiles.<key>.mix[].profile` arms
21
+ * - `llm.profiles.<key>.fallbackProfile` targets
22
+ * - `conversations.inference_profile` and `cron_jobs.inference_profile`
23
+ * rows
24
+ *
25
+ * The new key is `jev-managed-custom`, suffixed `-custom-2`, `-custom-3`,
26
+ * ... when that is taken. Candidates are checked against every existing
27
+ * profile key AND every reserved code-defined name, so a rename never lands
28
+ * on another name the catalog owns.
29
+ *
30
+ * Only a workspace-owned profile is renamed. An entry whose `source` is
31
+ * `managed` is a thin stub for the code-owned profile, and a non-object
32
+ * value under the key is not a profile at all; both are left alone.
33
+ *
34
+ * The DB rewrite is not best effort: a pin the rewrite abandons would route
35
+ * through the managed Jev profile instead of the user's own, so a database
36
+ * that exists but cannot be written is retried with a short backoff and a
37
+ * failure that outlives the retries throws; `retryFailedCheckpoint` re-runs
38
+ * the whole migration on the next boot. The DB rewrite runs before the
39
+ * config write, so a throw leaves config.json holding the old key.
40
+ *
41
+ * Idempotent: after a successful run no workspace-owned profile carries the
42
+ * reserved name, so a re-run finds nothing to rename and writes nothing.
43
+ */
44
+
45
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
46
+ import { join } from "node:path";
47
+ import { Database } from "bun:sqlite";
48
+
49
+ import { getLogger } from "../../util/logger.js";
50
+ import type { WorkspaceMigration } from "./types.js";
51
+
52
+ const log = getLogger(
53
+ "workspace-migration-159-rename-colliding-jev-profile-name",
54
+ );
55
+
56
+ /**
57
+ * The newly reserved key this migration clears. Frozen snapshot as of
58
+ * 2026-09-22 (migrations are self-contained).
59
+ */
60
+ const RESERVED_KEYS = ["jev-managed"];
61
+
62
+ /**
63
+ * Every code-defined profile name as of 2026-09-22: the default keys, the
64
+ * backups, the flag-gated `os-beta`, and the Jev profile. A renamed profile
65
+ * must not land on any of them, or the rename would just move the collision.
66
+ */
67
+ const RESERVED_PROFILE_NAMES = new Set<string>([
68
+ "balanced",
69
+ "quality-optimized",
70
+ "cost-optimized",
71
+ "latency-optimized",
72
+ "balanced-backup",
73
+ "quality-optimized-backup",
74
+ "cost-optimized-backup",
75
+ "latency-optimized-backup",
76
+ "os-beta",
77
+ ...RESERVED_KEYS,
78
+ ]);
79
+
80
+ const RENAME_SUFFIX = "-custom";
81
+
82
+ /** Total attempts at the pin rewrite, the first one included. */
83
+ const DB_ATTEMPTS = 4;
84
+
85
+ /** First backoff step; each further wait doubles it (50, 100, 200 ms). */
86
+ const DB_RETRY_BASE_DELAY_MS = 50;
87
+
88
+ export const renameCollidingJevProfileNameMigration: WorkspaceMigration = {
89
+ id: "159-rename-colliding-jev-profile-name",
90
+ description:
91
+ "Rename a user profile colliding with the reserved managed Jev profile key and rewrite its references",
92
+ // The failure this migration can hit is a database it cannot write, and
93
+ // both halves of the run are idempotent, so a failed checkpoint is worth
94
+ // re-running: the config still holds the old keys, the mapping rebuilds
95
+ // from them, and the pin rewrite matches on the old name.
96
+ retryFailedCheckpoint: true,
97
+
98
+ async run(workspaceDir: string): Promise<void> {
99
+ const configPath = join(workspaceDir, "config.json");
100
+ if (!existsSync(configPath)) {
101
+ return;
102
+ }
103
+
104
+ let config: Record<string, unknown>;
105
+ try {
106
+ const parsed = JSON.parse(readFileSync(configPath, "utf-8"));
107
+ if (!isPlainObject(parsed)) {
108
+ return;
109
+ }
110
+ config = parsed;
111
+ } catch {
112
+ return;
113
+ }
114
+
115
+ const llm = asObject(config.llm);
116
+ const profiles = llm === null ? null : asObject(llm.profiles);
117
+ if (llm === null || profiles === null) {
118
+ return;
119
+ }
120
+
121
+ const renames = planRenames(profiles);
122
+ if (renames.size === 0) {
123
+ return;
124
+ }
125
+
126
+ // The DB rewrite runs first so an interrupted or failed migration re-runs
127
+ // with the config still holding the old keys, i.e. with the mapping
128
+ // intact. A rewrite that cannot be completed throws out of here, which
129
+ // stops the config rename below rather than stranding the pins.
130
+ await rewriteDbReferences(workspaceDir, renames);
131
+
132
+ llm.profiles = renameProfileKeys(profiles, renames);
133
+ rewriteConfigReferences(llm, renames);
134
+
135
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
136
+ log.info(
137
+ { renames: Object.fromEntries(renames) },
138
+ "Renamed a user profile colliding with the reserved managed Jev profile key",
139
+ );
140
+ },
141
+
142
+ down(_workspaceDir: string): void {
143
+ // Forward-only: the reserved names cannot be handed back.
144
+ },
145
+ };
146
+
147
+ /**
148
+ * Old key -> new key for a workspace-owned profile sitting on the reserved
149
+ * name. Deterministic: each assigned name is reserved against later
150
+ * candidates, so the same config always produces the same mapping.
151
+ */
152
+ function planRenames(profiles: Record<string, unknown>): Map<string, string> {
153
+ const renames = new Map<string, string>();
154
+ const taken = new Set<string>([
155
+ ...Object.keys(profiles),
156
+ ...RESERVED_PROFILE_NAMES,
157
+ ]);
158
+
159
+ for (const key of RESERVED_KEYS) {
160
+ if (!(key in profiles)) {
161
+ continue;
162
+ }
163
+ const entry = asObject(profiles[key]);
164
+ if (entry === null) {
165
+ log.warn(
166
+ { profile: key },
167
+ "Left a non-object value under the reserved Jev profile key; it is not a profile to preserve",
168
+ );
169
+ continue;
170
+ }
171
+ if (entry.source === "managed") {
172
+ // A managed stub is the workspace's overlay slot for the code-owned
173
+ // profile, not a user profile; renaming it would manufacture a phantom
174
+ // user profile.
175
+ continue;
176
+ }
177
+ let candidate = `${key}${RENAME_SUFFIX}`;
178
+ let counter = 2;
179
+ while (taken.has(candidate)) {
180
+ candidate = `${key}${RENAME_SUFFIX}-${counter}`;
181
+ counter += 1;
182
+ }
183
+ taken.add(candidate);
184
+ renames.set(key, candidate);
185
+ }
186
+
187
+ return renames;
188
+ }
189
+
190
+ /** Rebuild the profiles record with the renamed keys, preserving order. */
191
+ function renameProfileKeys(
192
+ profiles: Record<string, unknown>,
193
+ renames: Map<string, string>,
194
+ ): Record<string, unknown> {
195
+ const renamed: Record<string, unknown> = {};
196
+ for (const [name, value] of Object.entries(profiles)) {
197
+ renamed[renames.get(name) ?? name] = value;
198
+ }
199
+ return renamed;
200
+ }
201
+
202
+ /** Point every config reference at the renamed key. */
203
+ function rewriteConfigReferences(
204
+ llm: Record<string, unknown>,
205
+ renames: Map<string, string>,
206
+ ): void {
207
+ for (const field of ["activeProfile", "advisorProfile"]) {
208
+ const current = llm[field];
209
+ if (typeof current === "string" && renames.has(current)) {
210
+ llm[field] = renames.get(current);
211
+ }
212
+ }
213
+
214
+ if (Array.isArray(llm.profileOrder)) {
215
+ llm.profileOrder = (llm.profileOrder as unknown[]).map((name) =>
216
+ typeof name === "string" ? (renames.get(name) ?? name) : name,
217
+ );
218
+ }
219
+
220
+ const callSites = asObject(llm.callSites);
221
+ if (callSites !== null) {
222
+ for (const value of Object.values(callSites)) {
223
+ const site = asObject(value);
224
+ if (
225
+ site !== null &&
226
+ typeof site.profile === "string" &&
227
+ renames.has(site.profile)
228
+ ) {
229
+ site.profile = renames.get(site.profile);
230
+ }
231
+ }
232
+ }
233
+
234
+ const profiles = asObject(llm.profiles);
235
+ if (profiles === null) {
236
+ return;
237
+ }
238
+ for (const value of Object.values(profiles)) {
239
+ const entry = asObject(value);
240
+ if (entry === null) {
241
+ continue;
242
+ }
243
+ if (
244
+ typeof entry.fallbackProfile === "string" &&
245
+ renames.has(entry.fallbackProfile)
246
+ ) {
247
+ entry.fallbackProfile = renames.get(entry.fallbackProfile);
248
+ }
249
+ if (!Array.isArray(entry.mix)) {
250
+ continue;
251
+ }
252
+ for (const armValue of entry.mix as unknown[]) {
253
+ const arm = asObject(armValue);
254
+ if (
255
+ arm !== null &&
256
+ typeof arm.profile === "string" &&
257
+ renames.has(arm.profile)
258
+ ) {
259
+ arm.profile = renames.get(arm.profile);
260
+ }
261
+ }
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Repoint the profile pins stored outside config.json: the sticky
267
+ * per-conversation profile and the per-schedule profile.
268
+ *
269
+ * Retried as a whole rather than per statement: opening the database, reading
270
+ * `sqlite_master` and running the updates all fail the same way under write
271
+ * contention (`SQLITE_BUSY` from a second connection), and every step is
272
+ * idempotent, so re-running the block is the simplest recovery that cannot
273
+ * double-apply. A failure that outlives the attempts throws, which fails the
274
+ * migration for `retryFailedCheckpoint` to pick up on the next boot.
275
+ */
276
+ async function rewriteDbReferences(
277
+ workspaceDir: string,
278
+ renames: Map<string, string>,
279
+ ): Promise<void> {
280
+ const dbPath = join(workspaceDir, "data", "db", "assistant.db");
281
+ if (!existsSync(dbPath)) {
282
+ // No database yet, so there are no pins that could be stranded.
283
+ return;
284
+ }
285
+ await withDbRetry(() => {
286
+ const db = new Database(dbPath);
287
+ try {
288
+ for (const table of ["conversations", "cron_jobs"]) {
289
+ const present = db
290
+ .query(
291
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`,
292
+ )
293
+ .get(table);
294
+ if (!present) {
295
+ // A workspace whose schema does not carry this table holds no pins
296
+ // in it. Nothing to rewrite, and nothing to fail over.
297
+ log.info({ table }, "No profile pin table to repoint");
298
+ continue;
299
+ }
300
+ for (const [from, to] of renames) {
301
+ db.query(
302
+ `UPDATE ${table} SET inference_profile = ? WHERE inference_profile = ?`,
303
+ ).run(to, from);
304
+ }
305
+ }
306
+ } finally {
307
+ db.close();
308
+ }
309
+ });
310
+ }
311
+
312
+ /**
313
+ * Run the pin rewrite, retrying a transient database failure with a short
314
+ * doubling backoff. Rethrows once the attempts are spent so the caller stops
315
+ * before the config rename.
316
+ */
317
+ async function withDbRetry(attempt: () => void): Promise<void> {
318
+ for (let index = 0; ; index += 1) {
319
+ try {
320
+ attempt();
321
+ return;
322
+ } catch (err) {
323
+ if (index >= DB_ATTEMPTS - 1) {
324
+ throw new Error(
325
+ `Could not repoint conversation and schedule profile pins after ${DB_ATTEMPTS} attempts; leaving the config unrenamed so the next boot retries`,
326
+ { cause: err },
327
+ );
328
+ }
329
+ log.warn(
330
+ { err, attempt: index },
331
+ "Could not repoint profile pins; retrying",
332
+ );
333
+ await Bun.sleep(DB_RETRY_BASE_DELAY_MS * 2 ** index);
334
+ }
335
+ }
336
+ }
337
+
338
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
339
+ return value !== null && typeof value === "object" && !Array.isArray(value);
340
+ }
341
+
342
+ function asObject(value: unknown): Record<string, unknown> | null {
343
+ return isPlainObject(value) ? value : null;
344
+ }
@@ -155,6 +155,7 @@ import { repairRetiredCodexGpt54ModelIdsMigration } from "./154-repair-retired-c
155
155
  import { moveFrontModelConfigToVoiceMigration } from "./155-move-front-model-config-to-voice.js";
156
156
  import { extractWorkspaceMcpJsonMigration } from "./156-extract-workspace-mcp-json.js";
157
157
  import { addDesktopFilesLauncherMigration } from "./157-add-desktop-files-launcher.js";
158
+ import { renameCollidingJevProfileNameMigration } from "./159-rename-colliding-jev-profile-name.js";
158
159
  import { migrateToWorkspaceVolumeMigration } from "./migrate-to-workspace-volume.js";
159
160
  import type { WorkspaceMigration } from "./types.js";
160
161
 
@@ -325,4 +326,5 @@ export const WORKSPACE_MIGRATIONS: WorkspaceMigration[] = [
325
326
  moveFrontModelConfigToVoiceMigration,
326
327
  extractWorkspaceMcpJsonMigration,
327
328
  addDesktopFilesLauncherMigration,
329
+ renameCollidingJevProfileNameMigration,
328
330
  ];