@vellumai/assistant 0.11.4-staging.1 → 0.11.4-staging.2
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.
- package/openapi.yaml +1 -1
- package/package.json +1 -1
- package/src/__tests__/byok-default-profile-ensure.test.ts +15 -0
- package/src/__tests__/conversation-queue.test.ts +122 -0
- package/src/__tests__/ui-shape-teaching.test.ts +33 -0
- package/src/__tests__/ui-voice-picker-surface.test.ts +128 -0
- package/src/__tests__/voice-config-update.test.ts +40 -0
- package/src/__tests__/workspace-migration-146-repair-retired-fireworks-deepseek-flash-model-id.test.ts +235 -0
- package/src/api/surfaces.ts +7 -3
- package/src/calls/call-controller.ts +16 -7
- package/src/config/bundled-skills/settings/tools/navigate-settings-tab.test.ts +65 -0
- package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +7 -1
- package/src/config/bundled-skills/settings/tools/shared.ts +16 -0
- package/src/config/bundled-skills/settings/tools/voice-config-update.ts +19 -1
- package/src/config/default-profile-catalog.ts +1 -1
- package/src/daemon/conversation-process.ts +27 -0
- package/src/daemon/conversation-surfaces.ts +27 -5
- package/src/daemon/interactive-turn-sender.ts +59 -0
- package/src/daemon/process-message.ts +11 -21
- package/src/plugin-api/vision-support.test.ts +1 -1
- package/src/providers/model-catalog.ts +3 -3
- package/src/providers/model-intents.ts +2 -2
- package/src/providers/openai/chat-completions-provider.ts +5 -6
- package/src/tools/ui-surface/surface-shape-docs.ts +11 -0
- package/src/tts/__tests__/reasoning-tag-filter.test.ts +15 -0
- package/src/tts/reasoning-tag-filter.ts +11 -32
- package/src/util/think-tag-stream.ts +95 -0
- package/src/workspace/byok-default-profile-ensure.ts +15 -6
- package/src/workspace/migrations/146-repair-retired-fireworks-deepseek-flash-model-id.ts +195 -0
- package/src/workspace/migrations/registry.ts +2 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared primitives for streaming think-tag scanning.
|
|
3
|
+
*
|
|
4
|
+
* Two consumers parse inline `<think>` spans out of streamed model content
|
|
5
|
+
* and must not drift apart (single source of truth):
|
|
6
|
+
*
|
|
7
|
+
* - `providers/openai/chat-completions-provider.ts` ROUTES the spans to
|
|
8
|
+
* `thinking_delta` events (content preserved, case-sensitive `<think>`
|
|
9
|
+
* only, matching the models that emit it).
|
|
10
|
+
* - `tts/reasoning-tag-filter.ts` DROPS the spans from the spoken stream
|
|
11
|
+
* (case-insensitive, `<think>` and `<thinking>` variants).
|
|
12
|
+
*
|
|
13
|
+
* The state machines stay separate because they do different things with
|
|
14
|
+
* the reasoning; what they share is the tag scanning below. All scanning is
|
|
15
|
+
* positionally exact on the ORIGINAL string: case-insensitive mode folds
|
|
16
|
+
* A-Z per character code and never calls `toLowerCase()` on the haystack,
|
|
17
|
+
* because Unicode lowercasing can change string length (e.g. dotted capital
|
|
18
|
+
* I, U+0130, lowers to two code units) and shift every subsequent index.
|
|
19
|
+
* Tags must be given in lowercase ASCII.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
function foldedCharCode(code: number, caseInsensitive: boolean): number {
|
|
23
|
+
return caseInsensitive && code >= 65 && code <= 90 ? code + 32 : code;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function tagMatchesAt(
|
|
27
|
+
haystack: string,
|
|
28
|
+
start: number,
|
|
29
|
+
tag: string,
|
|
30
|
+
caseInsensitive: boolean,
|
|
31
|
+
): boolean {
|
|
32
|
+
if (start + tag.length > haystack.length) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
for (let k = 0; k < tag.length; k += 1) {
|
|
36
|
+
if (
|
|
37
|
+
foldedCharCode(haystack.charCodeAt(start + k), caseInsensitive) !==
|
|
38
|
+
tag.charCodeAt(k)
|
|
39
|
+
) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* First occurrence of any tag in `haystack`, by original-string index.
|
|
48
|
+
* When several tags match at the same index the longest wins, so
|
|
49
|
+
* `<thinking>` is not consumed as `<think>` plus stray text.
|
|
50
|
+
*/
|
|
51
|
+
export function indexOfTag(
|
|
52
|
+
haystack: string,
|
|
53
|
+
tags: readonly string[],
|
|
54
|
+
caseInsensitive: boolean,
|
|
55
|
+
): { index: number; tag: string } | null {
|
|
56
|
+
for (let i = 0; i < haystack.length; i += 1) {
|
|
57
|
+
let bestTag: string | null = null;
|
|
58
|
+
for (const tag of tags) {
|
|
59
|
+
if (tagMatchesAt(haystack, i, tag, caseInsensitive)) {
|
|
60
|
+
if (bestTag === null || tag.length > bestTag.length) {
|
|
61
|
+
bestTag = tag;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (bestTag !== null) {
|
|
66
|
+
return { index: i, tag: bestTag };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Length of the longest suffix of `text` that is a proper prefix of any
|
|
74
|
+
* listed tag. Streaming scanners hold that many characters back so a tag
|
|
75
|
+
* split across deltas is not emitted before it can be recognized.
|
|
76
|
+
*/
|
|
77
|
+
export function partialTagSuffix(
|
|
78
|
+
text: string,
|
|
79
|
+
tags: readonly string[],
|
|
80
|
+
caseInsensitive: boolean,
|
|
81
|
+
): number {
|
|
82
|
+
const longest = Math.max(...tags.map((tag) => tag.length)) - 1;
|
|
83
|
+
for (let len = Math.min(text.length, longest); len > 0; len -= 1) {
|
|
84
|
+
const start = text.length - len;
|
|
85
|
+
for (const tag of tags) {
|
|
86
|
+
if (
|
|
87
|
+
len < tag.length &&
|
|
88
|
+
tagMatchesAt(text, start, tag.slice(0, len), caseInsensitive)
|
|
89
|
+
) {
|
|
90
|
+
return len;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
@@ -163,9 +163,10 @@ function foldPersonalEntryName(provider: string): string {
|
|
|
163
163
|
* 2026-05-05, #29755), and the profile-model migrations (100, 103, 109, 113,
|
|
164
164
|
* 123) deliberately rewrite only the managed default entries (`custom-*` is
|
|
165
165
|
* the user's to manage), so an untouched copy still carries whichever value
|
|
166
|
-
* its hatch-era intent resolved to.
|
|
167
|
-
*
|
|
168
|
-
* place
|
|
166
|
+
* its hatch-era intent resolved to. Migrations 136 and 146 are the
|
|
167
|
+
* exceptions: they rewrite stale Fireworks pins (`custom-*` included) in
|
|
168
|
+
* place, so their targets are machinery-authored values too. A model listed
|
|
169
|
+
* here counts as unedited only when every non-model
|
|
169
170
|
* field still matches the template.
|
|
170
171
|
*/
|
|
171
172
|
const HISTORICAL_INTENT_MODELS: Record<
|
|
@@ -184,6 +185,8 @@ const HISTORICAL_INTENT_MODELS: Record<
|
|
|
184
185
|
"accounts/fireworks/models/kimi-k2p6",
|
|
185
186
|
// migration 136's in-place rewrite of a kimi-k2p5 pin.
|
|
186
187
|
"accounts/fireworks/models/deepseek-v4-flash",
|
|
188
|
+
// the ID migration 146 writes over stale deepseek-v4-flash pins.
|
|
189
|
+
"accounts/fireworks/models/deepseek-v4-flash-0731",
|
|
187
190
|
],
|
|
188
191
|
},
|
|
189
192
|
"quality-optimized": {
|
|
@@ -208,6 +211,8 @@ const HISTORICAL_INTENT_MODELS: Record<
|
|
|
208
211
|
"accounts/fireworks/models/kimi-k2p5",
|
|
209
212
|
// migration 136's in-place rewrite of a kimi-k2p5 pin.
|
|
210
213
|
"accounts/fireworks/models/deepseek-v4-flash",
|
|
214
|
+
// the ID migration 146 writes over stale deepseek-v4-flash pins.
|
|
215
|
+
"accounts/fireworks/models/deepseek-v4-flash-0731",
|
|
211
216
|
],
|
|
212
217
|
},
|
|
213
218
|
"cost-optimized": {
|
|
@@ -221,10 +226,14 @@ const HISTORICAL_INTENT_MODELS: Record<
|
|
|
221
226
|
],
|
|
222
227
|
fireworks: [
|
|
223
228
|
// latency intent 2026-05-05 (#29755) to 2026-07-28 (#39446). On live
|
|
224
|
-
// configs migration 136
|
|
225
|
-
//
|
|
226
|
-
// backups.
|
|
229
|
+
// configs migration 136 rewrites it to deepseek-v4-flash, so this
|
|
230
|
+
// survives only in configs restored from pre-136 backups.
|
|
227
231
|
"accounts/fireworks/models/kimi-k2p5",
|
|
232
|
+
// latency intent 2026-07-28 (#39446) to the 2026-08-17 dated-ID
|
|
233
|
+
// repoint; also the ID migration 136 writes. On live configs migration
|
|
234
|
+
// 146 rewrites it to the dated ID, so this survives only in pre-146
|
|
235
|
+
// backups.
|
|
236
|
+
"accounts/fireworks/models/deepseek-v4-flash",
|
|
228
237
|
],
|
|
229
238
|
},
|
|
230
239
|
};
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { Database } from "bun:sqlite";
|
|
4
|
+
|
|
5
|
+
import type { WorkspaceMigration } from "./types.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Repair the retired undated Fireworks DeepSeek V4 Flash model ID in
|
|
9
|
+
* workspace LLM config.
|
|
10
|
+
*
|
|
11
|
+
* Fireworks serves DeepSeek V4 Flash only under the dated official release
|
|
12
|
+
* ID `accounts/fireworks/models/deepseek-v4-flash-0731`; the undated
|
|
13
|
+
* `accounts/fireworks/models/deepseek-v4-flash` has no deployment and every
|
|
14
|
+
* call to it fails with a 404 "Model not found, inaccessible, and/or not
|
|
15
|
+
* deployed". Existing configs can still pin the undated ID in
|
|
16
|
+
* `llm.default`, `llm.callSites.*`, and `llm.profiles.*`.
|
|
17
|
+
*
|
|
18
|
+
* Repair those leaves only on an exact stale match, replacing with the
|
|
19
|
+
* dated ID.
|
|
20
|
+
*
|
|
21
|
+
* Provider guard: the stale ID belongs to the `fireworks` provider and also
|
|
22
|
+
* appears in managed profiles stamped `provider: "vellum"` (which route
|
|
23
|
+
* Fireworks-account model IDs through the managed proxy). Under the entries
|
|
24
|
+
* model (migration 145) `provider` can also hold a `provider_connections`
|
|
25
|
+
* entry name whose row kind drives dispatch. A fragment is repaired when
|
|
26
|
+
* its `provider` is `"fireworks"`, `"vellum"`, absent, or an entry name
|
|
27
|
+
* whose row kind is one of those: every fireworks-kind route serves the
|
|
28
|
+
* dated ID and only the dated ID. Any other provider is left untouched: an
|
|
29
|
+
* `openai-compatible` endpoint may legitimately serve a model by the stale
|
|
30
|
+
* name.
|
|
31
|
+
*/
|
|
32
|
+
export const repairRetiredFireworksDeepseekFlashModelIdMigration: WorkspaceMigration =
|
|
33
|
+
{
|
|
34
|
+
id: "146-repair-retired-fireworks-deepseek-flash-model-id",
|
|
35
|
+
description:
|
|
36
|
+
"Repair retired Fireworks accounts/fireworks/models/deepseek-v4-flash model ID in workspace LLM config",
|
|
37
|
+
run(workspaceDir: string): void {
|
|
38
|
+
const configPath = join(workspaceDir, "config.json");
|
|
39
|
+
if (!existsSync(configPath)) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Read outside the parse catch: a transient filesystem error (EIO,
|
|
44
|
+
// EACCES) must reach the runner so the migration retries, while
|
|
45
|
+
// malformed JSON is a permanent state this migration cannot repair.
|
|
46
|
+
const rawText = readFileSync(configPath, "utf-8");
|
|
47
|
+
|
|
48
|
+
let config: Record<string, unknown>;
|
|
49
|
+
try {
|
|
50
|
+
const raw = JSON.parse(rawText);
|
|
51
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
config = raw as Record<string, unknown>;
|
|
55
|
+
} catch {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const llm = readObject(config.llm);
|
|
60
|
+
if (llm === null) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Entry rows load lazily: only a stale fragment whose provider is
|
|
65
|
+
// neither a repairable vendor nor absent needs them. An unreadable DB
|
|
66
|
+
// then fails the run (retried next boot) rather than checkpointing a
|
|
67
|
+
// pass that skips entry-bound profiles.
|
|
68
|
+
let rows: Map<string, string> | null | undefined;
|
|
69
|
+
const isRepairableProvider = (provider: unknown): boolean => {
|
|
70
|
+
if (provider === undefined) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
if (typeof provider !== "string") {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
if (REPAIRABLE_PROVIDERS.has(provider)) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
if (rows === undefined) {
|
|
80
|
+
rows = readConnectionRows(workspaceDir);
|
|
81
|
+
}
|
|
82
|
+
if (rows === null) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
"provider_connections is not readable; retrying the model-ID repair on the next run",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const kind = rows.get(provider);
|
|
88
|
+
return kind !== undefined && REPAIRABLE_PROVIDERS.has(kind);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
let changed = false;
|
|
92
|
+
|
|
93
|
+
changed =
|
|
94
|
+
repairFragment(readObject(llm.default), isRepairableProvider) ||
|
|
95
|
+
changed;
|
|
96
|
+
|
|
97
|
+
const callSites = readObject(llm.callSites);
|
|
98
|
+
if (callSites !== null) {
|
|
99
|
+
for (const rawConfig of Object.values(callSites)) {
|
|
100
|
+
changed =
|
|
101
|
+
repairFragment(readObject(rawConfig), isRepairableProvider) ||
|
|
102
|
+
changed;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const profiles = readObject(llm.profiles);
|
|
107
|
+
if (profiles !== null) {
|
|
108
|
+
for (const rawProfile of Object.values(profiles)) {
|
|
109
|
+
changed =
|
|
110
|
+
repairFragment(readObject(rawProfile), isRepairableProvider) ||
|
|
111
|
+
changed;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!changed) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Write-then-rename so an interrupted write cannot leave config.json
|
|
120
|
+
// truncated: a torn in-place write would parse as invalid JSON on the
|
|
121
|
+
// retry, which the catch above treats as "nothing to do", letting the
|
|
122
|
+
// runner checkpoint the migration as completed against a corrupt file.
|
|
123
|
+
const tmpPath = `${configPath}.migration-146.tmp`;
|
|
124
|
+
writeFileSync(tmpPath, JSON.stringify(config, null, 2) + "\n");
|
|
125
|
+
renameSync(tmpPath, configPath);
|
|
126
|
+
},
|
|
127
|
+
// The exact-match rewrite is idempotent, so a transient failure (full
|
|
128
|
+
// disk, I/O error) is safe to retry on later startups.
|
|
129
|
+
retryFailedCheckpoint: true,
|
|
130
|
+
down(_workspaceDir: string): void {
|
|
131
|
+
// Forward-only: reintroducing the retired model ID would break
|
|
132
|
+
// Fireworks calls.
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Helpers: self-contained per workspace migrations AGENTS.md
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
const STALE_MODEL_ID = "accounts/fireworks/models/deepseek-v4-flash";
|
|
141
|
+
const REPLACEMENT_MODEL_ID = "accounts/fireworks/models/deepseek-v4-flash-0731";
|
|
142
|
+
const REPAIRABLE_PROVIDERS = new Set(["fireworks", "vellum"]);
|
|
143
|
+
|
|
144
|
+
function repairFragment(
|
|
145
|
+
fragment: Record<string, unknown> | null,
|
|
146
|
+
isRepairableProvider: (provider: unknown) => boolean,
|
|
147
|
+
): boolean {
|
|
148
|
+
if (fragment === null) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
if (fragment.model !== STALE_MODEL_ID) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
if (!isRepairableProvider(fragment.provider)) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
fragment.model = REPLACEMENT_MODEL_ID;
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Connection name -> provider kind, or null when the DB or table is not
|
|
163
|
+
* readable. The caller fails the run on null: entry-name providers must be
|
|
164
|
+
* judged against real rows, never guessed. An absent DB file is a real
|
|
165
|
+
* state (no rows, so every entry name is dangling and stays untouched).
|
|
166
|
+
*/
|
|
167
|
+
function readConnectionRows(workspaceDir: string): Map<string, string> | null {
|
|
168
|
+
const dbPath = join(workspaceDir, "data", "db", "assistant.db");
|
|
169
|
+
if (!existsSync(dbPath)) {
|
|
170
|
+
return new Map();
|
|
171
|
+
}
|
|
172
|
+
let db: Database;
|
|
173
|
+
try {
|
|
174
|
+
db = new Database(dbPath);
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const rows = db
|
|
180
|
+
.query(`SELECT name, provider FROM provider_connections`)
|
|
181
|
+
.all() as Array<{ name: string; provider: string }>;
|
|
182
|
+
return new Map(rows.map((r) => [r.name, r.provider]));
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
} finally {
|
|
186
|
+
db.close();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function readObject(value: unknown): Record<string, unknown> | null {
|
|
191
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
return value as Record<string, unknown>;
|
|
195
|
+
}
|
|
@@ -143,6 +143,7 @@ import { consolidateVoiceFrontDoorMigration } from "./142-consolidate-voice-fron
|
|
|
143
143
|
import { repairDeprecatedCodexModelIdMigration } from "./143-repair-deprecated-codex-model-id.js";
|
|
144
144
|
import { convertStrandedSubscriptionOpenaiProfilesMigration } from "./144-convert-stranded-subscription-openai-profiles.js";
|
|
145
145
|
import { collapseProfileBindingsToEntriesMigration } from "./145-collapse-profile-bindings-to-entries.js";
|
|
146
|
+
import { repairRetiredFireworksDeepseekFlashModelIdMigration } from "./146-repair-retired-fireworks-deepseek-flash-model-id.js";
|
|
146
147
|
import { migrateToWorkspaceVolumeMigration } from "./migrate-to-workspace-volume.js";
|
|
147
148
|
import type { WorkspaceMigration } from "./types.js";
|
|
148
149
|
|
|
@@ -301,4 +302,5 @@ export const WORKSPACE_MIGRATIONS: WorkspaceMigration[] = [
|
|
|
301
302
|
repairDeprecatedCodexModelIdMigration,
|
|
302
303
|
convertStrandedSubscriptionOpenaiProfilesMigration,
|
|
303
304
|
collapseProfileBindingsToEntriesMigration,
|
|
305
|
+
repairRetiredFireworksDeepseekFlashModelIdMigration,
|
|
304
306
|
];
|