@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.
- package/openapi.yaml +3 -1
- package/package.json +1 -1
- package/src/__tests__/config-loader-backfill.test.ts +1 -0
- package/src/__tests__/default-profile-catalog-fallback.test.ts +1 -0
- package/src/__tests__/inference-profile-validation-jev.test.ts +17 -0
- package/src/__tests__/llm-catalog-parity.test.ts +4 -0
- package/src/__tests__/llm-resolver-override-or-default.test.ts +43 -0
- package/src/__tests__/openai-responses-prompt-cache.test.ts +3 -1
- package/src/__tests__/plugin-api-model-profiles.test.ts +7 -0
- package/src/__tests__/pricing.test.ts +33 -0
- package/src/__tests__/skill-load-tool.test.ts +42 -11
- package/src/__tests__/skill-tool-factory.test.ts +50 -2
- package/src/__tests__/validate-input.test.ts +73 -0
- package/src/__tests__/workspace-migration-159-rename-colliding-jev-profile-name.test.ts +197 -0
- package/src/api/requests/dictation.ts +2 -2
- package/src/calls/__tests__/typesafe-noul.test.ts +66 -0
- package/src/calls/typesafe-noul.ts +8 -2
- package/src/calls/voice-escalation-judge.ts +5 -4
- package/src/config/__tests__/default-profile-catalog.test.ts +33 -1
- package/src/config/__tests__/jev-managed-profile.test.ts +82 -0
- package/src/config/call-site-defaults.ts +11 -11
- package/src/config/default-profile-catalog.ts +88 -18
- package/src/config/default-profile-names.ts +35 -0
- package/src/config/inference-profile-validation.ts +7 -6
- package/src/config/llm-resolver.ts +16 -0
- package/src/config/profile-text-generation.test.ts +9 -0
- package/src/config/schemas/call-site-catalog.ts +1 -1
- package/src/config/schemas/llm.ts +54 -9
- package/src/daemon/conversation-slash.ts +2 -2
- package/src/daemon/dictation-text-processing.ts +4 -4
- package/src/plugin-api/model-profiles.ts +5 -3
- package/src/plugins/defaults/memory/substrate/skill-content.ts +1 -1
- package/src/plugins/defaults/memory/v3/__tests__/render-injection.test.ts +3 -0
- package/src/providers/__tests__/connection-model-compat.test.ts +2 -0
- package/src/providers/anthropic/client.ts +2 -2
- package/src/providers/model-catalog.ts +257 -4
- package/src/providers/openai/codex-models.ts +2 -0
- package/src/runtime/routes/__tests__/dictation-processing.test.ts +174 -0
- package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +16 -1
- package/src/runtime/routes/conversation-routes.ts +4 -2
- package/src/runtime/routes/diagnostics-routes.ts +66 -229
- package/src/runtime/routes/llm-call-sites-routes.ts +19 -10
- package/src/skills/validate-input.ts +75 -11
- package/src/tools/skills/execute.ts +1 -1
- package/src/tools/skills/format-input-schema.ts +15 -0
- package/src/tools/skills/load.ts +2 -26
- package/src/tools/skills/skill-tool-factory.ts +2 -1
- package/src/tools/workflows/manage-workflows.ts +4 -2
- package/src/workspace/migrations/159-rename-colliding-jev-profile-name.ts +344 -0
- package/src/workspace/migrations/registry.ts +2 -0
- package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +0 -107
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type { DictationProfile } from "../../../daemon/dictation-profile-store.js";
|
|
4
|
+
|
|
5
|
+
let profile: DictationProfile;
|
|
6
|
+
let nextToolInput: { mode?: string; text?: string } | null;
|
|
7
|
+
let providerAvailable: boolean;
|
|
8
|
+
let providerFails: boolean;
|
|
9
|
+
let providerResolutions: number;
|
|
10
|
+
let requestedProperties: Record<string, unknown> | undefined;
|
|
11
|
+
|
|
12
|
+
mock.module("../../../providers/provider-send-message.js", () => ({
|
|
13
|
+
getConfiguredProvider: async () => {
|
|
14
|
+
providerResolutions += 1;
|
|
15
|
+
if (!providerAvailable) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
sendMessage: async (
|
|
20
|
+
_messages: unknown,
|
|
21
|
+
options: {
|
|
22
|
+
tools: Array<{
|
|
23
|
+
input_schema: { properties: Record<string, unknown> };
|
|
24
|
+
}>;
|
|
25
|
+
},
|
|
26
|
+
) => {
|
|
27
|
+
if (providerFails) {
|
|
28
|
+
throw new Error("Provider unavailable");
|
|
29
|
+
}
|
|
30
|
+
requestedProperties = options.tools[0]?.input_schema.properties;
|
|
31
|
+
return {
|
|
32
|
+
content: nextToolInput
|
|
33
|
+
? [{ type: "tool_use", input: nextToolInput }]
|
|
34
|
+
: [{ type: "text", text: "Rewritten text." }],
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
},
|
|
39
|
+
extractToolUse: (response: { content: Array<{ type: string }> }) =>
|
|
40
|
+
response.content.find((block) => block.type === "tool_use"),
|
|
41
|
+
userMessage: (text: string) => ({ role: "user", content: text }),
|
|
42
|
+
createTimeout: () => ({ signal: undefined, cleanup: () => undefined }),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
mock.module("../../../daemon/dictation-profile-store.js", () => ({
|
|
46
|
+
resolveProfile: () => ({ profile, source: "default" }),
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
const { ROUTES, computeMaxTokens } = await import("../diagnostics-routes.js");
|
|
50
|
+
const route = ROUTES.find((entry) => entry.operationId === "dictation_post")!;
|
|
51
|
+
|
|
52
|
+
async function dictate(
|
|
53
|
+
transcription: string,
|
|
54
|
+
context: { cursorInTextField?: boolean; selectedText?: string } = {
|
|
55
|
+
cursorInTextField: true,
|
|
56
|
+
},
|
|
57
|
+
) {
|
|
58
|
+
return (await route.handler({
|
|
59
|
+
body: { transcription, context },
|
|
60
|
+
} as unknown as Parameters<typeof route.handler>[0])) as {
|
|
61
|
+
text: string;
|
|
62
|
+
mode: string;
|
|
63
|
+
actionPlan?: string;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
profile = { id: "general", name: "General" };
|
|
69
|
+
nextToolInput = { mode: "dictation", text: "We should ship it." };
|
|
70
|
+
providerAvailable = true;
|
|
71
|
+
providerFails = false;
|
|
72
|
+
providerResolutions = 0;
|
|
73
|
+
requestedProperties = undefined;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("dictation preserves recognized words", () => {
|
|
77
|
+
test.each([
|
|
78
|
+
"I think we probably shouldn't ship it, at least not yet.",
|
|
79
|
+
"Um, I don't, I don't want to say yes. Maybe next week?",
|
|
80
|
+
"First onions, then tomatoes. Actually, no tomatoes.",
|
|
81
|
+
" Please don't send that.\nI mean it. ",
|
|
82
|
+
"send me the draft, but don't send it to anyone else",
|
|
83
|
+
])(
|
|
84
|
+
"preserves text-field dictation despite an attempted model rewrite: %s",
|
|
85
|
+
async (words) => {
|
|
86
|
+
expect(await dictate(words)).toMatchObject({
|
|
87
|
+
mode: "dictation",
|
|
88
|
+
text: words,
|
|
89
|
+
});
|
|
90
|
+
expect(providerResolutions).toBe(1);
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
test("a style profile cannot automatically rewrite ordinary dictation", async () => {
|
|
95
|
+
profile.stylePrompt = "Make every sentence confident and professional.";
|
|
96
|
+
const words = "I guess maybe we shouldn't do this.";
|
|
97
|
+
|
|
98
|
+
expect(await dictate(words)).toMatchObject({ text: words });
|
|
99
|
+
expect(providerResolutions).toBe(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("whitespace-only selection is ordinary dictation", async () => {
|
|
103
|
+
expect(
|
|
104
|
+
await dictate("Don't change my words.", {
|
|
105
|
+
cursorInTextField: true,
|
|
106
|
+
selectedText: " \n ",
|
|
107
|
+
}),
|
|
108
|
+
).toMatchObject({ mode: "dictation", text: "Don't change my words." });
|
|
109
|
+
expect(providerResolutions).toBe(1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("keeps explicitly configured snippet and dictionary replacements", async () => {
|
|
113
|
+
profile.snippets = [
|
|
114
|
+
{ trigger: "my sign off", expansion: "Thanks, user one" },
|
|
115
|
+
];
|
|
116
|
+
profile.dictionary = [{ spoken: "user one", written: "Example User" }];
|
|
117
|
+
|
|
118
|
+
expect(await dictate("Maybe later. my sign off")).toMatchObject({
|
|
119
|
+
mode: "dictation",
|
|
120
|
+
text: "Maybe later. Thanks, Example User",
|
|
121
|
+
});
|
|
122
|
+
expect(providerResolutions).toBe(1);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("classification outside a text field cannot supply replacement text", async () => {
|
|
126
|
+
const words = "I think we probably shouldn't ship it.";
|
|
127
|
+
|
|
128
|
+
expect(await dictate(words, {})).toMatchObject({
|
|
129
|
+
mode: "dictation",
|
|
130
|
+
text: words,
|
|
131
|
+
});
|
|
132
|
+
expect(providerResolutions).toBe(1);
|
|
133
|
+
expect(Object.keys(requestedProperties ?? {})).toEqual(["mode"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test.each([true, false])(
|
|
137
|
+
"action classification preserves the original command with cursorInTextField=%s",
|
|
138
|
+
async (cursorInTextField) => {
|
|
139
|
+
nextToolInput = { mode: "action", text: "Send the files." };
|
|
140
|
+
profile.snippets = [{ trigger: "files", expansion: "all files" }];
|
|
141
|
+
const words = "Send the files, but not the private ones.";
|
|
142
|
+
|
|
143
|
+
expect(await dictate(words, { cursorInTextField })).toMatchObject({
|
|
144
|
+
mode: "action",
|
|
145
|
+
text: words,
|
|
146
|
+
actionPlan: `User wants to: ${words}`,
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
test.each([
|
|
152
|
+
"missing-provider",
|
|
153
|
+
"provider-error",
|
|
154
|
+
"missing-tool",
|
|
155
|
+
"invalid-mode",
|
|
156
|
+
])("classification fallback preserves words when %s", async (failure) => {
|
|
157
|
+
providerAvailable = failure !== "missing-provider";
|
|
158
|
+
providerFails = failure === "provider-error";
|
|
159
|
+
nextToolInput = failure === "missing-tool" ? null : { mode: "invalid" };
|
|
160
|
+
const words = "I don't think that's what I meant, you know?";
|
|
161
|
+
|
|
162
|
+
expect(await dictate(words, {})).toMatchObject({
|
|
163
|
+
mode: "dictation",
|
|
164
|
+
text: words,
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("selected-text output budget", () => {
|
|
170
|
+
test("reserves space for the edit and tool-call JSON", () => {
|
|
171
|
+
expect(computeMaxTokens(900)).toBeGreaterThan(600);
|
|
172
|
+
expect(computeMaxTokens(5)).toBe(512);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -4,8 +4,9 @@ import { CALL_SITE_DEFAULTS } from "../../../config/call-site-defaults.js";
|
|
|
4
4
|
import {
|
|
5
5
|
type LLMCallSite,
|
|
6
6
|
LLMCallSiteEnum,
|
|
7
|
+
LLMSchema,
|
|
7
8
|
} from "../../../config/schemas/llm.js";
|
|
8
|
-
import { ROUTES } from "../llm-call-sites-routes.js";
|
|
9
|
+
import { buildCallSiteCatalog, ROUTES } from "../llm-call-sites-routes.js";
|
|
9
10
|
|
|
10
11
|
const route = ROUTES.find((r) => r.operationId === "llm_call_sites_list")!;
|
|
11
12
|
|
|
@@ -89,6 +90,20 @@ describe("llm-call-sites-routes", () => {
|
|
|
89
90
|
}
|
|
90
91
|
});
|
|
91
92
|
|
|
93
|
+
test("omits an unavailable managed-only shipped default under BYOK", () => {
|
|
94
|
+
const managed = buildCallSiteCatalog(LLMSchema.parse({})).find(
|
|
95
|
+
({ id }) => id === "voiceEscalationJudge",
|
|
96
|
+
);
|
|
97
|
+
expect(managed?.defaultProfile).toBe("jev-managed");
|
|
98
|
+
expect(managed?.shippedDefaultProfile).toBe("jev-managed");
|
|
99
|
+
|
|
100
|
+
const byok = buildCallSiteCatalog(
|
|
101
|
+
LLMSchema.parse({ defaultProvider: { provider: "anthropic" } }),
|
|
102
|
+
).find(({ id }) => id === "voiceEscalationJudge");
|
|
103
|
+
expect(byok?.defaultProfile).toBeUndefined();
|
|
104
|
+
expect(byok?.shippedDefaultProfile).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
|
|
92
107
|
test("domains have non-empty id and displayName", async () => {
|
|
93
108
|
const result = (await route.handler({})) as {
|
|
94
109
|
domains: Array<{ id: string; displayName: string }>;
|
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
supportsHostProxy,
|
|
43
43
|
} from "../../channels/types.js";
|
|
44
44
|
import { isAssistantFeatureFlagEnabled } from "../../config/assistant-feature-flags.js";
|
|
45
|
-
import {
|
|
45
|
+
import { getConversationProfilesForProvider } from "../../config/default-profile-catalog.js";
|
|
46
46
|
import { isHttpAuthDisabled } from "../../config/env.js";
|
|
47
47
|
import { getConfig } from "../../config/loader.js";
|
|
48
48
|
import { isModeSessionRecoveryHealthy } from "../../config/session-groups-gate.js";
|
|
@@ -1754,7 +1754,9 @@ export async function handleSendMessage(
|
|
|
1754
1754
|
}
|
|
1755
1755
|
if (requestedInferenceProfile !== undefined) {
|
|
1756
1756
|
const { llm } = getConfig();
|
|
1757
|
-
|
|
1757
|
+
// The conversation view: a sticky pin runs the main agent's turns, so a
|
|
1758
|
+
// profile whose model returns verdicts rather than text is not offered.
|
|
1759
|
+
const profiles = getConversationProfilesForProvider(
|
|
1758
1760
|
llm.profiles,
|
|
1759
1761
|
llm.defaultProvider ?? null,
|
|
1760
1762
|
);
|
|
@@ -22,7 +22,6 @@ import {
|
|
|
22
22
|
getConfiguredProvider,
|
|
23
23
|
userMessage,
|
|
24
24
|
} from "../../providers/provider-send-message.js";
|
|
25
|
-
import { isMaxTokensStopReason } from "../../providers/stop-reasons.js";
|
|
26
25
|
import { getLogger } from "../../util/logger.js";
|
|
27
26
|
import { safeStringSlice } from "../../util/unicode.js";
|
|
28
27
|
import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
|
|
@@ -73,56 +72,14 @@ function buildAppMetadataBlock(context: DictationContext): string {
|
|
|
73
72
|
].join("\n");
|
|
74
73
|
}
|
|
75
74
|
|
|
76
|
-
function
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
): string {
|
|
80
|
-
// Every line here is read on every hold, so the prompt says each thing
|
|
81
|
-
// once and leaves the model no reasoning to write.
|
|
82
|
-
const sections = [
|
|
83
|
-
"You are a voice input assistant. Given a speech transcription, classify it and, if it is dictation, clean it up.",
|
|
84
|
-
"",
|
|
85
|
-
"## Classification",
|
|
75
|
+
function buildDictationClassificationPrompt(body: DictationBody): string {
|
|
76
|
+
return [
|
|
77
|
+
"Classify the speech transcription without rewriting it.",
|
|
86
78
|
"dictation: the user is composing text to be typed as-is.",
|
|
87
|
-
"action: the user is asking an assistant to do something (send, message, open, search, create, schedule).
|
|
79
|
+
"action: the user is asking an assistant to do something (send, message, open, search, create, schedule).",
|
|
88
80
|
`Cursor in text field: ${body.context.cursorInTextField ? "yes" : "no"}. If yes, lean toward dictation unless the intent to command is clear.`,
|
|
89
|
-
"",
|
|
90
|
-
"## Cleanup",
|
|
91
|
-
"- Fix grammar, punctuation and capitalization; remove filler words",
|
|
92
|
-
"- Rewrite hedging into clear statements, keeping the speaker's meaning",
|
|
93
|
-
"- When the speaker enumerates items, lay them out as a list, one per line",
|
|
94
|
-
"- Keep the user's natural voice; do not over-formalize casual speech",
|
|
95
|
-
];
|
|
96
|
-
|
|
97
|
-
if (stylePrompt) {
|
|
98
|
-
sections.push(
|
|
99
|
-
"",
|
|
100
|
-
"## User Style (highest priority)",
|
|
101
|
-
"The user's own writing preferences. They override the tone guidance below.",
|
|
102
|
-
"",
|
|
103
|
-
stylePrompt,
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
sections.push(
|
|
108
|
-
"",
|
|
109
|
-
"## Tone",
|
|
110
|
-
stylePrompt
|
|
111
|
-
? "Fallback guidance where the User Style above is silent:"
|
|
112
|
-
: "Adapt tone to the active application:",
|
|
113
|
-
"- Email: professional but warm, greetings and sign-offs where they fit",
|
|
114
|
-
"- Slack: casual and conversational",
|
|
115
|
-
"- Code editors: technical and concise",
|
|
116
|
-
"- Terminal: terse, command-like",
|
|
117
|
-
"- Messages: very casual, short sentences",
|
|
118
|
-
"- Notes and docs: neutral, clear writing",
|
|
119
|
-
"- Otherwise: the user's natural voice",
|
|
120
|
-
"The window title may name the recipient; adapt formality to the apparent relationship.",
|
|
121
|
-
"",
|
|
122
81
|
buildAppMetadataBlock(body.context),
|
|
123
|
-
);
|
|
124
|
-
|
|
125
|
-
return sections.join("\n");
|
|
82
|
+
].join("\n");
|
|
126
83
|
}
|
|
127
84
|
|
|
128
85
|
function buildCommandPrompt(body: DictationBody, stylePrompt?: string): string {
|
|
@@ -184,44 +141,10 @@ function buildCommandPrompt(body: DictationBody, stylePrompt?: string): string {
|
|
|
184
141
|
|
|
185
142
|
export function computeMaxTokens(inputLength: number): number {
|
|
186
143
|
const estimatedInputTokens = Math.ceil(inputLength / 3);
|
|
187
|
-
//
|
|
188
|
-
// argument, plus a `reasoning` string and the JSON scaffolding around both,
|
|
189
|
-
// so the budget has to cover well more than the input alone. `max_tokens` is
|
|
190
|
-
// a ceiling rather than a target: a call that stops short of it costs no
|
|
191
|
-
// extra latency.
|
|
144
|
+
// Leave room for the transformed selection and tool-call JSON.
|
|
192
145
|
return Math.max(512, estimatedInputTokens * 2 + 256);
|
|
193
146
|
}
|
|
194
147
|
|
|
195
|
-
export type CleanupRejection = "truncated";
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Decide whether the cleanup model's rewrite is safe to use as the payload.
|
|
199
|
-
*
|
|
200
|
-
* The rewrite replaces what the user actually said. A token-cap stop means the
|
|
201
|
-
* tool JSON was cut mid-argument, so however complete the `text` argument
|
|
202
|
-
* happens to look, it is a fragment; the raw transcript is the safer payload
|
|
203
|
-
* then. Unpolished beats wrong.
|
|
204
|
-
*
|
|
205
|
-
* Whether a shorter rewrite still preserved the meaning is the model's call
|
|
206
|
-
* (the prompt asks it to drop fillers and tighten phrasing), so there is no
|
|
207
|
-
* length check here: a deterministic ratio cannot tell a concise cleanup from
|
|
208
|
-
* a summary.
|
|
209
|
-
*/
|
|
210
|
-
export function resolveCleanedDictation(
|
|
211
|
-
raw: string,
|
|
212
|
-
cleaned: string,
|
|
213
|
-
stopReason: string | null | undefined,
|
|
214
|
-
): { text: string; rejected: CleanupRejection | null } {
|
|
215
|
-
const trimmed = cleaned.trim();
|
|
216
|
-
if (!trimmed) {
|
|
217
|
-
return { text: raw, rejected: null };
|
|
218
|
-
}
|
|
219
|
-
if (isMaxTokensStopReason(stopReason)) {
|
|
220
|
-
return { text: raw, rejected: "truncated" };
|
|
221
|
-
}
|
|
222
|
-
return { text: trimmed, rejected: null };
|
|
223
|
-
}
|
|
224
|
-
|
|
225
148
|
interface DictationResult {
|
|
226
149
|
text: string;
|
|
227
150
|
mode: DictationMode;
|
|
@@ -263,174 +186,88 @@ async function handleDictation(body: DictationBody): Promise<DictationResult> {
|
|
|
263
186
|
return handleCommandMode(body, profile, profileMeta, stylePrompt);
|
|
264
187
|
}
|
|
265
188
|
|
|
266
|
-
|
|
267
|
-
|
|
189
|
+
const mode = await classifyDictation(body);
|
|
190
|
+
if (mode === "action") {
|
|
191
|
+
return {
|
|
192
|
+
text: body.transcription,
|
|
193
|
+
mode,
|
|
194
|
+
actionPlan: `User wants to: ${body.transcription}`,
|
|
195
|
+
...profileMeta,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
268
198
|
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
199
|
+
// Only explicit user-authored replacements may change dictated words.
|
|
200
|
+
const text = applyDictionary(
|
|
201
|
+
expandSnippets(body.transcription, profile.snippets),
|
|
202
|
+
profile.dictionary,
|
|
203
|
+
);
|
|
204
|
+
return { text, mode, ...profileMeta };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function classifyDictation(
|
|
208
|
+
body: DictationBody,
|
|
209
|
+
): Promise<"dictation" | "action"> {
|
|
272
210
|
try {
|
|
273
211
|
const provider = await getConfiguredProvider("interactionClassifier");
|
|
274
|
-
if (
|
|
275
|
-
|
|
276
|
-
|
|
212
|
+
if (provider) {
|
|
213
|
+
const { signal, cleanup } = createTimeout(
|
|
214
|
+
DICTATION_CLASSIFICATION_TIMEOUT_MS,
|
|
277
215
|
);
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
mode,
|
|
296
|
-
...profileMeta,
|
|
297
|
-
};
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
const systemPrompt = buildCombinedDictationPrompt(body, stylePrompt);
|
|
301
|
-
const maxTokens = computeMaxTokens(transcription.length);
|
|
302
|
-
const { signal, cleanup } = createTimeout(
|
|
303
|
-
DICTATION_CLASSIFICATION_TIMEOUT_MS,
|
|
304
|
-
);
|
|
305
|
-
|
|
306
|
-
try {
|
|
307
|
-
const response = await provider.sendMessage(
|
|
308
|
-
[userMessage(`Transcription: "${transcription}"`)],
|
|
309
|
-
{
|
|
310
|
-
tools: [
|
|
311
|
-
{
|
|
312
|
-
name: "process_dictation",
|
|
313
|
-
description: "Classify the voice input and return cleaned text",
|
|
314
|
-
input_schema: {
|
|
315
|
-
type: "object" as const,
|
|
316
|
-
properties: {
|
|
317
|
-
mode: {
|
|
318
|
-
type: "string",
|
|
319
|
-
enum: ["dictation", "action"],
|
|
320
|
-
description:
|
|
321
|
-
"dictation = user wants text inserted/cleaned up for typing. action = user wants the assistant to perform a task.",
|
|
322
|
-
},
|
|
323
|
-
text: {
|
|
324
|
-
type: "string",
|
|
325
|
-
description:
|
|
326
|
-
"If dictation: the cleaned/formatted text ready for insertion. If action: the raw transcription unchanged.",
|
|
216
|
+
try {
|
|
217
|
+
const response = await provider.sendMessage(
|
|
218
|
+
[userMessage(body.transcription)],
|
|
219
|
+
{
|
|
220
|
+
tools: [
|
|
221
|
+
{
|
|
222
|
+
name: "process_dictation",
|
|
223
|
+
description: "Classify the voice input",
|
|
224
|
+
input_schema: {
|
|
225
|
+
type: "object" as const,
|
|
226
|
+
properties: {
|
|
227
|
+
mode: {
|
|
228
|
+
type: "string",
|
|
229
|
+
enum: ["dictation", "action"],
|
|
230
|
+
description:
|
|
231
|
+
"dictation = user wants text inserted. action = user wants the assistant to perform a task.",
|
|
232
|
+
},
|
|
327
233
|
},
|
|
234
|
+
required: ["mode"],
|
|
328
235
|
},
|
|
329
|
-
required: ["mode", "text"],
|
|
330
236
|
},
|
|
237
|
+
],
|
|
238
|
+
systemPrompt: buildDictationClassificationPrompt(body),
|
|
239
|
+
config: {
|
|
240
|
+
callSite: "interactionClassifier",
|
|
241
|
+
max_tokens: 512,
|
|
242
|
+
tool_choice: { type: "tool" as const, name: "process_dictation" },
|
|
331
243
|
},
|
|
332
|
-
|
|
333
|
-
systemPrompt,
|
|
334
|
-
config: {
|
|
335
|
-
callSite: "interactionClassifier",
|
|
336
|
-
max_tokens: maxTokens,
|
|
337
|
-
tool_choice: {
|
|
338
|
-
type: "tool" as const,
|
|
339
|
-
name: "process_dictation",
|
|
340
|
-
},
|
|
244
|
+
signal,
|
|
341
245
|
},
|
|
342
|
-
signal,
|
|
343
|
-
},
|
|
344
|
-
);
|
|
345
|
-
cleanup();
|
|
346
|
-
|
|
347
|
-
const toolBlock = extractToolUse(response);
|
|
348
|
-
if (toolBlock) {
|
|
349
|
-
const input = toolBlock.input as {
|
|
350
|
-
mode?: string;
|
|
351
|
-
text?: string;
|
|
352
|
-
};
|
|
353
|
-
const mode: DictationMode =
|
|
354
|
-
input.mode === "action" ? "action" : "dictation";
|
|
355
|
-
log.info(
|
|
356
|
-
{
|
|
357
|
-
mode,
|
|
358
|
-
modelMs: Date.now() - modelStartedAt,
|
|
359
|
-
inChars: transcription.length,
|
|
360
|
-
outChars: input.text?.length ?? 0,
|
|
361
|
-
},
|
|
362
|
-
"LLM dictation classify+clean",
|
|
363
246
|
);
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
actionPlan: `User wants to: ${body.transcription}`,
|
|
370
|
-
...profileMeta,
|
|
371
|
-
};
|
|
247
|
+
const input = extractToolUse(response)?.input as
|
|
248
|
+
| { mode?: string }
|
|
249
|
+
| undefined;
|
|
250
|
+
if (input?.mode === "action" || input?.mode === "dictation") {
|
|
251
|
+
return input.mode;
|
|
372
252
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
input.text ?? "",
|
|
376
|
-
response.stopReason,
|
|
377
|
-
);
|
|
378
|
-
if (rejected) {
|
|
379
|
-
// Lengths only -- transcript content must never be logged.
|
|
380
|
-
log.warn(
|
|
381
|
-
{
|
|
382
|
-
rejected,
|
|
383
|
-
stopReason: response.stopReason,
|
|
384
|
-
rawChars: transcription.length,
|
|
385
|
-
cleanedChars: input.text?.trim().length ?? 0,
|
|
386
|
-
},
|
|
387
|
-
"Dictation cleanup rejected, using raw transcription",
|
|
388
|
-
);
|
|
389
|
-
}
|
|
390
|
-
const normalizedText = applyDictionary(cleanedText, profile.dictionary);
|
|
391
|
-
return {
|
|
392
|
-
text: normalizedText,
|
|
393
|
-
mode: "dictation",
|
|
394
|
-
...profileMeta,
|
|
395
|
-
};
|
|
253
|
+
} finally {
|
|
254
|
+
cleanup();
|
|
396
255
|
}
|
|
397
|
-
|
|
398
|
-
log.warn(
|
|
399
|
-
{ modelMs: Date.now() - modelStartedAt },
|
|
400
|
-
"No tool_use block in combined dictation call, using heuristic",
|
|
401
|
-
);
|
|
402
|
-
} finally {
|
|
403
|
-
cleanup();
|
|
404
256
|
}
|
|
405
257
|
} catch (err) {
|
|
406
258
|
const message = err instanceof Error ? err.message : String(err);
|
|
407
259
|
log.warn(
|
|
408
|
-
{ err: message
|
|
409
|
-
"
|
|
260
|
+
{ err: message },
|
|
261
|
+
"Dictation classification failed, using heuristic",
|
|
410
262
|
);
|
|
411
263
|
}
|
|
412
264
|
|
|
413
|
-
|
|
414
|
-
const fallbackMode = detectDictationModeHeuristic({
|
|
265
|
+
const mode = detectDictationModeHeuristic({
|
|
415
266
|
type: "dictation_request",
|
|
416
267
|
transcription: body.transcription,
|
|
417
268
|
context: body.context,
|
|
418
269
|
} as DictationRequest);
|
|
419
|
-
|
|
420
|
-
if (fallbackMode === "action") {
|
|
421
|
-
return {
|
|
422
|
-
text: body.transcription,
|
|
423
|
-
mode: "action",
|
|
424
|
-
actionPlan: `User wants to: ${body.transcription}`,
|
|
425
|
-
...profileMeta,
|
|
426
|
-
};
|
|
427
|
-
}
|
|
428
|
-
const normalizedText = applyDictionary(transcription, profile.dictionary);
|
|
429
|
-
return {
|
|
430
|
-
text: normalizedText,
|
|
431
|
-
mode: fallbackMode,
|
|
432
|
-
...profileMeta,
|
|
433
|
-
};
|
|
270
|
+
return mode === "action" ? "action" : "dictation";
|
|
434
271
|
}
|
|
435
272
|
|
|
436
273
|
async function handleCommandMode(
|
|
@@ -560,7 +397,7 @@ export const ROUTES: RouteDefinition[] = [
|
|
|
560
397
|
},
|
|
561
398
|
summary: "Process dictation",
|
|
562
399
|
description:
|
|
563
|
-
"
|
|
400
|
+
"Preserve dictated words with explicit dictionary and snippet replacements, classify actions, or edit selected text when instructed.",
|
|
564
401
|
tags: ["diagnostics"],
|
|
565
402
|
requestBody: DictationRequestSchema,
|
|
566
403
|
responseBody: z.object({
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
|
-
import { CALL_SITE_DEFAULTS } from "../../config/call-site-defaults.js";
|
|
4
3
|
import { getUserSelectableProfilesForProvider } from "../../config/default-profile-catalog.js";
|
|
5
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
resolveDefaultProfileKey,
|
|
6
|
+
resolveShippedDefaultProfileKey,
|
|
7
|
+
} from "../../config/llm-resolver.js";
|
|
6
8
|
import { loadConfig } from "../../config/loader.js";
|
|
7
9
|
import {
|
|
8
10
|
CALL_SITE_CATALOG,
|
|
9
11
|
CALL_SITE_DOMAINS,
|
|
10
12
|
} from "../../config/schemas/call-site-catalog.js";
|
|
11
|
-
import type { LLMCallSite } from "../../config/schemas/llm.js";
|
|
13
|
+
import type { LLMCallSite, LLMConfig } from "../../config/schemas/llm.js";
|
|
12
14
|
import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
|
|
13
15
|
import type { RouteDefinition } from "./types.js";
|
|
14
16
|
|
|
@@ -30,7 +32,8 @@ const callSiteEntrySchema = z.object({
|
|
|
30
32
|
* `defaultProfile` would scatter pinned or remapped sites across their
|
|
31
33
|
* winners. Profileless call sites report `balanced`: the resolver's
|
|
32
34
|
* fallback anchor consults the balanced remap for them, so they follow
|
|
33
|
-
* the Balanced tier like any balanced-keyed site.
|
|
35
|
+
* the Balanced tier like any balanced-keyed site. A managed-only tier is
|
|
36
|
+
* omitted when it has no body under the workspace's default provider.
|
|
34
37
|
*/
|
|
35
38
|
shippedDefaultProfile: z.string().optional(),
|
|
36
39
|
});
|
|
@@ -40,16 +43,22 @@ const callSiteCatalogResponseSchema = z.object({
|
|
|
40
43
|
callSites: z.array(callSiteEntrySchema),
|
|
41
44
|
});
|
|
42
45
|
|
|
46
|
+
export function buildCallSiteCatalog(llm: LLMConfig) {
|
|
47
|
+
return CALL_SITE_CATALOG.map((entry) => ({
|
|
48
|
+
...entry,
|
|
49
|
+
defaultProfile: resolveDefaultProfileKey(entry.id as LLMCallSite, llm),
|
|
50
|
+
shippedDefaultProfile: resolveShippedDefaultProfileKey(
|
|
51
|
+
entry.id as LLMCallSite,
|
|
52
|
+
llm,
|
|
53
|
+
),
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
43
57
|
async function handleGetCallSites() {
|
|
44
58
|
const { llm } = loadConfig();
|
|
45
59
|
return {
|
|
46
60
|
domains: CALL_SITE_DOMAINS,
|
|
47
|
-
callSites:
|
|
48
|
-
...entry,
|
|
49
|
-
defaultProfile: resolveDefaultProfileKey(entry.id as LLMCallSite, llm),
|
|
50
|
-
shippedDefaultProfile:
|
|
51
|
-
CALL_SITE_DEFAULTS[entry.id]?.profile ?? "balanced",
|
|
52
|
-
})),
|
|
61
|
+
callSites: buildCallSiteCatalog(llm),
|
|
53
62
|
};
|
|
54
63
|
}
|
|
55
64
|
|