pi-voicekit 0.2.0 → 0.2.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 CHANGED
@@ -381,12 +381,25 @@ 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
+
390
403
  | Setting | Scope | Default | Notes |
391
404
  | ------------------------- | ------------------ | ----------- | ------------------------------------------------------- |
392
405
  | `postProcessEnabled` | global only | `true` | Master switch. A project `voice` block cannot flip it. |
@@ -217,3 +217,92 @@ 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
+ }
244
+
245
+ export function buildPolishAudit(input: {
246
+ raw: string;
247
+ written?: string;
248
+ status?: string;
249
+ disposition?: string;
250
+ reason?: string;
251
+ telemetry?: {
252
+ model?: string;
253
+ configured?: string;
254
+ ms?: number;
255
+ contextChars?: number;
256
+ truncated?: boolean;
257
+ };
258
+ }): PolishAudit {
259
+ const audit: PolishAudit = {
260
+ version: 1,
261
+ rawText: input.raw,
262
+ // A write that equals the raw text is still a write, but it did not change anything.
263
+ applied: input.written !== undefined && input.written !== input.raw,
264
+ };
265
+ if (input.written !== undefined) audit.writtenText = input.written;
266
+ if (input.status !== undefined) audit.status = input.status;
267
+ if (input.disposition !== undefined) audit.disposition = input.disposition;
268
+ if (input.reason !== undefined) audit.reason = input.reason;
269
+ const telemetry = input.telemetry;
270
+ if (telemetry) {
271
+ if (telemetry.model !== undefined) audit.model = telemetry.model;
272
+ if (telemetry.configured !== undefined) audit.configured = telemetry.configured;
273
+ if (telemetry.ms !== undefined) audit.latencyMs = telemetry.ms;
274
+ if (telemetry.contextChars !== undefined) audit.contextChars = telemetry.contextChars;
275
+ if (telemetry.truncated !== undefined) audit.truncated = telemetry.truncated;
276
+ }
277
+ return audit;
278
+ }
279
+
280
+ /**
281
+ * Extra request fields for the polish call, or nothing when the model has no thinking to turn
282
+ * off.
283
+ *
284
+ * Short transcripts keep thinking on: it is cheap there and the wording comes out better.
285
+ * Measured 2026-09-26 on the acceptance corpus, thinking on won exactly the samples this pass
286
+ * exists for — a self-correction merged for +1.71 CER with it on against 0 with it off, and two
287
+ * zh-en term samples +0.08/+0.10 against 0 — while a 161-character transcript spent only 66
288
+ * reasoning tokens in 0.47 s.
289
+ *
290
+ * Long transcripts turn it off: there thinking grows far past the token budget (309 characters
291
+ * needed ~1700 reasoning tokens, 471 characters ~2800-4400, against a budget of 1130-1454), so the
292
+ * answer was truncated and the pass fell back to the raw transcript — intermittently, which is
293
+ * what made a long dictation look unpolished. With thinking off the same input finished in ~1.2 s
294
+ * and spent no reasoning tokens at all.
295
+ *
296
+ * `samplingParams` is applied by OpenAI-compatible adapters only, and the `reasoning` gate keeps
297
+ * the field away from models with no thinking at all.
298
+ */
299
+ export const THINKING_MAX_CHARS = 200;
300
+
301
+ export function polishSamplingOptions(
302
+ model: { reasoning?: boolean } | undefined | null,
303
+ rawLength: number
304
+ ): { samplingParams?: { reasoning_effort: string } } {
305
+ if (!model || model.reasoning !== true) return {};
306
+ if (Number.isFinite(rawLength) && rawLength <= THINKING_MAX_CHARS) return {};
307
+ return { samplingParams: { reasoning_effort: "none" } };
308
+ }
@@ -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,
@@ -971,7 +973,14 @@ export default function (pi: ExtensionAPI) {
971
973
  ctx!.modelRegistry.complete(
972
974
  model as never,
973
975
  { systemPrompt: request.systemPrompt, messages: request.messages as never },
974
- { signal, maxTokens: request.maxTokens }
976
+ {
977
+ signal,
978
+ maxTokens: request.maxTokens,
979
+ // Measured 2026-09-26: without this a reasoning model spends the whole budget thinking
980
+ // about a long dictation and the pass falls back to the raw text — see
981
+ // polishSamplingOptions.
982
+ ...polishSamplingOptions(model as { reasoning?: boolean }, raw.length),
983
+ }
975
984
  ),
976
985
  debug: (reason, data) => voiceDebug(`polish ${reason}`, data),
977
986
  });
@@ -1787,17 +1796,38 @@ export default function (pi: ExtensionAPI) {
1787
1796
  if (polishOutcome !== undefined) {
1788
1797
  const final = finalizePolishDisposition(polishOutcome, wroteEditor, editorWriteFailed);
1789
1798
  polishOutcome = final.status;
1799
+ // One reason string for the debug log and the audit entry.
1800
+ const reason = editorWriteFailed
1801
+ ? "editor-write-failed"
1802
+ : !wroteEditor && !skipWrite
1803
+ ? "editor-write-skipped"
1804
+ : polishTelemetry?.reason;
1790
1805
  if (polishTelemetry) {
1791
1806
  voiceDebug("polish result", {
1792
1807
  ...polishTelemetry,
1793
1808
  disposition: final.disposition,
1794
- reason: editorWriteFailed
1795
- ? "editor-write-failed"
1796
- : !wroteEditor && !skipWrite
1797
- ? "editor-write-skipped"
1798
- : polishTelemetry.reason,
1809
+ reason,
1799
1810
  });
1800
1811
  }
1812
+ // Durable audit record: one CustomEntry per dictation, carrying the raw text, what
1813
+ // actually reached the editor and why the pass did what it did. A CustomEntry never
1814
+ // enters the model's context, so the pass stays analysable after the session ends
1815
+ // without changing what the model sees. A failure here must not affect the dictation.
1816
+ try {
1817
+ pi.appendEntry(
1818
+ "voice-polish",
1819
+ buildPolishAudit({
1820
+ raw: prefix + fullText,
1821
+ written: wroteEditor ? finalText : undefined,
1822
+ status: final.status,
1823
+ disposition: final.disposition,
1824
+ reason,
1825
+ telemetry: polishTelemetry,
1826
+ })
1827
+ );
1828
+ } catch (err) {
1829
+ voiceDebug("polish audit entry failed", { error: String(err) });
1830
+ }
1801
1831
  }
1802
1832
 
1803
1833
  // 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.0",
3
+ "version": "0.2.1",
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": [