pi-voicekit 0.2.0 → 0.2.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/README.md +16 -0
- package/extensions/voice/post-process-prompt.ts +4 -1
- package/extensions/voice/post-process.ts +105 -0
- package/extensions/voice.ts +48 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -381,12 +381,28 @@ One measured behaviour is worth knowing: a model that thinks before it answers m
|
|
|
381
381
|
spoken operator into its symbol — `select star` comes back as `select *`. The information is
|
|
382
382
|
unchanged, there is no setting for it, and `/voice-polish off` is the way to keep the words verbatim.
|
|
383
383
|
|
|
384
|
+
A reasoning model used to spend its whole token budget thinking about a long dictation, so the
|
|
385
|
+
answer was truncated and the pass kept the raw transcript — which looked like polish quietly
|
|
386
|
+
doing nothing past roughly half a minute of speech. Dictations longer than 200 characters now
|
|
387
|
+
turn thinking off (the same 309-character input went from 10.2 s to 1.2 s with the same
|
|
388
|
+
punctuation), while shorter ones keep it, because there it costs almost nothing and corrects
|
|
389
|
+
terms and self-corrections better. The field only reaches OpenAI-compatible providers; one that
|
|
390
|
+
ignores it behaves exactly as before.
|
|
391
|
+
|
|
384
392
|
Assistant text can contain anything the conversation contained — file paths,
|
|
385
393
|
identifiers, values the agent echoed. The character limits bound how much is sent,
|
|
386
394
|
not how sensitive it is. With the local backend, nothing else leaves your machine,
|
|
387
395
|
and audio never does: recognition runs on this machine with no API key. Turn the
|
|
388
396
|
feature off with `/voice-polish off` or the Polish tab's Enabled row.
|
|
389
397
|
|
|
398
|
+
Every dictation also writes one `voice-polish` entry into the session file: the raw
|
|
399
|
+
transcript, what reached the editor and why the pass decided that. The model never sees
|
|
400
|
+
these entries — they are not part of the conversation context — so they are there for
|
|
401
|
+
analysis, and they do keep the raw text on disk for as long as the session file exists.
|
|
402
|
+
Each entry also records how the pass was configured: the transcript length on its own
|
|
403
|
+
(separate from any text already in the editor), whether thinking was turned off for it,
|
|
404
|
+
and the output-token cap it carried.
|
|
405
|
+
|
|
390
406
|
| Setting | Scope | Default | Notes |
|
|
391
407
|
| ------------------------- | ------------------ | ----------- | ------------------------------------------------------- |
|
|
392
408
|
| `postProcessEnabled` | global only | `true` | Master switch. A project `voice` block cannot flip it. |
|
|
@@ -69,7 +69,10 @@ If nothing in <TRANSCRIPT> can be cleaned, return it unchanged.
|
|
|
69
69
|
*/
|
|
70
70
|
export function polishMaxTokens(rawChars: number): number {
|
|
71
71
|
const bounded = Number.isFinite(rawChars) ? Math.max(1, Math.floor(rawChars)) : 1;
|
|
72
|
-
|
|
72
|
+
// The floor is 2048 because thinking is not bounded by the input length: a 76-character
|
|
73
|
+
// dictation spent about 1,200 reasoning tokens, so the earlier 1024 floor truncated it and
|
|
74
|
+
// dropped the pass back to the raw transcript after a 7.6 s wait.
|
|
75
|
+
return Math.min(4096, Math.max(2048, Math.ceil(bounded * 2) + 512));
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
function renderContext(context: AssembledContext): string {
|
|
@@ -217,3 +217,108 @@ export async function polishTranscript(input: PolishInput): Promise<PolishResult
|
|
|
217
217
|
if (timer) clearTimeout(timer);
|
|
218
218
|
}
|
|
219
219
|
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* One durable record of what a pass did, written into the session file by the caller.
|
|
223
|
+
*
|
|
224
|
+
* `pi.appendEntry` stores it as a CustomEntry, which never enters the model's context, so
|
|
225
|
+
* this records the raw text, what actually reached the editor and why a pass fell back
|
|
226
|
+
* without changing anything the model sees. The shape is versioned so that a later analysis
|
|
227
|
+
* can tell which fields mean what.
|
|
228
|
+
*/
|
|
229
|
+
export interface PolishAudit {
|
|
230
|
+
version: 1;
|
|
231
|
+
rawText: string;
|
|
232
|
+
writtenText?: string;
|
|
233
|
+
/** True when a rewrite reached the editor; false for a fallback, a discard or no write. */
|
|
234
|
+
applied: boolean;
|
|
235
|
+
status?: string;
|
|
236
|
+
disposition?: string;
|
|
237
|
+
reason?: string;
|
|
238
|
+
model?: string;
|
|
239
|
+
configured?: string;
|
|
240
|
+
latencyMs?: number;
|
|
241
|
+
contextChars?: number;
|
|
242
|
+
truncated?: boolean;
|
|
243
|
+
/** Characters of the dictation itself, excluding text already in the editor. */
|
|
244
|
+
transcriptChars?: number;
|
|
245
|
+
/** Characters of the editor prefix, which is not part of the transcript. */
|
|
246
|
+
editorPrefixChars?: number;
|
|
247
|
+
/** True when the pass asked the model not to think. */
|
|
248
|
+
thinkingOff?: boolean;
|
|
249
|
+
/** The output-token cap the request carried, a cap and not a spend. */
|
|
250
|
+
maxTokens?: number;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function buildPolishAudit(input: {
|
|
254
|
+
raw: string;
|
|
255
|
+
written?: string;
|
|
256
|
+
status?: string;
|
|
257
|
+
disposition?: string;
|
|
258
|
+
reason?: string;
|
|
259
|
+
transcriptChars?: number;
|
|
260
|
+
editorPrefixChars?: number;
|
|
261
|
+
thinkingOff?: boolean;
|
|
262
|
+
maxTokens?: number;
|
|
263
|
+
telemetry?: {
|
|
264
|
+
model?: string;
|
|
265
|
+
configured?: string;
|
|
266
|
+
ms?: number;
|
|
267
|
+
contextChars?: number;
|
|
268
|
+
truncated?: boolean;
|
|
269
|
+
};
|
|
270
|
+
}): PolishAudit {
|
|
271
|
+
const audit: PolishAudit = {
|
|
272
|
+
version: 1,
|
|
273
|
+
rawText: input.raw,
|
|
274
|
+
// A write that equals the raw text is still a write, but it did not change anything.
|
|
275
|
+
applied: input.written !== undefined && input.written !== input.raw,
|
|
276
|
+
};
|
|
277
|
+
if (input.written !== undefined) audit.writtenText = input.written;
|
|
278
|
+
if (input.status !== undefined) audit.status = input.status;
|
|
279
|
+
if (input.disposition !== undefined) audit.disposition = input.disposition;
|
|
280
|
+
if (input.reason !== undefined) audit.reason = input.reason;
|
|
281
|
+
if (input.transcriptChars !== undefined) audit.transcriptChars = input.transcriptChars;
|
|
282
|
+
if (input.editorPrefixChars !== undefined) audit.editorPrefixChars = input.editorPrefixChars;
|
|
283
|
+
if (input.thinkingOff !== undefined) audit.thinkingOff = input.thinkingOff;
|
|
284
|
+
if (input.maxTokens !== undefined) audit.maxTokens = input.maxTokens;
|
|
285
|
+
const telemetry = input.telemetry;
|
|
286
|
+
if (telemetry) {
|
|
287
|
+
if (telemetry.model !== undefined) audit.model = telemetry.model;
|
|
288
|
+
if (telemetry.configured !== undefined) audit.configured = telemetry.configured;
|
|
289
|
+
if (telemetry.ms !== undefined) audit.latencyMs = telemetry.ms;
|
|
290
|
+
if (telemetry.contextChars !== undefined) audit.contextChars = telemetry.contextChars;
|
|
291
|
+
if (telemetry.truncated !== undefined) audit.truncated = telemetry.truncated;
|
|
292
|
+
}
|
|
293
|
+
return audit;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Extra request fields for the polish call, or nothing when the model has no thinking to turn
|
|
298
|
+
* off.
|
|
299
|
+
*
|
|
300
|
+
* Short transcripts keep thinking on: it is cheap there and the wording comes out better.
|
|
301
|
+
* Measured 2026-09-26 on the acceptance corpus, thinking on won exactly the samples this pass
|
|
302
|
+
* exists for — a self-correction merged for +1.71 CER with it on against 0 with it off, and two
|
|
303
|
+
* zh-en term samples +0.08/+0.10 against 0 — while a 161-character transcript spent only 66
|
|
304
|
+
* reasoning tokens in 0.47 s.
|
|
305
|
+
*
|
|
306
|
+
* Long transcripts turn it off: there thinking grows far past the token budget (309 characters
|
|
307
|
+
* needed ~1700 reasoning tokens, 471 characters ~2800-4400, against a budget of 1130-1454), so the
|
|
308
|
+
* answer was truncated and the pass fell back to the raw transcript — intermittently, which is
|
|
309
|
+
* what made a long dictation look unpolished. With thinking off the same input finished in ~1.2 s
|
|
310
|
+
* and spent no reasoning tokens at all.
|
|
311
|
+
*
|
|
312
|
+
* `samplingParams` is applied by OpenAI-compatible adapters only, and the `reasoning` gate keeps
|
|
313
|
+
* the field away from models with no thinking at all.
|
|
314
|
+
*/
|
|
315
|
+
export const THINKING_MAX_CHARS = 200;
|
|
316
|
+
|
|
317
|
+
export function polishSamplingOptions(
|
|
318
|
+
model: { reasoning?: boolean } | undefined | null,
|
|
319
|
+
rawLength: number
|
|
320
|
+
): { samplingParams?: { reasoning_effort: string } } {
|
|
321
|
+
if (!model || model.reasoning !== true) return {};
|
|
322
|
+
if (Number.isFinite(rawLength) && rawLength <= THINKING_MAX_CHARS) return {};
|
|
323
|
+
return { samplingParams: { reasoning_effort: "none" } };
|
|
324
|
+
}
|
package/extensions/voice.ts
CHANGED
|
@@ -103,6 +103,8 @@ import { shouldArmReleaseDetectOnRepeat, decideRecordingStartTimer } from "./voi
|
|
|
103
103
|
import { GapTimer, type TimerPort } from "./voice/release-controller";
|
|
104
104
|
import { audioToolOrder, type AudioToolName } from "./voice/audio-tool";
|
|
105
105
|
import {
|
|
106
|
+
buildPolishAudit,
|
|
107
|
+
polishSamplingOptions,
|
|
106
108
|
decideApply,
|
|
107
109
|
finalizePolishDisposition,
|
|
108
110
|
EDITOR_READ_FAILED,
|
|
@@ -112,6 +114,7 @@ import {
|
|
|
112
114
|
resolveModelChoice,
|
|
113
115
|
} from "./voice/post-process";
|
|
114
116
|
import { DEFAULT_CONTEXT_LIMITS } from "./voice/post-process-context";
|
|
117
|
+
import { polishMaxTokens } from "./voice/post-process-prompt";
|
|
115
118
|
|
|
116
119
|
/** Adapter for the real event loop — lets GapTimer run under the real setTimeout. */
|
|
117
120
|
const realTimerPort: TimerPort = {
|
|
@@ -881,6 +884,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
881
884
|
ms: number;
|
|
882
885
|
contextChars?: number;
|
|
883
886
|
truncated?: boolean;
|
|
887
|
+
/** Recorded so a slow or truncated pass can be diagnosed without reading code. */
|
|
888
|
+
thinkingOff?: boolean;
|
|
889
|
+
maxTokens?: number;
|
|
884
890
|
reason?: string;
|
|
885
891
|
error?: string;
|
|
886
892
|
};
|
|
@@ -971,13 +977,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
971
977
|
ctx!.modelRegistry.complete(
|
|
972
978
|
model as never,
|
|
973
979
|
{ systemPrompt: request.systemPrompt, messages: request.messages as never },
|
|
974
|
-
{
|
|
980
|
+
{
|
|
981
|
+
signal,
|
|
982
|
+
maxTokens: request.maxTokens,
|
|
983
|
+
// Measured 2026-09-26: without this a reasoning model spends the whole budget thinking
|
|
984
|
+
// about a long dictation and the pass falls back to the raw text — see
|
|
985
|
+
// polishSamplingOptions.
|
|
986
|
+
...polishSamplingOptions(model as { reasoning?: boolean }, raw.length),
|
|
987
|
+
}
|
|
975
988
|
),
|
|
976
989
|
debug: (reason, data) => voiceDebug(`polish ${reason}`, data),
|
|
977
990
|
});
|
|
978
991
|
const telemetry = {
|
|
979
992
|
model: polishModelLabel(choice),
|
|
980
993
|
configured: choice.ref,
|
|
994
|
+
// Pure and cheap, so computing it twice (here and in the call options) is fine, and it
|
|
995
|
+
// keeps the audit entry honest about what the pass decided.
|
|
996
|
+
thinkingOff: Boolean(polishSamplingOptions(model as { reasoning?: boolean }, raw.length).samplingParams),
|
|
997
|
+
maxTokens: polishMaxTokens(raw.length),
|
|
981
998
|
status: result.status,
|
|
982
999
|
ms: Date.now() - started,
|
|
983
1000
|
contextChars: result.contextChars,
|
|
@@ -1787,17 +1804,42 @@ export default function (pi: ExtensionAPI) {
|
|
|
1787
1804
|
if (polishOutcome !== undefined) {
|
|
1788
1805
|
const final = finalizePolishDisposition(polishOutcome, wroteEditor, editorWriteFailed);
|
|
1789
1806
|
polishOutcome = final.status;
|
|
1807
|
+
// One reason string for the debug log and the audit entry.
|
|
1808
|
+
const reason = editorWriteFailed
|
|
1809
|
+
? "editor-write-failed"
|
|
1810
|
+
: !wroteEditor && !skipWrite
|
|
1811
|
+
? "editor-write-skipped"
|
|
1812
|
+
: polishTelemetry?.reason;
|
|
1790
1813
|
if (polishTelemetry) {
|
|
1791
1814
|
voiceDebug("polish result", {
|
|
1792
1815
|
...polishTelemetry,
|
|
1793
1816
|
disposition: final.disposition,
|
|
1794
|
-
reason
|
|
1795
|
-
? "editor-write-failed"
|
|
1796
|
-
: !wroteEditor && !skipWrite
|
|
1797
|
-
? "editor-write-skipped"
|
|
1798
|
-
: polishTelemetry.reason,
|
|
1817
|
+
reason,
|
|
1799
1818
|
});
|
|
1800
1819
|
}
|
|
1820
|
+
// Durable audit record: one CustomEntry per dictation, carrying the raw text, what
|
|
1821
|
+
// actually reached the editor and why the pass did what it did. A CustomEntry never
|
|
1822
|
+
// enters the model's context, so the pass stays analysable after the session ends
|
|
1823
|
+
// without changing what the model sees. A failure here must not affect the dictation.
|
|
1824
|
+
try {
|
|
1825
|
+
pi.appendEntry(
|
|
1826
|
+
"voice-polish",
|
|
1827
|
+
buildPolishAudit({
|
|
1828
|
+
raw: prefix + fullText,
|
|
1829
|
+
transcriptChars: fullText.length,
|
|
1830
|
+
editorPrefixChars: prefix.length,
|
|
1831
|
+
thinkingOff: polishTelemetry?.thinkingOff,
|
|
1832
|
+
maxTokens: polishTelemetry?.maxTokens,
|
|
1833
|
+
written: wroteEditor ? finalText : undefined,
|
|
1834
|
+
status: final.status,
|
|
1835
|
+
disposition: final.disposition,
|
|
1836
|
+
reason,
|
|
1837
|
+
telemetry: polishTelemetry,
|
|
1838
|
+
})
|
|
1839
|
+
);
|
|
1840
|
+
} catch (err) {
|
|
1841
|
+
voiceDebug("polish audit entry failed", { error: String(err) });
|
|
1842
|
+
}
|
|
1801
1843
|
}
|
|
1802
1844
|
|
|
1803
1845
|
// v7.1.1 — auto-submit on STT (config.autoSubmitOnSpeak).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-voicekit",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Voice in + voice out for Pi CLI — hold-to-talk STT (Deepgram streaming or 21 offline models) plus TTS (Kitten Nano, Piper, Kokoro, or Deepgram Aura)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|