pi-voicekit 0.2.3 → 0.3.1
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/README.md +46 -3
- package/extensions/voice/local.ts +31 -23
- package/extensions/voice/post-process-queue.ts +532 -0
- package/extensions/voice/post-process.ts +42 -21
- package/extensions/voice/sherpa-engine.ts +41 -17
- package/extensions/voice.ts +367 -55
- package/package.json +1 -1
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Segmented transcript post-processing: a bounded, fail-open polish queue.
|
|
3
|
+
*
|
|
4
|
+
* Pure and dependency-injected like ./post-process: no Pi types, no TUI, no filesystem
|
|
5
|
+
* and no network. The model call arrives as `call`, so ordering, the concurrency cap,
|
|
6
|
+
* the per-segment timeout, the single retry and per-segment isolation are all provable offline.
|
|
7
|
+
*
|
|
8
|
+
* The cap counts real calls, not segment promises: a slot is released when the injected
|
|
9
|
+
* transport settles, so a caller that ignores the abort signal keeps its slot after its
|
|
10
|
+
* segment timed out instead of letting a hung endpoint pile up calls past the cap.
|
|
11
|
+
*
|
|
12
|
+
* Spec: docs/superpowers/specs/2026-09-26-polish-pipeline-design.md §4.2, §4.3, §4.4
|
|
13
|
+
* (a local design record, not part of the published package)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
polishSamplingOptions,
|
|
18
|
+
polishTranscript,
|
|
19
|
+
type AssistantLike,
|
|
20
|
+
type PolishRequest,
|
|
21
|
+
type PolishResult,
|
|
22
|
+
} from "./post-process";
|
|
23
|
+
import { assembleContext, DEFAULT_CONTEXT_LIMITS, type ContextLimits, type EntryLike } from "./post-process-context";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Start at most this many segment calls at once. A long dictation must not open one request
|
|
27
|
+
* per segment, and wall-clock must stay close to the slowest segment rather than their sum
|
|
28
|
+
* (spec §4.2, open question 1).
|
|
29
|
+
*/
|
|
30
|
+
export const POLISH_QUEUE_CONCURRENCY = 3;
|
|
31
|
+
|
|
32
|
+
/** The extra request fields `polishSamplingOptions` decided for one segment. */
|
|
33
|
+
export interface PolishSampling {
|
|
34
|
+
samplingParams?: { reasoning_effort: string };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The polish request as the injected caller sees it: the prompt plus this segment's sampling
|
|
39
|
+
* decision. The caller forwards `samplingParams` to its transport options; it is absent when
|
|
40
|
+
* the model has no thinking to gate.
|
|
41
|
+
*/
|
|
42
|
+
export type QueuePolishRequest = PolishRequest & PolishSampling;
|
|
43
|
+
|
|
44
|
+
/** The model call, injected. The queue decides `samplingParams` per segment. */
|
|
45
|
+
export type PolishQueueCaller = (request: QueuePolishRequest, signal: AbortSignal) => Promise<AssistantLike>;
|
|
46
|
+
|
|
47
|
+
export interface SegmentOutcome {
|
|
48
|
+
index: number;
|
|
49
|
+
status: "applied" | "fallback";
|
|
50
|
+
/** What this segment contributes: the rewrite when applied, its own raw text otherwise. */
|
|
51
|
+
text: string;
|
|
52
|
+
reason?: string;
|
|
53
|
+
retried: boolean;
|
|
54
|
+
latencyMs: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface QueueResult {
|
|
58
|
+
/** The ordered join of every segment outcome (spec §4.2 stitching rule). */
|
|
59
|
+
text: string;
|
|
60
|
+
segments: SegmentOutcome[];
|
|
61
|
+
polished: number;
|
|
62
|
+
failed: number;
|
|
63
|
+
retried: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface PolishQueue {
|
|
67
|
+
/** Enqueue one recogniser segment. Empty text is skipped; work starts immediately. */
|
|
68
|
+
push(index: number, raw: string): void;
|
|
69
|
+
/** Barrier for one dictation: resolves once every segment pushed before this call settled. */
|
|
70
|
+
finish(): Promise<QueueResult>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface PolishQueueOptions {
|
|
74
|
+
call: PolishQueueCaller;
|
|
75
|
+
/** Per-segment timeout, the same knob as the single-call path (`postProcessTimeoutMs`). */
|
|
76
|
+
timeoutMs: number;
|
|
77
|
+
/** Session context entries; attached to segment 0 only (spec §4.3). */
|
|
78
|
+
entries?: readonly EntryLike[];
|
|
79
|
+
limits?: ContextLimits;
|
|
80
|
+
/** The model whose thinking policy gates each segment; absent or non-reasoning keeps thinking on. */
|
|
81
|
+
model?: { reasoning?: boolean } | undefined | null;
|
|
82
|
+
/** Defaults to POLISH_QUEUE_CONCURRENCY; clamped to at least one. */
|
|
83
|
+
concurrency?: number;
|
|
84
|
+
/**
|
|
85
|
+
* One pass id for the whole queue. Checked before each attempt is scheduled (first try and
|
|
86
|
+
* retry), so an invalidated pass stops sending new requests instead of merely discarding
|
|
87
|
+
* the answers; `polishTranscript` checks it again before and after every call.
|
|
88
|
+
*/
|
|
89
|
+
isCurrent?: () => boolean;
|
|
90
|
+
/** Injected clock, so tests can pin latency without waiting. */
|
|
91
|
+
now?: () => number;
|
|
92
|
+
debug?: (reason: string, data?: Record<string, unknown>) => void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface SegmentJob {
|
|
96
|
+
index: number;
|
|
97
|
+
raw: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One real call, which owns a concurrency slot until the transport settles - timeout or not. */
|
|
101
|
+
interface LiveCall {
|
|
102
|
+
settled: boolean;
|
|
103
|
+
orphaned: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One attempt: its result, whether a request really went out, or that no request was sent. */
|
|
107
|
+
interface AttemptRun {
|
|
108
|
+
issued: boolean;
|
|
109
|
+
denied: boolean;
|
|
110
|
+
/** The pass lost ownership before this attempt could send anything. */
|
|
111
|
+
stale: boolean;
|
|
112
|
+
result: PolishResult | null;
|
|
113
|
+
error?: unknown;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Stitch segment texts into one transcript. Two adjacent CJK characters take no separator,
|
|
118
|
+
* so Chinese stays "这是第一段这是第二段" while English keeps "first part second part"; every
|
|
119
|
+
* other boundary takes a single space, which is what zh-en dictation reads as. Empty parts
|
|
120
|
+
* are dropped so a silent segment cannot inject a stray space, and each part is trimmed at
|
|
121
|
+
* its edges only.
|
|
122
|
+
*/
|
|
123
|
+
export function joinSegments(parts: readonly string[]): string {
|
|
124
|
+
let joined = "";
|
|
125
|
+
for (const part of parts) {
|
|
126
|
+
const text = part.trim();
|
|
127
|
+
if (!text) continue;
|
|
128
|
+
if (joined && needsSeparator(joined, text)) joined += " ";
|
|
129
|
+
joined += text;
|
|
130
|
+
}
|
|
131
|
+
return joined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function needsSeparator(left: string, right: string): boolean {
|
|
135
|
+
return !(isCjk(lastCodePoint(left)) && isCjk(firstCodePoint(right)));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function firstCodePoint(text: string): number {
|
|
139
|
+
return text.codePointAt(0) ?? -1;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The code point at the end of `text`, surrogate pairs included. */
|
|
143
|
+
function lastCodePoint(text: string): number {
|
|
144
|
+
const last = text.charCodeAt(text.length - 1);
|
|
145
|
+
const previous = text.charCodeAt(text.length - 2);
|
|
146
|
+
if (last >= 0xdc00 && last <= 0xdfff && previous >= 0xd800 && previous <= 0xdbff) {
|
|
147
|
+
return (previous - 0xd800) * 0x400 + (last - 0xdc00) + 0x10000;
|
|
148
|
+
}
|
|
149
|
+
return last;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* CJK for the stitching rule: ideographs and their extensions, kana, Bopomofo, CJK
|
|
154
|
+
* punctuation and fullwidth forms. Hangul is left out on purpose — Korean separates words
|
|
155
|
+
* with spaces.
|
|
156
|
+
*/
|
|
157
|
+
function isCjk(codePoint: number): boolean {
|
|
158
|
+
return (
|
|
159
|
+
(codePoint >= 0x2e80 && codePoint <= 0x2eff) || // CJK Radicals Supplement
|
|
160
|
+
(codePoint >= 0x3000 && codePoint <= 0x303f) || // CJK Symbols and Punctuation
|
|
161
|
+
(codePoint >= 0x3040 && codePoint <= 0x30ff) || // Hiragana and Katakana
|
|
162
|
+
(codePoint >= 0x3100 && codePoint <= 0x312f) || // Bopomofo
|
|
163
|
+
(codePoint >= 0x3190 && codePoint <= 0x319f) || // Kanbun
|
|
164
|
+
(codePoint >= 0x31c0 && codePoint <= 0x31ef) || // CJK Strokes
|
|
165
|
+
(codePoint >= 0x3400 && codePoint <= 0x4dbf) || // CJK Unified Ideographs Extension A
|
|
166
|
+
(codePoint >= 0x4e00 && codePoint <= 0x9fff) || // CJK Unified Ideographs
|
|
167
|
+
(codePoint >= 0xf900 && codePoint <= 0xfaff) || // CJK Compatibility Ideographs
|
|
168
|
+
(codePoint >= 0xfe30 && codePoint <= 0xfe4f) || // CJK Compatibility Forms
|
|
169
|
+
(codePoint >= 0xff01 && codePoint <= 0xff60) || // Fullwidth forms
|
|
170
|
+
(codePoint >= 0x20000 && codePoint <= 0x3ffff) // Extensions B and beyond
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Same shape as `resolveTurns` in post-process-context: floor locally, never let 0 through. */
|
|
175
|
+
function resolveConcurrency(value: number | undefined): number {
|
|
176
|
+
if (value === undefined || !Number.isFinite(value)) return POLISH_QUEUE_CONCURRENCY;
|
|
177
|
+
return Math.max(1, Math.floor(value));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* One polish queue per dictation. Every segment runs `polishTranscript` with the same
|
|
182
|
+
* guardrails as the single-call path, so nothing here re-implements the prompt, the output
|
|
183
|
+
* validation or the fail-open fallback.
|
|
184
|
+
*/
|
|
185
|
+
export function createPolishQueue(options: PolishQueueOptions): PolishQueue {
|
|
186
|
+
const concurrency = resolveConcurrency(options.concurrency);
|
|
187
|
+
const limits = options.limits ?? DEFAULT_CONTEXT_LIMITS;
|
|
188
|
+
const clock = (): number => {
|
|
189
|
+
try {
|
|
190
|
+
return (options.now ?? Date.now)();
|
|
191
|
+
} catch {
|
|
192
|
+
// A broken injected clock must not cost the user their dictation.
|
|
193
|
+
return Date.now();
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
/** Raw text by index: the join order, and the reference turn for the next segment. */
|
|
197
|
+
const raws = new Map<number, string>();
|
|
198
|
+
const outcomes = new Map<number, SegmentOutcome>();
|
|
199
|
+
/**
|
|
200
|
+
* Real calls in flight, and how many of them outlived their segment. A slot is released when
|
|
201
|
+
* the transport settles, never when the segment deadline fires: an injected caller that
|
|
202
|
+
* ignores the abort signal keeps its slot, so a hung endpoint cannot push the live request
|
|
203
|
+
* count past `concurrency`. `running` counts segments whose outcome is not decided yet.
|
|
204
|
+
*/
|
|
205
|
+
let liveCalls = 0;
|
|
206
|
+
let orphanedCalls = 0;
|
|
207
|
+
let running = 0;
|
|
208
|
+
const slotWaiters: ((granted: boolean) => void)[] = [];
|
|
209
|
+
let finished: Promise<QueueResult> | null = null;
|
|
210
|
+
let resolveIdle: (() => void) | null = null;
|
|
211
|
+
|
|
212
|
+
function orderedIndexes(): number[] {
|
|
213
|
+
return [...raws.keys()].sort((left, right) => left - right);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function hasFreeSlot(): boolean {
|
|
217
|
+
return liveCalls < concurrency;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* True when no slot can free on its own: every live call has already outlived its deadline,
|
|
222
|
+
* so waiting only delays the fallback. Every issued call sits inside a `polishTranscript`
|
|
223
|
+
* timeout, so a live call either settles or becomes an orphan within one deadline.
|
|
224
|
+
*/
|
|
225
|
+
function capacityStuck(): boolean {
|
|
226
|
+
return !hasFreeSlot() && orphanedCalls >= liveCalls;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** After a slot frees or an orphan appears: grant while slots last, else deny when stuck. */
|
|
230
|
+
function wakeWaiters(): void {
|
|
231
|
+
while (slotWaiters.length > 0) {
|
|
232
|
+
if (hasFreeSlot()) {
|
|
233
|
+
liveCalls += 1;
|
|
234
|
+
slotWaiters.shift()!(true);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (capacityStuck()) {
|
|
238
|
+
for (const waiter of slotWaiters.splice(0)) waiter(false);
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Take a slot right now, so a pushed segment reaches its caller without a microtask delay. */
|
|
245
|
+
function tryTakeSlot(): boolean {
|
|
246
|
+
if (!hasFreeSlot()) return false;
|
|
247
|
+
liveCalls += 1;
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Wait for a slot; false means no live call will ever free one, so the segment falls back. */
|
|
252
|
+
function waitForSlot(): Promise<boolean> {
|
|
253
|
+
if (tryTakeSlot()) return Promise.resolve(true);
|
|
254
|
+
if (capacityStuck()) return Promise.resolve(false);
|
|
255
|
+
return new Promise<boolean>((resolve) => {
|
|
256
|
+
slotWaiters.push(resolve);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function releaseSlot(orphaned: boolean): void {
|
|
261
|
+
if (orphaned) orphanedCalls = Math.max(0, orphanedCalls - 1);
|
|
262
|
+
liveCalls = Math.max(0, liveCalls - 1);
|
|
263
|
+
wakeWaiters();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The attempt is over but its call never settled: the slot stays held until it does. */
|
|
267
|
+
function markOrphaned(call: LiveCall): void {
|
|
268
|
+
if (call.orphaned) return;
|
|
269
|
+
call.orphaned = true;
|
|
270
|
+
orphanedCalls += 1;
|
|
271
|
+
wakeWaiters();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function resolveIdleNow(): void {
|
|
275
|
+
if (!resolveIdle || running > 0) return;
|
|
276
|
+
const resolve = resolveIdle;
|
|
277
|
+
resolveIdle = null;
|
|
278
|
+
resolve();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function settle(job: SegmentJob, outcome: SegmentOutcome): void {
|
|
282
|
+
outcomes.set(job.index, outcome);
|
|
283
|
+
running -= 1;
|
|
284
|
+
resolveIdleNow();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Spec §4.3: segment 0 carries the session context; a later segment carries the previous
|
|
289
|
+
* segment's raw text as a single reference turn instead. The reference turn goes through
|
|
290
|
+
* `assembleContext` so the same caps apply to it as to the session context.
|
|
291
|
+
*/
|
|
292
|
+
function entriesForSegment(index: number): readonly EntryLike[] {
|
|
293
|
+
if (index === 0) return options.entries ?? [];
|
|
294
|
+
const previous = raws.get(index - 1);
|
|
295
|
+
if (!previous) return [];
|
|
296
|
+
const reference = assembleContext([{ type: "message", message: { role: "user", content: previous } }], limits);
|
|
297
|
+
const text = reference.turns
|
|
298
|
+
.map((turn) => turn.text)
|
|
299
|
+
.join("\n")
|
|
300
|
+
.trim();
|
|
301
|
+
if (!text) return [];
|
|
302
|
+
return [{ type: "message", message: { role: "user", content: text } }];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function debugFor(index: number): ((reason: string, data?: Record<string, unknown>) => void) | undefined {
|
|
306
|
+
const debug = options.debug;
|
|
307
|
+
if (!debug) return undefined;
|
|
308
|
+
return (reason, data) => {
|
|
309
|
+
try {
|
|
310
|
+
debug(reason, { ...data, segment: index });
|
|
311
|
+
} catch {
|
|
312
|
+
// The debug hook is observational: a throw here must not break fail-open.
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function appliedOutcome(job: SegmentJob, text: string, retried: boolean, started: number): SegmentOutcome {
|
|
318
|
+
return { index: job.index, status: "applied", text, retried, latencyMs: Math.max(0, clock() - started) };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function fallbackOutcome(
|
|
322
|
+
job: SegmentJob,
|
|
323
|
+
reason: string | undefined,
|
|
324
|
+
retried: boolean,
|
|
325
|
+
started: number
|
|
326
|
+
): SegmentOutcome {
|
|
327
|
+
const outcome: SegmentOutcome = {
|
|
328
|
+
index: job.index,
|
|
329
|
+
status: "fallback",
|
|
330
|
+
text: job.raw,
|
|
331
|
+
retried,
|
|
332
|
+
latencyMs: Math.max(0, clock() - started),
|
|
333
|
+
};
|
|
334
|
+
if (reason !== undefined) outcome.reason = reason;
|
|
335
|
+
return outcome;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function reasonOf(run: AttemptRun): string | undefined {
|
|
339
|
+
if (run.stale) return "invalidated";
|
|
340
|
+
if (run.error !== undefined) {
|
|
341
|
+
return run.error instanceof Error && run.error.message ? run.error.message : "segment-error";
|
|
342
|
+
}
|
|
343
|
+
if (run.denied) return "no-capacity";
|
|
344
|
+
return run.result?.reason;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** True once the pass that owns this queue has been invalidated. A throwing check keeps
|
|
348
|
+
* fail-open intact: it must never block a dictation that might still be valid. */
|
|
349
|
+
function stale(): boolean {
|
|
350
|
+
try {
|
|
351
|
+
return options.isCurrent?.() === false;
|
|
352
|
+
} catch {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Spec §4.2: one retry, and only for a call that timed out or failed at the transport. A
|
|
359
|
+
* guardrail rejection means the rewrite was wrong rather than slow, so repeating it cannot
|
|
360
|
+
* help; an attempt that never issued a request has nothing to repeat.
|
|
361
|
+
*/
|
|
362
|
+
function isRetryable(run: AttemptRun): boolean {
|
|
363
|
+
if (!run.issued || !run.result || run.result.status !== "rejected") return false;
|
|
364
|
+
return run.result.reason === "timeout" || run.result.reason === "call-failed";
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function unexpectedOutcome(job: SegmentJob, error: unknown, latencyMs: number): SegmentOutcome {
|
|
368
|
+
return {
|
|
369
|
+
index: job.index,
|
|
370
|
+
status: "fallback",
|
|
371
|
+
text: job.raw,
|
|
372
|
+
reason: error instanceof Error && error.message ? error.message : "segment-error",
|
|
373
|
+
retried: false,
|
|
374
|
+
latencyMs,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* One model attempt. The slot is taken before `polishTranscript` starts its deadline, so a
|
|
380
|
+
* segment that waited for capacity still gets its full timeout, and the slot goes back only
|
|
381
|
+
* once the transport settles.
|
|
382
|
+
*/
|
|
383
|
+
async function runAttempt(job: SegmentJob, forceOff: boolean): Promise<AttemptRun> {
|
|
384
|
+
// Invalidation is checked before a slot is taken: a cancelled or superseded pass must stop
|
|
385
|
+
// scheduling requests, not just ignore their answers.
|
|
386
|
+
if (stale()) return { issued: false, denied: false, stale: true, result: null };
|
|
387
|
+
if (!tryTakeSlot() && !(await waitForSlot())) return { issued: false, denied: true, stale: false, result: null };
|
|
388
|
+
// The pass may have died while this segment waited for a slot.
|
|
389
|
+
if (stale()) {
|
|
390
|
+
releaseSlot(false);
|
|
391
|
+
return { issued: false, denied: false, stale: true, result: null };
|
|
392
|
+
}
|
|
393
|
+
const call: LiveCall = { settled: false, orphaned: false };
|
|
394
|
+
let issued = false;
|
|
395
|
+
try {
|
|
396
|
+
const sampling = polishSamplingOptions(options.model);
|
|
397
|
+
const result = await polishTranscript({
|
|
398
|
+
raw: job.raw,
|
|
399
|
+
entries: entriesForSegment(job.index),
|
|
400
|
+
limits,
|
|
401
|
+
timeoutMs: options.timeoutMs,
|
|
402
|
+
timestamp: clock(),
|
|
403
|
+
isCurrent: options.isCurrent,
|
|
404
|
+
call: async (request, signal) => {
|
|
405
|
+
issued = true;
|
|
406
|
+
try {
|
|
407
|
+
return await options.call({ ...request, ...sampling }, signal);
|
|
408
|
+
} finally {
|
|
409
|
+
call.settled = true;
|
|
410
|
+
releaseSlot(call.orphaned);
|
|
411
|
+
}
|
|
412
|
+
},
|
|
413
|
+
debug: debugFor(job.index),
|
|
414
|
+
});
|
|
415
|
+
if (!call.settled) markOrphaned(call);
|
|
416
|
+
return { issued, denied: false, stale: false, result };
|
|
417
|
+
} catch (error) {
|
|
418
|
+
// `polishTranscript` normally reports through its result; this only covers a fault
|
|
419
|
+
// thrown before it could invoke the caller.
|
|
420
|
+
if (!issued) releaseSlot(false);
|
|
421
|
+
else if (!call.settled) markOrphaned(call);
|
|
422
|
+
return { issued, denied: false, stale: false, result: null, error };
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function runSegment(job: SegmentJob): Promise<SegmentOutcome> {
|
|
427
|
+
const started = clock();
|
|
428
|
+
try {
|
|
429
|
+
const first = await runAttempt(job, false);
|
|
430
|
+
if (first.result?.status === "applied") return appliedOutcome(job, first.result.text, false, started);
|
|
431
|
+
const firstReason = reasonOf(first);
|
|
432
|
+
if (!isRetryable(first)) return fallbackOutcome(job, firstReason, false, started);
|
|
433
|
+
// Never retry for a pass that lost ownership while the first attempt ran.
|
|
434
|
+
if (stale()) return fallbackOutcome(job, "invalidated", false, started);
|
|
435
|
+
const second = await runAttempt(job, true);
|
|
436
|
+
if (second.result?.status === "applied") return appliedOutcome(job, second.result.text, second.issued, started);
|
|
437
|
+
// A retry that never reached the transport leaves the first failure as the reason.
|
|
438
|
+
const reason = second.issued ? (reasonOf(second) ?? firstReason) : firstReason;
|
|
439
|
+
return fallbackOutcome(job, reason, second.issued, started);
|
|
440
|
+
} catch (error) {
|
|
441
|
+
// Fail-open, last resort: this segment keeps its own raw text.
|
|
442
|
+
return unexpectedOutcome(job, error, Math.max(0, clock() - started));
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function buildResult(): QueueResult {
|
|
447
|
+
const segments: SegmentOutcome[] = [];
|
|
448
|
+
for (const index of orderedIndexes()) {
|
|
449
|
+
const outcome = outcomes.get(index);
|
|
450
|
+
if (outcome) {
|
|
451
|
+
segments.push(outcome);
|
|
452
|
+
} else {
|
|
453
|
+
// A pushed segment always settles before finish() resolves, so this only fires if
|
|
454
|
+
// an unexpected throw slipped past the per-segment catch. Keep the raw text.
|
|
455
|
+
segments.push({
|
|
456
|
+
index,
|
|
457
|
+
status: "fallback",
|
|
458
|
+
text: raws.get(index) ?? "",
|
|
459
|
+
reason: "missing-outcome",
|
|
460
|
+
retried: false,
|
|
461
|
+
latencyMs: 0,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return {
|
|
466
|
+
text: joinSegments(segments.map((segment) => segment.text)),
|
|
467
|
+
segments,
|
|
468
|
+
polished: segments.filter((segment) => segment.status === "applied").length,
|
|
469
|
+
failed: segments.filter((segment) => segment.status === "fallback").length,
|
|
470
|
+
retried: segments.filter((segment) => segment.retried).length,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Every segment raw, order kept: the last-resort result if the builder itself faults. */
|
|
475
|
+
function rawOnlyResult(reason: string): QueueResult {
|
|
476
|
+
const segments: SegmentOutcome[] = orderedIndexes().map((index) => ({
|
|
477
|
+
index,
|
|
478
|
+
status: "fallback" as const,
|
|
479
|
+
text: raws.get(index) ?? "",
|
|
480
|
+
reason,
|
|
481
|
+
retried: false,
|
|
482
|
+
latencyMs: 0,
|
|
483
|
+
}));
|
|
484
|
+
return {
|
|
485
|
+
text: joinSegments(segments.map((segment) => segment.text)),
|
|
486
|
+
segments,
|
|
487
|
+
polished: 0,
|
|
488
|
+
failed: segments.length,
|
|
489
|
+
retried: 0,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
push(index: number, raw: string): void {
|
|
495
|
+
// finish() is the barrier for one dictation: anything offered afterwards belongs to
|
|
496
|
+
// a pass that is already being written, so it can only be dropped.
|
|
497
|
+
if (finished) return;
|
|
498
|
+
// Silence contributes nothing, exactly as today's segment concatenation skips it.
|
|
499
|
+
if (!raw.trim()) return;
|
|
500
|
+
// One segment per index: a repeated push would duplicate the text in the join.
|
|
501
|
+
if (raws.has(index)) return;
|
|
502
|
+
raws.set(index, raw);
|
|
503
|
+
running += 1;
|
|
504
|
+
const job: SegmentJob = { index, raw };
|
|
505
|
+
// Every pushed segment starts here; the slot pool, not this call site, bounds requests.
|
|
506
|
+
void runSegment(job).then(
|
|
507
|
+
(outcome) => settle(job, outcome),
|
|
508
|
+
(error: unknown) => settle(job, unexpectedOutcome(job, error, 0))
|
|
509
|
+
);
|
|
510
|
+
},
|
|
511
|
+
|
|
512
|
+
finish(): Promise<QueueResult> {
|
|
513
|
+
if (!finished) {
|
|
514
|
+
finished = new Promise<QueueResult>((resolve) => {
|
|
515
|
+
// The result builder is the last place a fault could cost the dictation its
|
|
516
|
+
// text, so a throw still resolves with the ordered raw transcript.
|
|
517
|
+
resolveIdle = () => {
|
|
518
|
+
try {
|
|
519
|
+
resolve(buildResult());
|
|
520
|
+
} catch {
|
|
521
|
+
resolve(rawOnlyResult("result-error"));
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
});
|
|
525
|
+
// Nothing pushed: resolve in this tick instead of waiting for a segment that may
|
|
526
|
+
// never come.
|
|
527
|
+
resolveIdleNow();
|
|
528
|
+
}
|
|
529
|
+
return finished;
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
}
|
|
@@ -142,7 +142,7 @@ export type EditorRead = string | typeof EDITOR_READ_FAILED;
|
|
|
142
142
|
* while the pass still owns the flow AND the editor still holds the value the pass
|
|
143
143
|
* snapshotted. Used by the normal path and by the pass's own throw path.
|
|
144
144
|
*/
|
|
145
|
-
export function decideApply(input: { tokenCurrent: boolean; editorSnapshot:
|
|
145
|
+
export function decideApply(input: { tokenCurrent: boolean; editorSnapshot: EditorRead; currentEditor: EditorRead }): {
|
|
146
146
|
apply: boolean;
|
|
147
147
|
reason?: string;
|
|
148
148
|
} {
|
|
@@ -180,6 +180,12 @@ export async function polishTranscript(input: PolishInput): Promise<PolishResult
|
|
|
180
180
|
const context = assembleContext(input.entries, input.limits);
|
|
181
181
|
const shape = { contextChars: context.characters, truncatedContext: context.truncated };
|
|
182
182
|
const request = buildPolishRequest(context, input.raw, input.timestamp);
|
|
183
|
+
// Invalidation stops scheduling, not merely the write: a cancelled or superseded pass must
|
|
184
|
+
// not send transcript text to the provider at all, so the check in front of the call is as
|
|
185
|
+
// important as the one after it.
|
|
186
|
+
if (input.isCurrent && !input.isCurrent()) {
|
|
187
|
+
return { status: "skipped", text: input.raw, reason: "invalidated", ...shape };
|
|
188
|
+
}
|
|
183
189
|
const controller = new AbortController();
|
|
184
190
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
185
191
|
|
|
@@ -218,6 +224,17 @@ export async function polishTranscript(input: PolishInput): Promise<PolishResult
|
|
|
218
224
|
}
|
|
219
225
|
}
|
|
220
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Per-segment outcome of the pipelined pass, one summary per dictation. Absent for the
|
|
229
|
+
* single-call path, where there are no recogniser segments to report.
|
|
230
|
+
*/
|
|
231
|
+
export interface PolishAuditSegments {
|
|
232
|
+
count: number;
|
|
233
|
+
polished: number;
|
|
234
|
+
failed: number;
|
|
235
|
+
retried: number;
|
|
236
|
+
}
|
|
237
|
+
|
|
221
238
|
/**
|
|
222
239
|
* One durable record of what a pass did, written into the session file by the caller.
|
|
223
240
|
*
|
|
@@ -248,6 +265,14 @@ export interface PolishAudit {
|
|
|
248
265
|
durationSec?: number;
|
|
249
266
|
/** Which recogniser produced it, so results are never pooled across backends. */
|
|
250
267
|
backend?: string;
|
|
268
|
+
/**
|
|
269
|
+
* The local model that produced the text (e.g. "parakeet-v3"), so dictations can be
|
|
270
|
+
* grouped by recogniser within the local backend. Absent for cloud backends, which
|
|
271
|
+
* carry their own server-side model choice.
|
|
272
|
+
*/
|
|
273
|
+
recognizer?: string;
|
|
274
|
+
/** Per-segment outcome when the segmented queue produced this dictation. */
|
|
275
|
+
segments?: PolishAuditSegments;
|
|
251
276
|
/** True when the pass asked the model not to think. */
|
|
252
277
|
thinkingOff?: boolean;
|
|
253
278
|
/** The output-token cap the request carried, a cap and not a spend. */
|
|
@@ -265,6 +290,8 @@ export function buildPolishAudit(input: {
|
|
|
265
290
|
thinkingOff?: boolean;
|
|
266
291
|
durationSec?: number;
|
|
267
292
|
backend?: string;
|
|
293
|
+
recognizer?: string;
|
|
294
|
+
segments?: PolishAuditSegments;
|
|
268
295
|
maxTokens?: number;
|
|
269
296
|
telemetry?: {
|
|
270
297
|
model?: string;
|
|
@@ -290,6 +317,8 @@ export function buildPolishAudit(input: {
|
|
|
290
317
|
if (input.maxTokens !== undefined) audit.maxTokens = input.maxTokens;
|
|
291
318
|
if (input.durationSec !== undefined) audit.durationSec = input.durationSec;
|
|
292
319
|
if (input.backend !== undefined) audit.backend = input.backend;
|
|
320
|
+
if (input.recognizer !== undefined) audit.recognizer = input.recognizer;
|
|
321
|
+
if (input.segments !== undefined) audit.segments = input.segments;
|
|
293
322
|
const telemetry = input.telemetry;
|
|
294
323
|
if (telemetry) {
|
|
295
324
|
if (telemetry.model !== undefined) audit.model = telemetry.model;
|
|
@@ -302,31 +331,23 @@ export function buildPolishAudit(input: {
|
|
|
302
331
|
}
|
|
303
332
|
|
|
304
333
|
/**
|
|
305
|
-
* Extra request fields for the polish call, or nothing when the model has no thinking to turn
|
|
306
|
-
* off.
|
|
307
|
-
*
|
|
308
|
-
* Short transcripts keep thinking on: it is cheap there and the wording comes out better.
|
|
309
|
-
* Measured 2026-09-26 on the acceptance corpus, thinking on won exactly the samples this pass
|
|
310
|
-
* exists for — a self-correction merged for +1.71 CER with it on against 0 with it off, and two
|
|
311
|
-
* zh-en term samples +0.08/+0.10 against 0 — while a 161-character transcript spent only 66
|
|
312
|
-
* reasoning tokens in 0.47 s.
|
|
334
|
+
* Extra request fields for the polish call, or nothing when the model has no thinking to turn off.
|
|
313
335
|
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
* and
|
|
336
|
+
* Thinking is off for every polish call on a reasoning model. It buys quality on short samples
|
|
337
|
+
* (measured 2026-09-26: a self-correction merged for +1.71 CER with thinking on against 0 with it
|
|
338
|
+
* off, two zh-en term samples +0.08/+0.10) but it spends a budget nobody can predict: the same
|
|
339
|
+
* spend that truncated long transcripts also hit short ones - a 76-character dictation burned
|
|
340
|
+
* ~1,200 reasoning tokens against a 664-1,024 budget and came back truncated after 7.6 s, and
|
|
341
|
+
* every real-corpus failure recorded on 2026-09-26 was `stop-reason:length` on a transcript of
|
|
342
|
+
* under 200 characters. On the real corpus the measured correction gain was ~0 either way, so
|
|
343
|
+
* the pass keeps the wording it can improve and gives up the truncation class entirely.
|
|
319
344
|
*
|
|
320
345
|
* `samplingParams` is applied by OpenAI-compatible adapters only, and the `reasoning` gate keeps
|
|
321
346
|
* the field away from models with no thinking at all.
|
|
322
347
|
*/
|
|
323
|
-
export
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
model: { reasoning?: boolean } | undefined | null,
|
|
327
|
-
rawLength: number
|
|
328
|
-
): { samplingParams?: { reasoning_effort: string } } {
|
|
348
|
+
export function polishSamplingOptions(model: { reasoning?: boolean } | undefined | null): {
|
|
349
|
+
samplingParams?: { reasoning_effort: string };
|
|
350
|
+
} {
|
|
329
351
|
if (!model || model.reasoning !== true) return {};
|
|
330
|
-
if (Number.isFinite(rawLength) && rawLength <= THINKING_MAX_CHARS) return {};
|
|
331
352
|
return { samplingParams: { reasoning_effort: "none" } };
|
|
332
353
|
}
|