@tychenjiajun/pi-voice 0.8.0 → 0.9.0
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 +39 -2
- package/assets/demo.gif +0 -0
- package/assets/demo.mp4 +0 -0
- package/assets/preview.png +0 -0
- package/assets/preview.svg +47 -0
- package/assets/social-og.png +0 -0
- package/assets/social.svg +37 -0
- package/package.json +16 -7
- package/src/default-shortcut.ts +45 -0
- package/src/file-transcription.ts +6 -7
- package/src/index.ts +7 -0
- package/src/onboarding.ts +8 -5
- package/src/polish.ts +27 -5
- package/src/ready-handoff.ts +15 -0
- package/src/runtime.ts +51 -18
- package/src/settings-menu.ts +112 -0
- package/src/settings.ts +29 -5
- package/src/shortcut-core.ts +0 -2
- package/src/shortcuts.ts +6 -8
- package/src/startup-shortcut.ts +6 -5
package/README.md
CHANGED
|
@@ -4,6 +4,14 @@ Speech-to-text for Pi — on-device model or OpenAI-compatible remote ASR, with
|
|
|
4
4
|
|
|
5
5
|
Press a keyboard shortcut, talk, and the resulting transcript will be put directly into your chat.
|
|
6
6
|
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
[](https://pi.dev/docs/latest/packages) [](https://www.npmjs.com/package/@tychenjiajun/pi-voice) [](LICENSE)
|
|
12
|
+
|
|
13
|
+
Browse more Pi packages in the [pi.dev packages gallery](https://pi.dev/packages/@tychenjiajun/pi-voice) and read the [Pi packages documentation](https://pi.dev/docs/latest/packages).
|
|
14
|
+
|
|
7
15
|
> **Fork notice.** `@tychenjiajun/pi-voice` is a fork of [`@earendil-works/pi-voice`](https://github.com/earendil-works/pi-voice) that adds a remote ASR backend (any OpenAI-compatible `input_audio` chat-completions endpoint) in an exclusive local-only or remote-only setup, plus compaction-style post-processing that refines a transcript with Pi's own model. It is published independently under a personal npm scope. See the [changelog](#changelog) for what changed.
|
|
8
16
|
|
|
9
17
|
## Install
|
|
@@ -24,10 +32,10 @@ pi install ssh://git@github.com/tychenjiajun/pi-voice
|
|
|
24
32
|
|
|
25
33
|
The extension registers:
|
|
26
34
|
|
|
27
|
-
- a configurable terminal shortcut (`Ctrl+
|
|
35
|
+
- a configurable terminal shortcut (`Ctrl+Space` by default) to start and stop recording;
|
|
28
36
|
- `/voice`, which does the same as the shortcut. While recording, `Space` also stops and transcribes, and `Escape` discards;
|
|
29
37
|
- a `transcribe_file` tool that the agent can use to transcribe local audio or video files;
|
|
30
|
-
- `/voice-settings` for preferred languages, model, transcription backend, transcription language, post-processing, microphone, and shortcut settings.
|
|
38
|
+
- `/voice-settings` for preferred languages, model, transcription backend, transcription language, post-processing (model and prompt), microphone, and shortcut settings.
|
|
31
39
|
|
|
32
40
|
`/transcribe` remains available as a compatibility alias for `/voice-settings`.
|
|
33
41
|
|
|
@@ -66,6 +74,8 @@ Set it up in `/voice-settings` → **Post-processing** → **On**. You then choo
|
|
|
66
74
|
- **Pi's current model** (default) — whichever model the agent is using, exactly like compaction;
|
|
67
75
|
- **a different model** — any model from Pi's registry, using that provider's configured authentication. The picker is searchable: type a keyword (a model name or part of `provider/id`) to filter the list, `Enter` selects the highlighted match, and `Esc` clears the search.
|
|
68
76
|
|
|
77
|
+
**Post-processing prompt** (the row below it) controls the instructions the model follows. **Built-in default** strips fillers and false starts, fixes punctuation and casing, and keeps the language, names, numbers, and technical terms exactly. **Custom prompt** opens a multi-line editor prefilled with the built-in wording, so you can adjust it instead of retyping it — for example to keep the transcript verbatim and only add punctuation, translate, or reformat as Markdown. Clearing the editor, or picking **Built-in default**, restores the built-in prompt; the row summary shows `Built-in default` or `Custom (n chars)`. The custom prompt replaces the built-in system prompt entirely, so keep the "output only the refined text" instruction if you want it.
|
|
78
|
+
|
|
69
79
|
Pi Voice makes a small nested request through Pi's model registry, so it automatically reuses Pi's model definitions and API keys (or OAuth). No base URL, key, or timeout is stored in Pi Voice settings.
|
|
70
80
|
|
|
71
81
|
Behavior:
|
|
@@ -74,6 +84,7 @@ Behavior:
|
|
|
74
84
|
- **Reasoning-safe.** A provider's thinking is ignored (Pi reports it as `thinking` blocks), and inline reasoning tags a model may embed in its text are stripped. If only reasoning comes back, the raw transcript is kept.
|
|
75
85
|
- **Discrete, not streaming.** Refinement happens after the full transcript is produced, so there is no live preview of the polished text.
|
|
76
86
|
- **Applies to all transcription.** Microphone dictation and the `transcribe_file` tool both go through the same post-processing step when it is enabled.
|
|
87
|
+
- **Custom prompt is optional and stored in settings.** `polishPrompt` in `settings.json` holds the custom instructions; absent means the built-in prompt. The prompt is sent as the system prompt of the refinement request, and an empty or whitespace-only value falls back to the built-in one.
|
|
77
88
|
|
|
78
89
|
## Upgrading from pi-transcribe
|
|
79
90
|
|
|
@@ -114,8 +125,34 @@ The legacy `PI_TRANSCRIBE_FFMPEG_PATH` variable remains supported when `PI_VOICE
|
|
|
114
125
|
|
|
115
126
|
When FFmpeg is unavailable, `transcribe_file` reports platform-specific guidance to the agent. The agent should ask before running a package-manager command. Model setup is still explicit: run `/voice-settings` once in the interactive TUI to choose a mode and, in local mode, confirm the download of a local model.
|
|
116
127
|
|
|
128
|
+
## FAQ
|
|
129
|
+
|
|
130
|
+
### What models does it support?
|
|
131
|
+
|
|
132
|
+
Local mode downloads GGUF ASR models from a built-in catalog — Whisper (e.g. `whisper-medium`), Parakeet, Canary, Nemotron, Qwen3-ASR and more, picked via a fuzzy-searchable `/voice-settings` model picker. Remote mode works with **any** endpoint that speaks the OpenAI chat-completions `input_audio` dialect (OpenAI, Groq, self-hosted servers, …).
|
|
133
|
+
|
|
134
|
+
### Does audio leave my machine?
|
|
135
|
+
|
|
136
|
+
Not unless you configure remote mode. With a local model, recording and transcription happen entirely on-device. If you switch **Transcription backend** to **Remote ASR**, the 16 kHz mono WAV clip is sent to *your* endpoint — the API key is read from an env var you name and is never stored in settings.
|
|
137
|
+
|
|
138
|
+
### How is this different from `@earendil-works/pi-voice`?
|
|
139
|
+
|
|
140
|
+
This fork adds a remote ASR backend (exclusive local-only or remote-only setup) and compaction-style post-processing that refines transcripts with Pi's own model — stripping filler words, fixing punctuation and capitalisation. See the [changelog](#changelog).
|
|
141
|
+
|
|
142
|
+
### Do I need FFmpeg?
|
|
143
|
+
|
|
144
|
+
Only for the `transcribe_file` tool (transcribing existing audio/video files). Microphone dictation works without it. Install hints for macOS, Debian/Ubuntu and Windows are in [File transcription and FFmpeg](#file-transcription-and-ffmpeg).
|
|
145
|
+
|
|
117
146
|
## Changelog
|
|
118
147
|
|
|
148
|
+
### 0.9.0
|
|
149
|
+
|
|
150
|
+
**Added — Configurable post-processing prompt**
|
|
151
|
+
|
|
152
|
+
- `/voice-settings` → **Post-processing prompt** (shown while **Post-processing** is On) opens a picker with **Built-in default** or **Custom prompt**; the row summary reads `Built-in default` or `Custom (n chars)`.
|
|
153
|
+
- **Custom prompt** opens a multi-line editor prefilled with the built-in wording, so the default can be adjusted instead of retyped — for example to keep transcripts verbatim, translate, or reformat as Markdown. Clearing the editor, or picking **Built-in default**, restores the built-in prompt.
|
|
154
|
+
- The custom instructions are stored as `polishPrompt` in `settings.json` and sent as the system prompt of the refinement request. They replace the built-in prompt entirely, including its rule that the transcript is text to edit and never instructions to follow; an absent, blank, or over-20 000-character value means the built-in prompt is used.
|
|
155
|
+
|
|
119
156
|
### 0.8.0
|
|
120
157
|
|
|
121
158
|
**Added — Upstream sync: voice activity detection, long-audio chunking, and language detection**
|
package/assets/demo.gif
ADDED
|
Binary file
|
package/assets/demo.mp4
ADDED
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000" viewBox="0 0 1600 1000" role="img" aria-label="@tychenjiajun/pi-voice promo card">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
|
|
4
|
+
<stop offset="0" stop-color="#7c3aed"/>
|
|
5
|
+
<stop offset="1" stop-color="#a78bfa"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<radialGradient id="glow" cx="0.5" cy="1" r="0.9">
|
|
8
|
+
<stop offset="0" stop-color="#7c3aed" stop-opacity="0.30"/>
|
|
9
|
+
<stop offset="0.6" stop-color="#7c3aed" stop-opacity="0.10"/>
|
|
10
|
+
<stop offset="1" stop-color="#7c3aed" stop-opacity="0"/>
|
|
11
|
+
</radialGradient>
|
|
12
|
+
<pattern id="grid" width="50" height="50" patternUnits="userSpaceOnUse">
|
|
13
|
+
<path d="M50 0H0v50" fill="none" stroke="#ffffff" stroke-opacity="0.05" stroke-width="1"/>
|
|
14
|
+
</pattern>
|
|
15
|
+
</defs>
|
|
16
|
+
|
|
17
|
+
<rect width="1600" height="1000" fill="#0d0d12"/>
|
|
18
|
+
<rect width="1600" height="1000" fill="url(#grid)"/>
|
|
19
|
+
<rect width="1600" height="1000" fill="url(#glow)"/>
|
|
20
|
+
|
|
21
|
+
<!-- corner labels -->
|
|
22
|
+
<rect x="140" y="120" rx="8" width="300" height="56" fill="#7c3aed" fill-opacity="0.18" stroke="#7c3aed"/>
|
|
23
|
+
<text x="290" y="158" text-anchor="middle" font-family="ui-monospace, Menlo, monospace" font-size="30" fill="#c4b5fd">pi-extension</text>
|
|
24
|
+
<text x="1460" y="158" text-anchor="end" font-family="ui-monospace, Menlo, monospace" font-size="30" fill="#8b8b9e">pi.dev/packages</text>
|
|
25
|
+
|
|
26
|
+
<!-- mic glyph -->
|
|
27
|
+
<g transform="translate(800 268)">
|
|
28
|
+
<circle cx="0" cy="0" r="64" fill="#7c3aed" fill-opacity="0.18" stroke="#7c3aed" stroke-width="4"/>
|
|
29
|
+
<rect x="-16" y="-44" width="32" height="56" rx="16" fill="#a78bfa"/>
|
|
30
|
+
<path d="M-32 -2 a32 32 0 0 0 64 0" fill="none" stroke="#a78bfa" stroke-width="6" stroke-linecap="round"/>
|
|
31
|
+
<line x1="0" y1="30" x2="0" y2="52" stroke="#a78bfa" stroke-width="6" stroke-linecap="round"/>
|
|
32
|
+
</g>
|
|
33
|
+
|
|
34
|
+
<!-- name -->
|
|
35
|
+
<text x="800" y="480" text-anchor="middle" font-family="ui-monospace, Menlo, monospace" font-size="132" font-weight="700" fill="#ffffff">pi-voice</text>
|
|
36
|
+
<text x="800" y="540" text-anchor="middle" font-family="ui-monospace, Menlo, monospace" font-size="34" fill="#8b8b9e">@tychenjiajun</text>
|
|
37
|
+
|
|
38
|
+
<!-- tagline -->
|
|
39
|
+
<text x="800" y="620" text-anchor="middle" font-family="system-ui, sans-serif" font-size="44" fill="#c9c9d4">Speak — transcript lands in your chat</text>
|
|
40
|
+
|
|
41
|
+
<!-- install pill -->
|
|
42
|
+
<rect x="270" y="690" rx="14" width="1060" height="92" fill="#16161d" stroke="url(#accent)" stroke-width="3"/>
|
|
43
|
+
<text x="800" y="749" text-anchor="middle" font-family="ui-monospace, Menlo, monospace" font-size="40" fill="#a78bfa">$ pi install npm:@tychenjiajun/pi-voice</text>
|
|
44
|
+
|
|
45
|
+
<!-- accent bar -->
|
|
46
|
+
<rect x="440" y="860" width="720" height="10" rx="5" fill="url(#accent)"/>
|
|
47
|
+
</svg>
|
|
Binary file
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-label="@tychenjiajun/pi-voice social card">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
|
|
4
|
+
<stop offset="0" stop-color="#7c3aed"/>
|
|
5
|
+
<stop offset="1" stop-color="#a78bfa"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<radialGradient id="glow" cx="0.5" cy="1" r="0.9">
|
|
8
|
+
<stop offset="0" stop-color="#7c3aed" stop-opacity="0.30"/>
|
|
9
|
+
<stop offset="0.6" stop-color="#7c3aed" stop-opacity="0.10"/>
|
|
10
|
+
<stop offset="1" stop-color="#7c3aed" stop-opacity="0"/>
|
|
11
|
+
</radialGradient>
|
|
12
|
+
<pattern id="grid" width="50" height="50" patternUnits="userSpaceOnUse">
|
|
13
|
+
<path d="M50 0H0v50" fill="none" stroke="#ffffff" stroke-opacity="0.05" stroke-width="1"/>
|
|
14
|
+
</pattern>
|
|
15
|
+
</defs>
|
|
16
|
+
|
|
17
|
+
<rect width="1200" height="630" fill="#0d0d12"/>
|
|
18
|
+
<rect width="1200" height="630" fill="url(#grid)"/>
|
|
19
|
+
<rect width="1200" height="630" fill="url(#glow)"/>
|
|
20
|
+
|
|
21
|
+
<rect x="80" y="72" rx="8" width="260" height="50" fill="#7c3aed" fill-opacity="0.18" stroke="#7c3aed"/>
|
|
22
|
+
<text x="210" y="107" text-anchor="middle" font-family="ui-monospace, Menlo, monospace" font-size="27" fill="#c4b5fd">pi-extension</text>
|
|
23
|
+
|
|
24
|
+
<g transform="translate(600 218)">
|
|
25
|
+
<circle cx="0" cy="0" r="48" fill="#7c3aed" fill-opacity="0.18" stroke="#7c3aed" stroke-width="3"/>
|
|
26
|
+
<rect x="-12" y="-33" width="24" height="42" rx="12" fill="#a78bfa"/>
|
|
27
|
+
<path d="M-24 -2 a24 24 0 0 0 48 0" fill="none" stroke="#a78bfa" stroke-width="5" stroke-linecap="round"/>
|
|
28
|
+
<line x1="0" y1="22" x2="0" y2="39" stroke="#a78bfa" stroke-width="5" stroke-linecap="round"/>
|
|
29
|
+
</g>
|
|
30
|
+
|
|
31
|
+
<text x="600" y="360" text-anchor="middle" font-family="ui-monospace, Menlo, monospace" font-size="92" font-weight="700" fill="#ffffff">pi-voice</text>
|
|
32
|
+
<text x="600" y="406" text-anchor="middle" font-family="ui-monospace, Menlo, monospace" font-size="28" fill="#8b8b9e">@tychenjiajun/pi-voice</text>
|
|
33
|
+
<text x="600" y="462" text-anchor="middle" font-family="system-ui, sans-serif" font-size="36" fill="#c9c9d4">Speech-to-text for Pi — local model or remote ASR</text>
|
|
34
|
+
|
|
35
|
+
<rect x="216" y="510" rx="12" width="768" height="70" fill="#16161d" stroke="url(#accent)" stroke-width="3"/>
|
|
36
|
+
<text x="600" y="556" text-anchor="middle" font-family="ui-monospace, Menlo, monospace" font-size="29" fill="#a78bfa">$ pi install npm:@tychenjiajun/pi-voice</text>
|
|
37
|
+
</svg>
|
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tychenjiajun/pi-voice",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Speech-to-text for Pi (
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "Speech-to-text for the Pi coding agent: press a shortcut and dictate — transcripts land directly in your chat. Local models (Whisper, Parakeet) or any OpenAI-compatible remote ASR endpoint, with compaction-style post-processing that strips fillers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "git+https://github.com/tychenjiajun/pi-voice.git"
|
|
10
10
|
},
|
|
11
|
-
"homepage": "https://
|
|
11
|
+
"homepage": "https://pi.dev/packages/@tychenjiajun/pi-voice",
|
|
12
12
|
"bugs": {
|
|
13
13
|
"url": "https://github.com/tychenjiajun/pi-voice/issues"
|
|
14
14
|
},
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"catalog/recommendations.json",
|
|
20
20
|
"models/silero-vad-v6.2-F32.gguf",
|
|
21
21
|
"models/README.md",
|
|
22
|
+
"assets",
|
|
22
23
|
"README.md",
|
|
23
24
|
"LICENSE"
|
|
24
25
|
],
|
|
@@ -31,7 +32,13 @@
|
|
|
31
32
|
"voice",
|
|
32
33
|
"dictation",
|
|
33
34
|
"transcription",
|
|
34
|
-
"speech-to-text"
|
|
35
|
+
"speech-to-text",
|
|
36
|
+
"asr",
|
|
37
|
+
"whisper",
|
|
38
|
+
"openai-compatible",
|
|
39
|
+
"voice-input",
|
|
40
|
+
"voice-control",
|
|
41
|
+
"local-model"
|
|
35
42
|
],
|
|
36
43
|
"engines": {
|
|
37
44
|
"node": ">=22"
|
|
@@ -47,7 +54,9 @@
|
|
|
47
54
|
"pi": {
|
|
48
55
|
"extensions": [
|
|
49
56
|
"./index.ts"
|
|
50
|
-
]
|
|
57
|
+
],
|
|
58
|
+
"image": "https://raw.githubusercontent.com/tychenjiajun/pi-voice/remote-asr-backend/assets/preview.png",
|
|
59
|
+
"video": "https://raw.githubusercontent.com/tychenjiajun/pi-voice/remote-asr-backend/assets/demo.mp4"
|
|
51
60
|
},
|
|
52
61
|
"peerDependencies": {
|
|
53
62
|
"@earendil-works/pi-coding-agent": "*",
|
|
@@ -55,8 +64,8 @@
|
|
|
55
64
|
"typebox": "*"
|
|
56
65
|
},
|
|
57
66
|
"devDependencies": {
|
|
58
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
59
|
-
"@earendil-works/pi-tui": "^1.
|
|
67
|
+
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
68
|
+
"@earendil-works/pi-tui": "^1.4.0",
|
|
60
69
|
"@huggingface/gguf": "^0.4.6",
|
|
61
70
|
"@types/node": "22.20.1",
|
|
62
71
|
"typebox": "^1.3.7",
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/** The shortcut new users get when nothing on the system claims it. */
|
|
4
|
+
export const PREFERRED_SHORTCUT = "ctrl+space";
|
|
5
|
+
/** Used instead when Ctrl+Space would switch keyboard input sources. */
|
|
6
|
+
export const FALLBACK_SHORTCUT = "ctrl+alt+z";
|
|
7
|
+
|
|
8
|
+
const KEYBOARD_SOURCE =
|
|
9
|
+
/InputSourceKind = "(?:Keyboard Layout|Keyboard Input Method|Input Mode)"/g;
|
|
10
|
+
|
|
11
|
+
/** macOS only claims Ctrl+Space once there are two keyboard sources to switch between. */
|
|
12
|
+
export function hasMultipleInputSources(enabledSources: string): boolean {
|
|
13
|
+
return (enabledSources.match(KEYBOARD_SOURCE)?.length ?? 0) > 1;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function macCtrlSpaceSwitchesInput(): boolean {
|
|
17
|
+
try {
|
|
18
|
+
const enabledSources = execFileSync(
|
|
19
|
+
"/usr/bin/defaults",
|
|
20
|
+
["read", "com.apple.HIToolbox", "AppleEnabledInputSources"],
|
|
21
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 1000 },
|
|
22
|
+
);
|
|
23
|
+
return hasMultipleInputSources(enabledSources);
|
|
24
|
+
} catch {
|
|
25
|
+
// Unknown counts as taken; the fallback works everywhere.
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** fcitx toggles on Ctrl+Space by default; IBus and GNOME use Super+Space. */
|
|
31
|
+
function linuxCtrlSpaceSwitchesInput(env: NodeJS.ProcessEnv): boolean {
|
|
32
|
+
return [env.XMODIFIERS, env.GTK_IM_MODULE, env.QT_IM_MODULE].some((value) =>
|
|
33
|
+
value?.includes("fcitx"),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Only for users without saved settings, which always store a shortcut. */
|
|
38
|
+
export function defaultShortcut(
|
|
39
|
+
platform: NodeJS.Platform = process.platform,
|
|
40
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
41
|
+
): string {
|
|
42
|
+
if (platform === "darwin" && macCtrlSpaceSwitchesInput()) return FALLBACK_SHORTCUT;
|
|
43
|
+
if (platform === "linux" && linuxCtrlSpaceSwitchesInput(env)) return FALLBACK_SHORTCUT;
|
|
44
|
+
return PREFERRED_SHORTCUT;
|
|
45
|
+
}
|
|
@@ -162,13 +162,12 @@ export function registerFileTranscriptionTool(
|
|
|
162
162
|
operationSignal,
|
|
163
163
|
);
|
|
164
164
|
const transcript = configured.polishEnabled
|
|
165
|
-
? await polishTranscript(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
)
|
|
165
|
+
? await polishTranscript(ctx, rawTranscript, {
|
|
166
|
+
enabled: true,
|
|
167
|
+
modelRef: configured.polishModel,
|
|
168
|
+
prompt: configured.polishPrompt,
|
|
169
|
+
signal: operationSignal,
|
|
170
|
+
})
|
|
172
171
|
: rawTranscript;
|
|
173
172
|
const details: FileTranscriptionDetails = {
|
|
174
173
|
inputPath,
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
findLegacyGitInstall,
|
|
11
11
|
legacyGitMigrationMessage,
|
|
12
12
|
} from "./install-migration.js";
|
|
13
|
+
import { takeReadyRequest } from "./ready-handoff.js";
|
|
13
14
|
import type { PiVoiceRuntime } from "./runtime.js";
|
|
14
15
|
import { displayShortcut, STATUS_WIDGET_KEY } from "./shortcut-core.js";
|
|
15
16
|
import { initLog, log, logStep, markKeyPress, watchEventLoop } from "./log.js";
|
|
@@ -43,6 +44,12 @@ export default function piVoice(pi: ExtensionAPI): void {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
pi.on("session_start", async (_event, ctx) => {
|
|
47
|
+
// Setup that saved a new shortcut reloaded to bind it; finish it here.
|
|
48
|
+
if (takeReadyRequest()) {
|
|
49
|
+
await (await loadRuntime()).showReadyAfterReload(ctx);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
46
53
|
let showedMigrationNotice = false;
|
|
47
54
|
if (ctx.mode === "tui") {
|
|
48
55
|
const legacyInstall = findLegacyGitInstall(pi.getCommands());
|
package/src/onboarding.ts
CHANGED
|
@@ -30,7 +30,6 @@ import {
|
|
|
30
30
|
type TranscribeSettings,
|
|
31
31
|
type TranscriptionLanguage,
|
|
32
32
|
} from "./settings.js";
|
|
33
|
-
import { DEFAULT_SHORTCUT } from "./shortcut-core.js";
|
|
34
33
|
import {
|
|
35
34
|
SingleSelectPicker,
|
|
36
35
|
type SingleSelectChoice,
|
|
@@ -58,7 +57,7 @@ function createSettingsActivation(
|
|
|
58
57
|
}
|
|
59
58
|
|
|
60
59
|
type ModelSelectionOptions = {
|
|
61
|
-
shortcut
|
|
60
|
+
shortcut: string;
|
|
62
61
|
preferredLanguages?: readonly string[];
|
|
63
62
|
transcriptionLanguage?: TranscriptionLanguage;
|
|
64
63
|
chineseOutput?: ChineseOutput;
|
|
@@ -70,6 +69,8 @@ type ModelSelectionOptions = {
|
|
|
70
69
|
polishEnabled?: boolean;
|
|
71
70
|
/** Preserved across the model re-pick; absent means the agent's current model. */
|
|
72
71
|
polishModel?: PolishModelRef;
|
|
72
|
+
/** Preserved across the model re-pick; absent means the built-in prompt. */
|
|
73
|
+
polishPrompt?: string;
|
|
73
74
|
voiceActivityDetection?: boolean;
|
|
74
75
|
longformChunking?: boolean;
|
|
75
76
|
/** Persists language changes made before this flow activates a model. */
|
|
@@ -79,7 +80,7 @@ type ModelSelectionOptions = {
|
|
|
79
80
|
|
|
80
81
|
export async function runModelSelection(
|
|
81
82
|
ctx: ExtensionContext,
|
|
82
|
-
options: ModelSelectionOptions
|
|
83
|
+
options: ModelSelectionOptions,
|
|
83
84
|
): Promise<TranscribeSettings | undefined> {
|
|
84
85
|
if (!requireTui(ctx)) return undefined;
|
|
85
86
|
|
|
@@ -90,7 +91,7 @@ export async function runModelSelection(
|
|
|
90
91
|
let configured: TranscribeSettings | undefined;
|
|
91
92
|
const { activate, waitForCommits } = createSettingsActivation(
|
|
92
93
|
() => ({
|
|
93
|
-
shortcut: configured?.shortcut ?? options.shortcut
|
|
94
|
+
shortcut: configured?.shortcut ?? options.shortcut,
|
|
94
95
|
preferredLanguages,
|
|
95
96
|
transcriptionLanguage:
|
|
96
97
|
configured?.transcriptionLanguage ?? options.transcriptionLanguage,
|
|
@@ -99,6 +100,7 @@ export async function runModelSelection(
|
|
|
99
100
|
backend: configured?.backend ?? options.backend,
|
|
100
101
|
polishEnabled: configured?.polishEnabled ?? options.polishEnabled ?? false,
|
|
101
102
|
polishModel: configured?.polishModel ?? options.polishModel,
|
|
103
|
+
polishPrompt: configured?.polishPrompt ?? options.polishPrompt,
|
|
102
104
|
voiceActivityDetection: configured?.voiceActivityDetection ?? options.voiceActivityDetection,
|
|
103
105
|
longformChunking: configured?.longformChunking ?? options.longformChunking,
|
|
104
106
|
}),
|
|
@@ -233,6 +235,7 @@ export async function changeOnboardingModel(
|
|
|
233
235
|
backend: current.backend,
|
|
234
236
|
polishEnabled: current.polishEnabled,
|
|
235
237
|
polishModel: current.polishModel,
|
|
238
|
+
polishPrompt: current.polishPrompt,
|
|
236
239
|
voiceActivityDetection: current.voiceActivityDetection,
|
|
237
240
|
longformChunking: current.longformChunking,
|
|
238
241
|
}),
|
|
@@ -322,7 +325,7 @@ async function chooseSetupMode(ctx: ExtensionContext): Promise<SetupMode | undef
|
|
|
322
325
|
|
|
323
326
|
export async function runOnboarding(
|
|
324
327
|
ctx: ExtensionContext,
|
|
325
|
-
shortcut
|
|
328
|
+
shortcut: string,
|
|
326
329
|
): Promise<TranscribeSettings | undefined> {
|
|
327
330
|
if (!requireTui(ctx)) return undefined;
|
|
328
331
|
|
package/src/polish.ts
CHANGED
|
@@ -13,6 +13,11 @@ import type { PolishModelRef } from "./settings.js";
|
|
|
13
13
|
* empty response — the original transcript is returned unchanged. Provider
|
|
14
14
|
* reasoning is ignored (Pi reports it as `thinking` blocks), and inline
|
|
15
15
|
* reasoning tags are stripped from the refined text.
|
|
16
|
+
*
|
|
17
|
+
* The instructions can be replaced from settings (`polishPrompt`); the built-in
|
|
18
|
+
* prompt below remains the default and the starting point for editing. A custom
|
|
19
|
+
* prompt is the whole system prompt, so it also takes over rule 5 of the
|
|
20
|
+
* built-in one: the transcript is text to edit, never instructions to follow.
|
|
16
21
|
*/
|
|
17
22
|
|
|
18
23
|
export const POLISH_SYSTEM_PROMPT = `# Role
|
|
@@ -73,13 +78,26 @@ type PolishModelRegistry = {
|
|
|
73
78
|
): Promise<PolishAssistantMessage>;
|
|
74
79
|
};
|
|
75
80
|
|
|
81
|
+
export type PolishRequest = {
|
|
82
|
+
/** When false the raw transcript is returned untouched. */
|
|
83
|
+
enabled: boolean;
|
|
84
|
+
/** Explicit model override; absent uses the agent's current model. */
|
|
85
|
+
modelRef?: PolishModelRef;
|
|
86
|
+
/**
|
|
87
|
+
* The whole system prompt when set; absent or blank uses POLISH_SYSTEM_PROMPT.
|
|
88
|
+
* A custom value replaces the built-in prompt entirely, including its rule that
|
|
89
|
+
* treats the transcript as text and never as instructions.
|
|
90
|
+
*/
|
|
91
|
+
prompt?: string;
|
|
92
|
+
signal?: AbortSignal;
|
|
93
|
+
};
|
|
94
|
+
|
|
76
95
|
export async function polishTranscript(
|
|
77
96
|
ctx: ExtensionContext,
|
|
78
97
|
text: string,
|
|
79
|
-
|
|
80
|
-
modelRef?: PolishModelRef,
|
|
81
|
-
signal?: AbortSignal,
|
|
98
|
+
request: PolishRequest,
|
|
82
99
|
): Promise<string> {
|
|
100
|
+
const { enabled, modelRef, signal } = request;
|
|
83
101
|
if (!enabled) return text;
|
|
84
102
|
const trimmed = text.trim();
|
|
85
103
|
if (!trimmed) return text;
|
|
@@ -87,6 +105,9 @@ export async function polishTranscript(
|
|
|
87
105
|
const requestSignal = signal ?? ctx.signal;
|
|
88
106
|
if (requestSignal?.aborted) return text;
|
|
89
107
|
|
|
108
|
+
const customPrompt = request.prompt?.trim();
|
|
109
|
+
const systemPrompt = customPrompt || POLISH_SYSTEM_PROMPT;
|
|
110
|
+
|
|
90
111
|
// An explicit override is resolved against Pi's own model definitions, so it
|
|
91
112
|
// shares the same catalog and provider authentication as the agent. Falls
|
|
92
113
|
// back to the agent's current model when the override is unusable.
|
|
@@ -113,14 +134,15 @@ export async function polishTranscript(
|
|
|
113
134
|
writeLog(
|
|
114
135
|
"info",
|
|
115
136
|
"polish",
|
|
116
|
-
`refining ${trimmed.length} chars with ${model.provider ?? "?"}/${model.id ?? "?"}
|
|
137
|
+
`refining ${trimmed.length} chars with ${model.provider ?? "?"}/${model.id ?? "?"}` +
|
|
138
|
+
`${customPrompt ? " (custom prompt)" : ""}`,
|
|
117
139
|
);
|
|
118
140
|
|
|
119
141
|
try {
|
|
120
142
|
const response = await registry.complete(
|
|
121
143
|
model,
|
|
122
144
|
{
|
|
123
|
-
systemPrompt
|
|
145
|
+
systemPrompt,
|
|
124
146
|
messages: [
|
|
125
147
|
{
|
|
126
148
|
role: "user",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// ctx.reload() clears widgets, so setup asks the next instance to show Ready.
|
|
2
|
+
// Reload re-evaluates the extension in the same process; a global symbol survives it.
|
|
3
|
+
const READY_AFTER_RELOAD = Symbol.for("pi-voice.readyAfterReload");
|
|
4
|
+
|
|
5
|
+
type Handoff = { [READY_AFTER_RELOAD]?: true };
|
|
6
|
+
|
|
7
|
+
export function requestReadyAfterReload(): void {
|
|
8
|
+
(globalThis as Handoff)[READY_AFTER_RELOAD] = true;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function takeReadyRequest(): boolean {
|
|
12
|
+
const requested = (globalThis as Handoff)[READY_AFTER_RELOAD] === true;
|
|
13
|
+
delete (globalThis as Handoff)[READY_AFTER_RELOAD];
|
|
14
|
+
return requested;
|
|
15
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { logStep, watchEventLoop } from "./log.js";
|
|
|
12
12
|
import { DictationController } from "./dictation-controller.js";
|
|
13
13
|
import { VoiceKeys } from "./keybindings.js";
|
|
14
14
|
import { polishTranscript } from "./polish.js";
|
|
15
|
+
import { requestReadyAfterReload, takeReadyRequest } from "./ready-handoff.js";
|
|
15
16
|
import type { TranscribeSettings } from "./settings.js";
|
|
16
17
|
import { displayShortcut } from "./shortcut-core.js";
|
|
17
18
|
import { TranscriptionService } from "./transcription-service.js";
|
|
@@ -36,6 +37,8 @@ export type PiVoiceRuntime = {
|
|
|
36
37
|
toggleCapture(ctx: ExtensionContext): Promise<void>;
|
|
37
38
|
showSettings(ctx: ExtensionCommandContext): Promise<void>;
|
|
38
39
|
replayOnboarding(ctx: ExtensionCommandContext): Promise<void>;
|
|
40
|
+
/** Shows Ready for setup that a reload interrupted. */
|
|
41
|
+
showReadyAfterReload(ctx: ExtensionContext): Promise<void>;
|
|
39
42
|
shutdown(ctx: ExtensionContext): Promise<void>;
|
|
40
43
|
};
|
|
41
44
|
|
|
@@ -93,13 +96,13 @@ export function createPiVoiceRuntime(
|
|
|
93
96
|
settingsReadWarning = undefined;
|
|
94
97
|
}
|
|
95
98
|
|
|
96
|
-
async function notifyReady(ctx: ExtensionContext,
|
|
99
|
+
async function notifyReady(ctx: ExtensionContext, shortcut: string): Promise<void> {
|
|
97
100
|
// Pi binds shortcuts at extension load. The command path reloads on its
|
|
98
101
|
// own; the shortcut path cannot, so say what it takes to use a new one.
|
|
99
|
-
const reloadNeeded =
|
|
102
|
+
const reloadNeeded = shortcut !== registeredShortcut;
|
|
100
103
|
const talk = reloadNeeded
|
|
101
|
-
? `run /reload, then ${displayShortcut(
|
|
102
|
-
: `${displayShortcut(
|
|
104
|
+
? `run /reload, then ${displayShortcut(shortcut)} to talk`
|
|
105
|
+
: `${displayShortcut(shortcut)} to talk`;
|
|
103
106
|
const command = "/voice-settings";
|
|
104
107
|
const commandDescription = "to change settings and download new models";
|
|
105
108
|
const summary = `${command} ${commandDescription}`;
|
|
@@ -152,6 +155,7 @@ export function createPiVoiceRuntime(
|
|
|
152
155
|
backend: previous.backend,
|
|
153
156
|
polishEnabled: previous.polishEnabled,
|
|
154
157
|
polishModel: previous.polishModel,
|
|
158
|
+
polishPrompt: previous.polishPrompt,
|
|
155
159
|
voiceActivityDetection: previous.voiceActivityDetection,
|
|
156
160
|
longformChunking: previous.longformChunking,
|
|
157
161
|
postActivation: "advance",
|
|
@@ -160,9 +164,10 @@ export function createPiVoiceRuntime(
|
|
|
160
164
|
return configured;
|
|
161
165
|
}
|
|
162
166
|
|
|
167
|
+
// `setUp`: setup ran and finished; the caller shows Ready since only it knows if a reload follows.
|
|
163
168
|
async function ensureSettings(
|
|
164
169
|
ctx: ExtensionContext,
|
|
165
|
-
): Promise<{ configured?: TranscribeSettings; completedFirstRun: boolean }> {
|
|
170
|
+
): Promise<{ configured?: TranscribeSettings; setUp: boolean; completedFirstRun: boolean }> {
|
|
166
171
|
await loadSettingsOnce();
|
|
167
172
|
if (settingsReadWarning && !settingsWarningShown) {
|
|
168
173
|
settingsWarningShown = true;
|
|
@@ -170,7 +175,7 @@ export function createPiVoiceRuntime(
|
|
|
170
175
|
}
|
|
171
176
|
|
|
172
177
|
if (settings && (!settings.model || existsSync(settings.model.path))) {
|
|
173
|
-
return { configured: settings, completedFirstRun: false };
|
|
178
|
+
return { configured: settings, setUp: false, completedFirstRun: false };
|
|
174
179
|
}
|
|
175
180
|
|
|
176
181
|
const previous = settings;
|
|
@@ -185,8 +190,11 @@ export function createPiVoiceRuntime(
|
|
|
185
190
|
const configured = previous
|
|
186
191
|
? await configureModel(ctx, previous)
|
|
187
192
|
: await configureFirstRun(ctx);
|
|
188
|
-
|
|
189
|
-
|
|
193
|
+
return {
|
|
194
|
+
configured,
|
|
195
|
+
setUp: configured !== undefined,
|
|
196
|
+
completedFirstRun: previous === undefined && configured !== undefined,
|
|
197
|
+
};
|
|
190
198
|
}
|
|
191
199
|
|
|
192
200
|
async function requireConfiguredSettingsForTool(): Promise<TranscribeSettings> {
|
|
@@ -303,7 +311,11 @@ export function createPiVoiceRuntime(
|
|
|
303
311
|
await reportDictationError(ctx, active.dictation);
|
|
304
312
|
} else if (result.text) {
|
|
305
313
|
const text = active.settings.polishEnabled
|
|
306
|
-
? await polishTranscript(ctx, result.text,
|
|
314
|
+
? await polishTranscript(ctx, result.text, {
|
|
315
|
+
enabled: true,
|
|
316
|
+
modelRef: active.settings.polishModel,
|
|
317
|
+
prompt: active.settings.polishPrompt,
|
|
318
|
+
})
|
|
307
319
|
: result.text;
|
|
308
320
|
ctx.ui.pasteToEditor(text);
|
|
309
321
|
showTranscribeStatus(
|
|
@@ -422,7 +434,8 @@ export function createPiVoiceRuntime(
|
|
|
422
434
|
clearTranscribeWidget(ctx);
|
|
423
435
|
}
|
|
424
436
|
|
|
425
|
-
const { configured, completedFirstRun } = await ensureSettings(ctx);
|
|
437
|
+
const { configured, setUp, completedFirstRun } = await ensureSettings(ctx);
|
|
438
|
+
if (configured && setUp) await notifyReady(ctx, configured.shortcut);
|
|
426
439
|
if (configured && !completedFirstRun) await startRecording(ctx, configured);
|
|
427
440
|
// The meter shares the widget slot and has replaced the spinner when
|
|
428
441
|
// recording began; clear the spinner only when recording never started.
|
|
@@ -462,14 +475,16 @@ export function createPiVoiceRuntime(
|
|
|
462
475
|
}
|
|
463
476
|
|
|
464
477
|
let reload = false;
|
|
478
|
+
let readyShortcut: string | undefined;
|
|
465
479
|
await runExclusive(ctx, async () => {
|
|
466
|
-
await
|
|
467
|
-
const { configured, completedFirstRun } = await ensureSettings(ctx);
|
|
480
|
+
const { configured, setUp } = await ensureSettings(ctx);
|
|
468
481
|
if (!configured) return;
|
|
469
|
-
if (
|
|
482
|
+
if (setUp) {
|
|
470
483
|
// First-run setup ends on its Ready message rather than falling
|
|
471
484
|
// straight through into the regular settings menu.
|
|
472
485
|
reload = configured.shortcut !== registeredShortcut;
|
|
486
|
+
if (reload) readyShortcut = configured.shortcut;
|
|
487
|
+
else await notifyReady(ctx, configured.shortcut);
|
|
473
488
|
return;
|
|
474
489
|
}
|
|
475
490
|
// A recovered missing model file also lands here: the user asked for
|
|
@@ -478,8 +493,19 @@ export function createPiVoiceRuntime(
|
|
|
478
493
|
const { showSettingsMenu } = await import("./settings-menu.js");
|
|
479
494
|
reload = await showSettingsMenu(pi, ctx, configured, registeredShortcut);
|
|
480
495
|
});
|
|
481
|
-
if (reload)
|
|
482
|
-
|
|
496
|
+
if (reload) await reloadForShortcut(ctx, readyShortcut);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Reload clears widgets, so Ready after setup is shown by the next instance.
|
|
500
|
+
async function reloadForShortcut(
|
|
501
|
+
ctx: ExtensionCommandContext,
|
|
502
|
+
readyShortcut: string | undefined,
|
|
503
|
+
): Promise<void> {
|
|
504
|
+
if (readyShortcut) requestReadyAfterReload();
|
|
505
|
+
await ctx.reload();
|
|
506
|
+
// Unclaimed means Pi declined the reload (e.g. mid-response): show Ready with the /reload hint.
|
|
507
|
+
if (takeReadyRequest() && readyShortcut && !shuttingDown) {
|
|
508
|
+
await notifyReady(ctx, readyShortcut);
|
|
483
509
|
}
|
|
484
510
|
}
|
|
485
511
|
|
|
@@ -493,6 +519,7 @@ export function createPiVoiceRuntime(
|
|
|
493
519
|
return;
|
|
494
520
|
}
|
|
495
521
|
|
|
522
|
+
let readyShortcut: string | undefined;
|
|
496
523
|
await runExclusive(ctx, async () => {
|
|
497
524
|
await loadSettingsOnce();
|
|
498
525
|
const { runOnboarding } = await import("./onboarding.js");
|
|
@@ -502,10 +529,15 @@ export function createPiVoiceRuntime(
|
|
|
502
529
|
);
|
|
503
530
|
if (!configured) return;
|
|
504
531
|
rememberSettings(configured);
|
|
505
|
-
// End
|
|
506
|
-
|
|
507
|
-
|
|
532
|
+
// End like first-run setup: Ready, reloading first if the shortcut changed.
|
|
533
|
+
if (configured.shortcut === registeredShortcut) await notifyReady(ctx, configured.shortcut);
|
|
534
|
+
else readyShortcut = configured.shortcut;
|
|
508
535
|
});
|
|
536
|
+
if (readyShortcut) await reloadForShortcut(ctx, readyShortcut);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function showReadyAfterReload(ctx: ExtensionContext): Promise<void> {
|
|
540
|
+
await notifyReady(ctx, registeredShortcut);
|
|
509
541
|
}
|
|
510
542
|
|
|
511
543
|
async function shutdown(ctx: ExtensionContext): Promise<void> {
|
|
@@ -536,6 +568,7 @@ export function createPiVoiceRuntime(
|
|
|
536
568
|
toggleCapture,
|
|
537
569
|
showSettings,
|
|
538
570
|
replayOnboarding,
|
|
571
|
+
showReadyAfterReload,
|
|
539
572
|
shutdown,
|
|
540
573
|
};
|
|
541
574
|
}
|
package/src/settings-menu.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import { runModelSelection } from "./onboarding.js";
|
|
22
22
|
import { chooseBackendType, promptRemoteBackend, warnMissingApiKey } from "./remote-asr.js";
|
|
23
23
|
import {
|
|
24
|
+
MAX_POLISH_PROMPT_LENGTH,
|
|
24
25
|
settingsWithPreferredLanguages,
|
|
25
26
|
writeSettings,
|
|
26
27
|
type BackendSettings,
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
} from "./settings.js";
|
|
33
34
|
import { displayShortcut } from "./shortcut-core.js";
|
|
34
35
|
import { createShortcutPicker } from "./shortcuts.js";
|
|
36
|
+
import { POLISH_SYSTEM_PROMPT } from "./polish.js";
|
|
35
37
|
import {
|
|
36
38
|
padToWidth,
|
|
37
39
|
SingleSelectPicker,
|
|
@@ -51,6 +53,7 @@ export type SettingsAction =
|
|
|
51
53
|
| "transcription-language"
|
|
52
54
|
| "chinese-output"
|
|
53
55
|
| "post-processing"
|
|
56
|
+
| "post-processing-prompt"
|
|
54
57
|
| "microphone"
|
|
55
58
|
| "shortcut";
|
|
56
59
|
|
|
@@ -99,6 +102,10 @@ function polishSummary(polishEnabled: boolean | undefined, model: PolishModelRef
|
|
|
99
102
|
return model ? `${model.provider}/${model.id}` : "Pi's current model";
|
|
100
103
|
}
|
|
101
104
|
|
|
105
|
+
function polishPromptSummary(prompt: string | undefined): string {
|
|
106
|
+
return prompt ? `Custom (${prompt.length} chars)` : "Built-in default";
|
|
107
|
+
}
|
|
108
|
+
|
|
102
109
|
let detectorDownload: Promise<void> | undefined;
|
|
103
110
|
|
|
104
111
|
/**
|
|
@@ -164,6 +171,18 @@ export function settingsHomeChoices(
|
|
|
164
171
|
},
|
|
165
172
|
);
|
|
166
173
|
|
|
174
|
+
// The prompt only applies to a refinement that runs, so its row follows the
|
|
175
|
+
// post-processing toggle instead of offering dead configuration.
|
|
176
|
+
if (configured.polishEnabled) {
|
|
177
|
+
choices.push({
|
|
178
|
+
value: "post-processing-prompt",
|
|
179
|
+
label: "Post-processing prompt",
|
|
180
|
+
summary: polishPromptSummary(configured.polishPrompt),
|
|
181
|
+
description:
|
|
182
|
+
"Instructions the post-processing model follows; edit or reset the built-in prompt",
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
167
186
|
if (model) {
|
|
168
187
|
choices.push({
|
|
169
188
|
value: "transcription-language",
|
|
@@ -344,6 +363,7 @@ async function editBackend(
|
|
|
344
363
|
backend: { type: "transcribe-cpp" },
|
|
345
364
|
polishEnabled: configured.polishEnabled,
|
|
346
365
|
polishModel: configured.polishModel,
|
|
366
|
+
polishPrompt: configured.polishPrompt,
|
|
347
367
|
postActivation: "stay",
|
|
348
368
|
});
|
|
349
369
|
if (!updated) {
|
|
@@ -499,6 +519,92 @@ async function editPostProcessing(
|
|
|
499
519
|
);
|
|
500
520
|
}
|
|
501
521
|
|
|
522
|
+
type PolishPromptSource = "default" | "custom";
|
|
523
|
+
|
|
524
|
+
/** Ask whether post-processing runs the built-in prompt or a custom one. */
|
|
525
|
+
export async function choosePolishPromptSource(
|
|
526
|
+
ctx: ExtensionContext,
|
|
527
|
+
current: string | undefined,
|
|
528
|
+
): Promise<PolishPromptSource | undefined> {
|
|
529
|
+
const choices: SingleSelectChoice<PolishPromptSource>[] = [
|
|
530
|
+
{
|
|
531
|
+
value: "default",
|
|
532
|
+
label: "Built-in default",
|
|
533
|
+
description:
|
|
534
|
+
"Strip fillers and false starts, fix punctuation and casing; keeps the language and technical terms exactly",
|
|
535
|
+
},
|
|
536
|
+
{
|
|
537
|
+
value: "custom",
|
|
538
|
+
label: "Custom prompt",
|
|
539
|
+
description:
|
|
540
|
+
"Write your own instructions, starting from the built-in prompt; your text replaces it entirely, including its rule that the transcript is never instructions",
|
|
541
|
+
},
|
|
542
|
+
];
|
|
543
|
+
return ctx.ui.custom<PolishPromptSource | undefined>((tui, theme, keybindings, done) =>
|
|
544
|
+
new SingleSelectPicker(
|
|
545
|
+
tui,
|
|
546
|
+
theme,
|
|
547
|
+
keybindings,
|
|
548
|
+
choices,
|
|
549
|
+
current ? "custom" : "default",
|
|
550
|
+
{ title: "Post-processing prompt", cancelLabel: "back" },
|
|
551
|
+
done,
|
|
552
|
+
),
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Post-processing prompt flow: pick the built-in prompt or edit a custom one.
|
|
558
|
+
* Clearing the custom text resets to the built-in prompt, so the editor doubles
|
|
559
|
+
* as a way to read and restore the default.
|
|
560
|
+
*/
|
|
561
|
+
export async function editPostProcessingPrompt(
|
|
562
|
+
ctx: ExtensionContext,
|
|
563
|
+
configured: TranscribeSettings,
|
|
564
|
+
): Promise<boolean> {
|
|
565
|
+
const source = await choosePolishPromptSource(ctx, configured.polishPrompt);
|
|
566
|
+
if (!source) return false;
|
|
567
|
+
|
|
568
|
+
const resetToDefault = (message: string): Promise<boolean> =>
|
|
569
|
+
saveUpdatedSettings(ctx, configured, { ...configured, polishPrompt: undefined }, message);
|
|
570
|
+
|
|
571
|
+
if (source === "default") {
|
|
572
|
+
if (!configured.polishPrompt) return false;
|
|
573
|
+
return resetToDefault("Post-processing uses the built-in prompt");
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Prefilled so the built-in wording can be adjusted instead of retyped.
|
|
577
|
+
const edited = await ctx.ui.editor(
|
|
578
|
+
"Post-processing prompt (clear it to restore the built-in default)",
|
|
579
|
+
configured.polishPrompt ?? POLISH_SYSTEM_PROMPT,
|
|
580
|
+
);
|
|
581
|
+
if (edited === undefined) return false;
|
|
582
|
+
|
|
583
|
+
const prompt = edited.trim();
|
|
584
|
+
if (prompt.length > MAX_POLISH_PROMPT_LENGTH) {
|
|
585
|
+
ctx.ui.notify(
|
|
586
|
+
`Post-processing prompt is too long (${prompt.length} characters; the limit is ${MAX_POLISH_PROMPT_LENGTH})`,
|
|
587
|
+
"error",
|
|
588
|
+
);
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
if (!prompt) {
|
|
592
|
+
if (!configured.polishPrompt) return false;
|
|
593
|
+
return resetToDefault("Post-processing prompt reset to the built-in default");
|
|
594
|
+
}
|
|
595
|
+
if (prompt === configured.polishPrompt) return false;
|
|
596
|
+
// Saving the prefilled built-in text unchanged is not a custom prompt: storing
|
|
597
|
+
// it would relabel the row "Custom" and pin a copy that no longer tracks
|
|
598
|
+
// improvements to the built-in wording.
|
|
599
|
+
if (!configured.polishPrompt && prompt === POLISH_SYSTEM_PROMPT.trim()) return false;
|
|
600
|
+
return saveUpdatedSettings(
|
|
601
|
+
ctx,
|
|
602
|
+
configured,
|
|
603
|
+
{ ...configured, polishPrompt: prompt },
|
|
604
|
+
`Post-processing prompt saved (${prompt.length} characters)`,
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
502
608
|
export async function openMacOSMicrophoneSettings(
|
|
503
609
|
pi: ExtensionAPI,
|
|
504
610
|
ctx: ExtensionContext,
|
|
@@ -576,6 +682,7 @@ export async function showSettingsMenu(
|
|
|
576
682
|
backend: configured.backend,
|
|
577
683
|
polishEnabled: configured.polishEnabled,
|
|
578
684
|
polishModel: configured.polishModel,
|
|
685
|
+
polishPrompt: configured.polishPrompt,
|
|
579
686
|
voiceActivityDetection: configured.voiceActivityDetection,
|
|
580
687
|
longformChunking: configured.longformChunking,
|
|
581
688
|
postActivation: "stay",
|
|
@@ -601,6 +708,11 @@ export async function showSettingsMenu(
|
|
|
601
708
|
continue;
|
|
602
709
|
}
|
|
603
710
|
|
|
711
|
+
if (action === "post-processing-prompt") {
|
|
712
|
+
await editPostProcessingPrompt(ctx, configured);
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
|
|
604
716
|
if (action === "transcription-language") {
|
|
605
717
|
const transcriptionLanguage = await chooseTranscriptionLanguage(ctx, configured);
|
|
606
718
|
if (!transcriptionLanguage) continue;
|
package/src/settings.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
type CatalogModel,
|
|
7
7
|
} from "./catalog.js";
|
|
8
8
|
import { autoLanguagePolicy } from "./language-detection-policy.js";
|
|
9
|
-
import {
|
|
9
|
+
import { normalizeShortcut } from "./shortcut-core.js";
|
|
10
10
|
import { legacySettingsPath, settingsPath } from "./settings-path.js";
|
|
11
11
|
|
|
12
12
|
const SETTINGS_VERSION = 1;
|
|
@@ -58,6 +58,8 @@ export type TranscribeSettings = {
|
|
|
58
58
|
polishEnabled?: boolean;
|
|
59
59
|
/** Optional post-processing model override; absent uses the agent's current model. */
|
|
60
60
|
polishModel?: PolishModelRef;
|
|
61
|
+
/** Optional post-processing instructions; absent uses the built-in prompt. */
|
|
62
|
+
polishPrompt?: string;
|
|
61
63
|
/** Transcribe only the speech in a recording, not its silence and noise. */
|
|
62
64
|
voiceActivityDetection: boolean;
|
|
63
65
|
/** Hidden, settings.json only: false decodes long audio in one piece, without voice activity cuts. */
|
|
@@ -175,6 +177,22 @@ function normalizePolishModel(value: unknown): PolishModelRef | undefined {
|
|
|
175
177
|
return { provider: value.provider.trim(), id: value.id.trim() };
|
|
176
178
|
}
|
|
177
179
|
|
|
180
|
+
/** A custom prompt replaces the whole system prompt, so keep it to a sane size. */
|
|
181
|
+
export const MAX_POLISH_PROMPT_LENGTH = 20_000;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A blank or non-string prompt means "use the built-in one", so it is dropped.
|
|
185
|
+
* So is one over the cap: the value would become a model system prompt, and the
|
|
186
|
+
* editor never writes one that large, so it can only come from a hand-edited
|
|
187
|
+
* file.
|
|
188
|
+
*/
|
|
189
|
+
function normalizePolishPrompt(value: unknown): string | undefined {
|
|
190
|
+
if (typeof value !== "string") return undefined;
|
|
191
|
+
const prompt = value.trim();
|
|
192
|
+
if (!prompt || prompt.length > MAX_POLISH_PROMPT_LENGTH) return undefined;
|
|
193
|
+
return prompt;
|
|
194
|
+
}
|
|
195
|
+
|
|
178
196
|
function validateModelValue(
|
|
179
197
|
value: unknown,
|
|
180
198
|
): { id: string; path: string; catalogModel: CatalogModel } | undefined {
|
|
@@ -203,6 +221,7 @@ function validateSettings(value: unknown): TranscribeSettings | undefined {
|
|
|
203
221
|
const microphone = validateMicrophone(value.microphone);
|
|
204
222
|
if (!preferredLanguages || !microphone) return undefined;
|
|
205
223
|
const polishModel = normalizePolishModel(value.polishModel);
|
|
224
|
+
const polishPrompt = normalizePolishPrompt(value.polishPrompt);
|
|
206
225
|
|
|
207
226
|
// The remote endpoint detects the language itself; the stored language only
|
|
208
227
|
// triggers Chinese script conversion, so any previous value is kept as-is.
|
|
@@ -227,6 +246,7 @@ function validateSettings(value: unknown): TranscribeSettings | undefined {
|
|
|
227
246
|
microphone,
|
|
228
247
|
polishEnabled: value.polishEnabled === true,
|
|
229
248
|
...(polishModel ? { polishModel } : {}),
|
|
249
|
+
...(polishPrompt ? { polishPrompt } : {}),
|
|
230
250
|
voiceActivityDetection: value.voiceActivityDetection !== false,
|
|
231
251
|
longformChunking: value.longformChunking !== false,
|
|
232
252
|
...(backend.type === "transcribe-cpp"
|
|
@@ -288,7 +308,7 @@ export async function writeSettings(settings: TranscribeSettings): Promise<void>
|
|
|
288
308
|
}
|
|
289
309
|
|
|
290
310
|
type ModelSettingsOptions = {
|
|
291
|
-
shortcut
|
|
311
|
+
shortcut: string;
|
|
292
312
|
preferredLanguages?: readonly string[];
|
|
293
313
|
transcriptionLanguage?: TranscriptionLanguage;
|
|
294
314
|
chineseOutput?: ChineseOutput;
|
|
@@ -302,6 +322,8 @@ type ModelSettingsOptions = {
|
|
|
302
322
|
polishEnabled?: boolean;
|
|
303
323
|
/** Preserved across model re-selection; absent means the agent's current model. */
|
|
304
324
|
polishModel?: PolishModelRef;
|
|
325
|
+
/** Preserved across model re-selection; absent means the built-in prompt. */
|
|
326
|
+
polishPrompt?: string;
|
|
305
327
|
voiceActivityDetection?: boolean;
|
|
306
328
|
longformChunking?: boolean;
|
|
307
329
|
};
|
|
@@ -309,7 +331,7 @@ type ModelSettingsOptions = {
|
|
|
309
331
|
export function settingsForModel(
|
|
310
332
|
modelId: string,
|
|
311
333
|
modelPath: string,
|
|
312
|
-
options: ModelSettingsOptions
|
|
334
|
+
options: ModelSettingsOptions,
|
|
313
335
|
): TranscribeSettings {
|
|
314
336
|
const model = getCatalogModel(modelId);
|
|
315
337
|
if (!model) throw new Error(`Unknown catalog model: ${modelId}`);
|
|
@@ -323,7 +345,7 @@ export function settingsForModel(
|
|
|
323
345
|
options.backend && options.backend.type === "transcribe-cpp"
|
|
324
346
|
? options.backend
|
|
325
347
|
: { type: "transcribe-cpp" },
|
|
326
|
-
shortcut: options.shortcut
|
|
348
|
+
shortcut: options.shortcut,
|
|
327
349
|
preferredLanguages,
|
|
328
350
|
transcriptionLanguage: transcriptionLanguageForModel(
|
|
329
351
|
options.transcriptionLanguage,
|
|
@@ -334,6 +356,7 @@ export function settingsForModel(
|
|
|
334
356
|
microphone: { ...(options.microphone ?? DEFAULT_MICROPHONE) },
|
|
335
357
|
polishEnabled: options.polishEnabled ?? false,
|
|
336
358
|
...(options.polishModel ? { polishModel: options.polishModel } : {}),
|
|
359
|
+
...(options.polishPrompt ? { polishPrompt: options.polishPrompt } : {}),
|
|
337
360
|
voiceActivityDetection: options.voiceActivityDetection ?? true,
|
|
338
361
|
longformChunking: options.longformChunking ?? true,
|
|
339
362
|
model: { source: "catalog", id: modelId, path: modelPath },
|
|
@@ -353,7 +376,7 @@ export function settingsRemoteOnly(options: RemoteSettingsOptions): TranscribeSe
|
|
|
353
376
|
return {
|
|
354
377
|
version: SETTINGS_VERSION,
|
|
355
378
|
backend: options.backend,
|
|
356
|
-
shortcut: options.shortcut
|
|
379
|
+
shortcut: options.shortcut,
|
|
357
380
|
preferredLanguages,
|
|
358
381
|
// The endpoint detects the language itself; "auto" keeps the stored value
|
|
359
382
|
// from triggering Chinese conversion against a language that is not there.
|
|
@@ -362,6 +385,7 @@ export function settingsRemoteOnly(options: RemoteSettingsOptions): TranscribeSe
|
|
|
362
385
|
microphone: { ...(options.microphone ?? DEFAULT_MICROPHONE) },
|
|
363
386
|
polishEnabled: options.polishEnabled ?? false,
|
|
364
387
|
...(options.polishModel ? { polishModel: options.polishModel } : {}),
|
|
388
|
+
...(options.polishPrompt ? { polishPrompt: options.polishPrompt } : {}),
|
|
365
389
|
voiceActivityDetection: options.voiceActivityDetection ?? true,
|
|
366
390
|
longformChunking: options.longformChunking ?? true,
|
|
367
391
|
};
|
package/src/shortcut-core.ts
CHANGED
package/src/shortcuts.ts
CHANGED
|
@@ -9,11 +9,8 @@ import {
|
|
|
9
9
|
type KeybindingsManager,
|
|
10
10
|
type TUI,
|
|
11
11
|
} from "@earendil-works/pi-tui";
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
displayShortcut,
|
|
15
|
-
normalizeShortcut,
|
|
16
|
-
} from "./shortcut-core.js";
|
|
12
|
+
import { defaultShortcut } from "./default-shortcut.js";
|
|
13
|
+
import { displayShortcut, normalizeShortcut } from "./shortcut-core.js";
|
|
17
14
|
import { VoiceKeys } from "./keybindings.js";
|
|
18
15
|
import { panelBorder } from "./ui-components.js";
|
|
19
16
|
|
|
@@ -48,6 +45,7 @@ export function createShortcutPicker(
|
|
|
48
45
|
done: (shortcut: string | undefined) => void,
|
|
49
46
|
): Component {
|
|
50
47
|
const keys = new VoiceKeys(keybindings);
|
|
48
|
+
const defaultKey = defaultShortcut();
|
|
51
49
|
let phase: Phase = { kind: "waiting" };
|
|
52
50
|
const container = new Container();
|
|
53
51
|
|
|
@@ -98,7 +96,7 @@ export function createShortcutPicker(
|
|
|
98
96
|
container.addChild(new Spacer(1));
|
|
99
97
|
container.addChild(
|
|
100
98
|
new Text(
|
|
101
|
-
`${keys.hint("voice.shortcut.useDefault", `default (${displayShortcut(
|
|
99
|
+
`${keys.hint("voice.shortcut.useDefault", `default (${displayShortcut(defaultKey)})`)} ${keys.hint("tui.select.cancel", "back")}`,
|
|
102
100
|
1,
|
|
103
101
|
0,
|
|
104
102
|
),
|
|
@@ -132,8 +130,8 @@ export function createShortcutPicker(
|
|
|
132
130
|
if (keys.matches(data, "voice.shortcut.useDefault")) {
|
|
133
131
|
phase = {
|
|
134
132
|
kind: "preview",
|
|
135
|
-
normalized:
|
|
136
|
-
conflicts: findConflicts(keybindings,
|
|
133
|
+
normalized: defaultKey,
|
|
134
|
+
conflicts: findConflicts(keybindings, defaultKey),
|
|
137
135
|
};
|
|
138
136
|
rebuild();
|
|
139
137
|
tui.requestRender();
|
package/src/startup-shortcut.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
2
|
+
import { defaultShortcut } from "./default-shortcut.js";
|
|
3
|
+
import { normalizeShortcut } from "./shortcut-core.js";
|
|
3
4
|
import { legacySettingsPath, settingsPath } from "./settings-path.js";
|
|
4
5
|
|
|
5
6
|
function isObject(value: unknown): value is Record<string, unknown> {
|
|
@@ -12,13 +13,13 @@ export function readShortcutForRegistration(): string {
|
|
|
12
13
|
try {
|
|
13
14
|
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
14
15
|
if (!isObject(parsed) || parsed.version !== 1 || typeof parsed.shortcut !== "string") {
|
|
15
|
-
return
|
|
16
|
+
return defaultShortcut();
|
|
16
17
|
}
|
|
17
|
-
return normalizeShortcut(parsed.shortcut) ??
|
|
18
|
+
return normalizeShortcut(parsed.shortcut) ?? defaultShortcut();
|
|
18
19
|
} catch (error) {
|
|
19
20
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
20
|
-
return
|
|
21
|
+
return defaultShortcut();
|
|
21
22
|
}
|
|
22
23
|
}
|
|
23
|
-
return
|
|
24
|
+
return defaultShortcut();
|
|
24
25
|
}
|