pi-voice-input 0.2.12 → 0.2.14
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 +92 -128
- package/extensions/voice-input.ts +248 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,193 +1,158 @@
|
|
|
1
1
|
# pi Voice Input
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Voice dictation for [pi](https://pi.dev/). Press one shortcut, speak naturally, and insert the transcript into the editor without sending the prompt automatically.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
- Press `Ctrl+Shift+R` again to stop.
|
|
7
|
-
- The extension sends the audio to VolcEngine WebSocket ASR.
|
|
8
|
-
- The recognized text is inserted into pi's editor without submitting.
|
|
5
|
+
## Why use it?
|
|
9
6
|
|
|
10
|
-
|
|
7
|
+
Typing long prompts can slow you down. `pi-voice-input` lets you:
|
|
11
8
|
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
9
|
+
- capture ideas quickly while you are thinking out loud
|
|
10
|
+
- dictate long instructions, notes, bug reports, or code review comments
|
|
11
|
+
- speak naturally in Chinese, English, or a mix of both
|
|
12
|
+
- keep your hands on the keyboard with a simple toggle shortcut
|
|
13
|
+
- review or edit the inserted text before you submit it
|
|
14
|
+
- optionally polish dictated text with one of your configured pi models
|
|
16
15
|
|
|
17
|
-
|
|
16
|
+
## Features
|
|
18
17
|
|
|
19
|
-
|
|
18
|
+
- **One-key dictation**: `Ctrl+Shift+R` starts recording; press it again to stop and insert text.
|
|
19
|
+
- **Editor-safe workflow**: transcription is pasted into the current editor only. It does not auto-submit.
|
|
20
|
+
- **Chinese/English mixed input**: handles prompts that switch between Chinese, English, product names, and technical terms.
|
|
21
|
+
- **Works on Linux and macOS**: uses common system recording tools.
|
|
22
|
+
- **Lowers sound while you speak**: automatically turns down system audio during recording, then restores it afterwards.
|
|
23
|
+
- **Optional transcript polish**: use a pi model to clean up punctuation and wording before insertion.
|
|
24
|
+
- **Simple setup commands**: configure from inside pi with `/voice init` and `/voice key`.
|
|
20
25
|
|
|
21
|
-
|
|
26
|
+
Current speech provider: **VolcEngine Speech ASR**. A VolcEngine Speech API key is required.
|
|
22
27
|
|
|
23
|
-
|
|
24
|
-
pi extension: extensions/index.ts → extensions/voice-input.ts
|
|
25
|
-
├─ registers Ctrl+Shift+R and /voice commands
|
|
26
|
-
├─ starts/stops a local recorder process
|
|
27
|
-
│ ├─ Linux preferred: pw-record
|
|
28
|
-
│ ├─ Linux fallback: arecord
|
|
29
|
-
│ └─ macOS: afrecord, or ffmpeg/AVFoundation fallback
|
|
30
|
-
├─ ducks system output volume while the microphone is listening
|
|
31
|
-
├─ records a temporary 16 kHz mono 16-bit WAV
|
|
32
|
-
├─ parses the WAV container in TypeScript and extracts raw PCM
|
|
33
|
-
├─ sends PCM frames to the configured ASR provider via ws
|
|
34
|
-
│ └─ current provider: VolcEngine /api/v3/sauc/bigmodel_nostream
|
|
35
|
-
├─ optionally post-processes raw ASR text with a configured pi model
|
|
36
|
-
│ └─ default: disabled; set polishModel to enable it
|
|
37
|
-
└─ pastes the final transcript into pi's editor
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
Runtime package dependency:
|
|
41
|
-
|
|
42
|
-
- `ws`
|
|
43
|
-
|
|
44
|
-
System dependency, one of:
|
|
45
|
-
|
|
46
|
-
- Linux: `pw-record` from PipeWire tools, preferred
|
|
47
|
-
- Linux: `arecord` from alsa-utils, fallback
|
|
48
|
-
- macOS: `afrecord` when present, or `ffmpeg` from Homebrew (`brew install ffmpeg`) as the AVFoundation fallback
|
|
49
|
-
|
|
50
|
-
On macOS, grant Terminal, ffmpeg, or your pi host app microphone permission when prompted. If macOS has previously denied microphone access, enable it in System Settings → Privacy & Security → Microphone.
|
|
51
|
-
|
|
52
|
-
## Install / Update
|
|
53
|
-
|
|
54
|
-
Install the published package with pi:
|
|
28
|
+
## Install
|
|
55
29
|
|
|
56
30
|
```bash
|
|
57
31
|
pi install npm:pi-voice-input
|
|
58
32
|
```
|
|
59
33
|
|
|
60
|
-
Update
|
|
34
|
+
Update later with:
|
|
61
35
|
|
|
62
36
|
```bash
|
|
63
37
|
pi update npm:pi-voice-input
|
|
64
38
|
```
|
|
65
39
|
|
|
66
|
-
|
|
40
|
+
Restart pi after installing or updating.
|
|
67
41
|
|
|
68
|
-
##
|
|
42
|
+
## First-time setup
|
|
69
43
|
|
|
70
|
-
|
|
44
|
+
1. Install the extension:
|
|
71
45
|
|
|
72
|
-
|
|
46
|
+
```bash
|
|
47
|
+
pi install npm:pi-voice-input
|
|
48
|
+
```
|
|
73
49
|
|
|
74
|
-
|
|
50
|
+
2. Restart pi.
|
|
75
51
|
|
|
76
|
-
|
|
52
|
+
3. Create the local config:
|
|
77
53
|
|
|
78
|
-
|
|
79
|
-
|
|
54
|
+
```text
|
|
55
|
+
/voice init
|
|
56
|
+
```
|
|
80
57
|
|
|
81
|
-
|
|
58
|
+
4. Add your VolcEngine Speech API key:
|
|
82
59
|
|
|
83
|
-
|
|
60
|
+
```text
|
|
61
|
+
/voice key
|
|
62
|
+
```
|
|
84
63
|
|
|
85
|
-
|
|
86
|
-
~/.pi/agent/voice-input.config.json
|
|
87
|
-
```
|
|
64
|
+
Get your key here:
|
|
88
65
|
|
|
89
|
-
|
|
66
|
+
https://console.volcengine.com/speech/new/setting/apikeys?projectName=default
|
|
90
67
|
|
|
91
|
-
|
|
68
|
+
5. Check that pi sees your setup:
|
|
92
69
|
|
|
93
|
-
```text
|
|
94
|
-
/voice
|
|
95
|
-
```
|
|
70
|
+
```text
|
|
71
|
+
/voice config
|
|
72
|
+
```
|
|
96
73
|
|
|
97
|
-
|
|
74
|
+
6. Press `Ctrl+Shift+R`, speak, then press `Ctrl+Shift+R` again to insert the transcript.
|
|
75
|
+
|
|
76
|
+
## Use
|
|
77
|
+
|
|
78
|
+
Press:
|
|
98
79
|
|
|
99
80
|
```text
|
|
100
|
-
|
|
81
|
+
Ctrl+Shift+R
|
|
101
82
|
```
|
|
102
83
|
|
|
103
|
-
|
|
84
|
+
Then speak naturally in Chinese, English, or both. Press `Ctrl+Shift+R` again to stop recording. The recognized text appears in the editor at your cursor.
|
|
104
85
|
|
|
105
|
-
|
|
86
|
+
Useful commands:
|
|
106
87
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
88
|
+
```text
|
|
89
|
+
/voice start start recording
|
|
90
|
+
/voice stop stop, transcribe, and insert text
|
|
91
|
+
/voice toggle start or stop recording
|
|
92
|
+
/voice cancel stop and discard the recording
|
|
93
|
+
/voice status show current recorder state
|
|
94
|
+
/voice config show non-secret configuration
|
|
95
|
+
/voice key set or replace the API key
|
|
96
|
+
/voice help show setup help
|
|
117
97
|
```
|
|
118
98
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
`duckSystemVolume` is enabled by default. While recording, the extension lowers system output volume to `duckSystemVolumeFactor` of the original volume using a short ease-in/ease-out fade (`duckSystemVolumeFadeMs`), then restores the saved volume when recording stops or is cancelled. Linux uses `wpctl` or `pactl`; macOS uses `osascript`.
|
|
99
|
+
## Optional: polish dictated text
|
|
122
100
|
|
|
123
|
-
|
|
101
|
+
By default, pi inserts the raw transcript. To let a pi model clean up punctuation and wording, set `polishModel` in:
|
|
124
102
|
|
|
125
103
|
```text
|
|
126
|
-
/voice
|
|
104
|
+
~/.pi/agent/voice-input.config.json
|
|
127
105
|
```
|
|
128
106
|
|
|
129
|
-
|
|
107
|
+
Use any model name shown by:
|
|
130
108
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
```text
|
|
134
|
-
Ctrl+Shift+R
|
|
109
|
+
```bash
|
|
110
|
+
pi --list-models
|
|
135
111
|
```
|
|
136
112
|
|
|
137
|
-
|
|
113
|
+
Example:
|
|
138
114
|
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
/voice status # show recorder state
|
|
145
|
-
/voice config # show effective non-secret config and whether API key is detected
|
|
146
|
-
/voice init # create or normalize ~/.pi/agent/voice-input.config.json
|
|
147
|
-
/voice key # prompt for and save the current provider API key
|
|
148
|
-
/voice help # show setup help, including the explicit VolcEngine API key URL
|
|
115
|
+
```json
|
|
116
|
+
{
|
|
117
|
+
"volcApiKey": "",
|
|
118
|
+
"polishModel": "your-model-name"
|
|
119
|
+
}
|
|
149
120
|
```
|
|
150
121
|
|
|
151
|
-
|
|
122
|
+
If polishing fails, the raw transcript is inserted instead.
|
|
152
123
|
|
|
153
|
-
|
|
154
|
-
- The default ASR segment size is intentionally larger than realtime packet sizes because this workflow sends already-recorded audio.
|
|
155
|
-
- The transcript is inserted into the editor only; it is not submitted automatically.
|
|
156
|
-
- Recorder stdout/stderr is not logged to disk, to avoid retaining potentially sensitive runtime data.
|
|
157
|
-
- On startup, legacy `~/.pi/agent/voice-input/recordings` and `~/.pi/agent/voice-input/logs` artifacts are cleaned up when they are not part of an active recording.
|
|
158
|
-
- When `polishModel` is set, polishing uses the unsent editor draft and recent session messages as context, but outputs only the refined voice text to insert at the current cursor. It must not reconstruct the full draft; the final text is pasted without replacing existing editor content.
|
|
159
|
-
- While recording, the status line shows `● Mic on: [device name] — press Ctrl+Shift+R again to stop/transcribe` in the current theme accent color; no separate popup is shown when recording starts.
|
|
160
|
-
- By default, system output volume is ducked to 50% of its previous level with a 300 ms ease-in/ease-out fade while the microphone is listening, then restored after recording stops.
|
|
124
|
+
## System requirements
|
|
161
125
|
|
|
162
|
-
|
|
126
|
+
Linux needs one recording tool:
|
|
163
127
|
|
|
164
|
-
|
|
128
|
+
- `pw-record` from PipeWire tools, recommended
|
|
129
|
+
- or `arecord` from alsa-utils
|
|
165
130
|
|
|
166
|
-
|
|
131
|
+
macOS uses the built-in recorder when available. If recording does not work, install ffmpeg:
|
|
167
132
|
|
|
168
133
|
```bash
|
|
169
|
-
|
|
170
|
-
cd pi-voice-input
|
|
171
|
-
npm install
|
|
134
|
+
brew install ffmpeg
|
|
172
135
|
```
|
|
173
136
|
|
|
174
|
-
|
|
137
|
+
On macOS, allow microphone access for your terminal or pi host app when prompted. You can also check System Settings → Privacy & Security → Microphone.
|
|
175
138
|
|
|
176
|
-
|
|
177
|
-
pi -e .
|
|
178
|
-
```
|
|
139
|
+
## Privacy notes
|
|
179
140
|
|
|
180
|
-
|
|
141
|
+
- Your API key is stored locally in `~/.pi/agent/voice-input.config.json`.
|
|
142
|
+
- Recordings are temporary and are removed after use.
|
|
143
|
+
- Transcribed text is inserted into the editor so you can review it before submitting.
|
|
181
144
|
|
|
182
|
-
|
|
183
|
-
pi install .
|
|
184
|
-
```
|
|
145
|
+
## Troubleshooting
|
|
185
146
|
|
|
186
|
-
|
|
147
|
+
- Run `/voice status` to see whether recording is active.
|
|
148
|
+
- Run `/voice config` to confirm the API key is detected.
|
|
149
|
+
- Run `/voice key` again if the key was changed or expired.
|
|
150
|
+
- On macOS, check microphone permission if recording immediately fails.
|
|
151
|
+
- On Linux, make sure `pw-record` or `arecord` is installed and your microphone works in other apps.
|
|
187
152
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
153
|
+
## Development
|
|
154
|
+
|
|
155
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines.
|
|
191
156
|
|
|
192
157
|
## Roadmap
|
|
193
158
|
|
|
@@ -196,5 +161,4 @@ See [ROADMAP.md](ROADMAP.md) for planned user-visible work.
|
|
|
196
161
|
## Links
|
|
197
162
|
|
|
198
163
|
- API key settings: https://console.volcengine.com/speech/new/setting/apikeys?projectName=default
|
|
199
|
-
- ASR
|
|
200
|
-
- WebSocket ASR docs: https://www.volcengine.com/docs/6561/1354869?lang=zh
|
|
164
|
+
- VolcEngine ASR: https://www.volcengine.com/product/asr
|
|
@@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto";
|
|
|
6
6
|
import {
|
|
7
7
|
chmodSync,
|
|
8
8
|
existsSync,
|
|
9
|
+
lstatSync,
|
|
9
10
|
mkdirSync,
|
|
10
11
|
mkdtempSync,
|
|
11
12
|
readFileSync,
|
|
@@ -24,6 +25,25 @@ const CONFIG_PATH = path.join(homedir(), ".pi", "agent", "voice-input.config.jso
|
|
|
24
25
|
const VOLC_API_KEY_URL = "https://console.volcengine.com/speech/new/setting/apikeys?projectName=default";
|
|
25
26
|
const DEFAULT_SHORTCUT = Key.ctrlShift("r");
|
|
26
27
|
const DEFAULT_POSTPROCESS_MODEL = "";
|
|
28
|
+
const DEFAULT_POSTPROCESS_CONTEXT_TOKENS = 20000;
|
|
29
|
+
const POSTPROCESS_GIT_LOG_LIMIT = 10;
|
|
30
|
+
const POSTPROCESS_DIRECTORY_DEPTH = 2;
|
|
31
|
+
const POSTPROCESS_DIRECTORY_TOKENS = 20000;
|
|
32
|
+
const SKIPPED_DIRECTORY_ENTRIES = new Set([
|
|
33
|
+
".git",
|
|
34
|
+
".hg",
|
|
35
|
+
".svn",
|
|
36
|
+
"node_modules",
|
|
37
|
+
"dist",
|
|
38
|
+
"build",
|
|
39
|
+
"coverage",
|
|
40
|
+
".next",
|
|
41
|
+
".nuxt",
|
|
42
|
+
".turbo",
|
|
43
|
+
".cache",
|
|
44
|
+
"logs",
|
|
45
|
+
"recordings",
|
|
46
|
+
]);
|
|
27
47
|
const POSTPROCESS_SYSTEM_PROMPT = `You are the speech-recognition postprocessor for the pi voice input extension. Your only job is to polish the raw ASR text into text that the plugin can paste verbatim at the current cursor position in the pi editor.
|
|
28
48
|
|
|
29
49
|
Interaction contract:
|
|
@@ -88,7 +108,7 @@ type VoiceConfig = {
|
|
|
88
108
|
postprocessModel: string;
|
|
89
109
|
postprocessTimeoutMs: number;
|
|
90
110
|
postprocessMaxTokens: number;
|
|
91
|
-
|
|
111
|
+
postprocessContextTokens: number;
|
|
92
112
|
duckSystemVolume: boolean;
|
|
93
113
|
duckSystemVolumeFactor: number;
|
|
94
114
|
duckSystemVolumeFadeMs: number;
|
|
@@ -222,7 +242,7 @@ function getConfig(): VoiceConfig {
|
|
|
222
242
|
postprocessModel: polishModel,
|
|
223
243
|
postprocessTimeoutMs: 30000,
|
|
224
244
|
postprocessMaxTokens: 2048,
|
|
225
|
-
|
|
245
|
+
postprocessContextTokens: DEFAULT_POSTPROCESS_CONTEXT_TOKENS,
|
|
226
246
|
duckSystemVolume: fileConfig.duckSystemVolume,
|
|
227
247
|
duckSystemVolumeFactor: fileConfig.duckSystemVolumeFactor,
|
|
228
248
|
duckSystemVolumeFadeMs: fileConfig.duckSystemVolumeFadeMs,
|
|
@@ -956,16 +976,77 @@ async function transcribePcm(pcm: Buffer, durationMs: number, config: VoiceConfi
|
|
|
956
976
|
};
|
|
957
977
|
}
|
|
958
978
|
|
|
959
|
-
function
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
979
|
+
function estimateTokens(text: string): number {
|
|
980
|
+
let tokens = 0;
|
|
981
|
+
let asciiRun = 0;
|
|
982
|
+
const flushAscii = () => {
|
|
983
|
+
if (asciiRun > 0) {
|
|
984
|
+
tokens += Math.ceil(asciiRun / 4);
|
|
985
|
+
asciiRun = 0;
|
|
986
|
+
}
|
|
987
|
+
};
|
|
988
|
+
|
|
989
|
+
for (const char of text) {
|
|
990
|
+
if (/\s/u.test(char)) {
|
|
991
|
+
flushAscii();
|
|
992
|
+
} else if (/[^\x00-\x7F]/u.test(char)) {
|
|
993
|
+
flushAscii();
|
|
994
|
+
tokens += 1;
|
|
995
|
+
} else {
|
|
996
|
+
asciiRun += 1;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
flushAscii();
|
|
1000
|
+
return tokens;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function takeTokensFromStart(text: string, maxTokens: number): string {
|
|
1004
|
+
if (maxTokens <= 0 || !text) return "";
|
|
1005
|
+
if (estimateTokens(text) <= maxTokens) return text;
|
|
1006
|
+
|
|
1007
|
+
let low = 0;
|
|
1008
|
+
let high = text.length;
|
|
1009
|
+
while (low < high) {
|
|
1010
|
+
const mid = Math.ceil((low + high) / 2);
|
|
1011
|
+
if (estimateTokens(text.slice(0, mid)) <= maxTokens) low = mid;
|
|
1012
|
+
else high = mid - 1;
|
|
1013
|
+
}
|
|
1014
|
+
return text.slice(0, low);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function takeTokensFromEnd(text: string, maxTokens: number): string {
|
|
1018
|
+
if (maxTokens <= 0 || !text) return "";
|
|
1019
|
+
if (estimateTokens(text) <= maxTokens) return text;
|
|
1020
|
+
|
|
1021
|
+
let low = 0;
|
|
1022
|
+
let high = text.length;
|
|
1023
|
+
while (low < high) {
|
|
1024
|
+
const mid = Math.ceil((low + high) / 2);
|
|
1025
|
+
if (estimateTokens(text.slice(text.length - mid)) <= maxTokens) low = mid;
|
|
1026
|
+
else high = mid - 1;
|
|
1027
|
+
}
|
|
1028
|
+
return text.slice(text.length - low);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function limitTokensFromStart(text: string, maxTokens: number, marker = "\n…(truncated to fit token budget)"): string {
|
|
1032
|
+
if (maxTokens <= 0) return "";
|
|
1033
|
+
if (estimateTokens(text) <= maxTokens) return text;
|
|
1034
|
+
const markerTokens = estimateTokens(marker);
|
|
1035
|
+
return `${takeTokensFromStart(text, Math.max(0, maxTokens - markerTokens)).trimEnd()}${marker}`;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function limitTokensFromEnd(text: string, maxTokens: number, marker = "…(older context omitted to fit token budget)\n"): string {
|
|
1039
|
+
if (maxTokens <= 0) return "";
|
|
1040
|
+
if (estimateTokens(text) <= maxTokens) return text;
|
|
1041
|
+
const markerTokens = estimateTokens(marker);
|
|
1042
|
+
return `${marker}${takeTokensFromEnd(text, Math.max(0, maxTokens - markerTokens)).trimStart()}`;
|
|
963
1043
|
}
|
|
964
1044
|
|
|
965
|
-
function
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1045
|
+
function redactSensitiveText(text: string): string {
|
|
1046
|
+
return text
|
|
1047
|
+
.replace(/(\bVOLC_API_KEY\s*=\s*)[^\s"']+/giu, "$1[redacted]")
|
|
1048
|
+
.replace(/((?:\b|["'])(?:volcApiKey|api[_-]?key|apikey|access[_-]?token|secret|password)(?:\b|["'])\s*[:=]\s*["']?)[^"'\s,}]+/giu, "$1[redacted]")
|
|
1049
|
+
.replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/giu, "[redacted-uuid]");
|
|
969
1050
|
}
|
|
970
1051
|
|
|
971
1052
|
function textFromContent(content: unknown): string {
|
|
@@ -982,27 +1063,163 @@ function textFromContent(content: unknown): string {
|
|
|
982
1063
|
.join("\n");
|
|
983
1064
|
}
|
|
984
1065
|
|
|
985
|
-
function getEditorContext(ctx: ExtensionContext
|
|
986
|
-
if (maxChars <= 0) return "";
|
|
1066
|
+
function getEditorContext(ctx: ExtensionContext): string {
|
|
987
1067
|
try {
|
|
988
|
-
return
|
|
1068
|
+
return redactSensitiveText(ctx.ui.getEditorText()).trim();
|
|
989
1069
|
} catch {
|
|
990
1070
|
return "";
|
|
991
1071
|
}
|
|
992
1072
|
}
|
|
993
1073
|
|
|
994
|
-
function getRecentSessionContext(ctx: ExtensionContext
|
|
995
|
-
if (maxChars <= 0) return "";
|
|
1074
|
+
function getRecentSessionContext(ctx: ExtensionContext): string {
|
|
996
1075
|
const lines: string[] = [];
|
|
997
1076
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
998
1077
|
if (entry.type !== "message") continue;
|
|
999
1078
|
const message = entry.message as { role?: unknown; content?: unknown };
|
|
1000
1079
|
if (message.role !== "user" && message.role !== "assistant") continue;
|
|
1001
|
-
const text = textFromContent(message.content)
|
|
1080
|
+
const text = redactSensitiveText(textFromContent(message.content)).trim();
|
|
1002
1081
|
if (!text) continue;
|
|
1003
|
-
lines.push(`${message.role}: ${
|
|
1082
|
+
lines.push(`${message.role}: ${text}`);
|
|
1004
1083
|
}
|
|
1005
|
-
return
|
|
1084
|
+
return lines.join("\n\n");
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function buildConversationContext(ctx: ExtensionContext, maxTokens: number): { editorContext: string; sessionContext: string } {
|
|
1088
|
+
if (maxTokens <= 0) return { editorContext: "", sessionContext: "" };
|
|
1089
|
+
|
|
1090
|
+
const editorContext = getEditorContext(ctx);
|
|
1091
|
+
const sessionContext = getRecentSessionContext(ctx);
|
|
1092
|
+
const editorSection = [
|
|
1093
|
+
"--- Context: current unsent editor draft (context only; do not output wholesale) ---",
|
|
1094
|
+
editorContext || "(empty)",
|
|
1095
|
+
].join("\n");
|
|
1096
|
+
const sessionHeader = "--- Context: recent conversation ---\n";
|
|
1097
|
+
const totalTokens = estimateTokens([editorSection, sessionHeader, sessionContext || "(empty)"].join("\n\n"));
|
|
1098
|
+
if (totalTokens <= maxTokens) return { editorContext, sessionContext };
|
|
1099
|
+
|
|
1100
|
+
const editorTokenBudget = Math.min(5000, Math.floor(maxTokens / 4));
|
|
1101
|
+
const limitedEditor = editorContext ? limitTokensFromEnd(editorContext, editorTokenBudget) : "";
|
|
1102
|
+
const fixedTokens = estimateTokens(
|
|
1103
|
+
[
|
|
1104
|
+
"--- Context: current unsent editor draft (context only; do not output wholesale) ---",
|
|
1105
|
+
limitedEditor || "(empty)",
|
|
1106
|
+
sessionHeader,
|
|
1107
|
+
].join("\n\n"),
|
|
1108
|
+
);
|
|
1109
|
+
const sessionBudget = Math.max(0, maxTokens - fixedTokens);
|
|
1110
|
+
return {
|
|
1111
|
+
editorContext: limitedEditor,
|
|
1112
|
+
sessionContext: sessionContext ? limitTokensFromEnd(sessionContext, sessionBudget) : "",
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function commandOutputInDir(cwd: string, command: string, args: string[], timeoutMs = 2500): string {
|
|
1117
|
+
const result = spawnSync(command, args, { cwd, encoding: "utf8", timeout: timeoutMs, maxBuffer: 1024 * 1024 * 8 });
|
|
1118
|
+
if (result.status !== 0) return "";
|
|
1119
|
+
return (result.stdout || "").trim();
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function findGitRoot(startDir: string): string | null {
|
|
1123
|
+
const root = commandOutputInDir(startDir, "git", ["rev-parse", "--show-toplevel"], 1500);
|
|
1124
|
+
return root || null;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function buildGitContext(cwd: string): string {
|
|
1128
|
+
const gitRoot = findGitRoot(cwd);
|
|
1129
|
+
if (!gitRoot) return "(not a git repository)";
|
|
1130
|
+
|
|
1131
|
+
const recentLog = commandOutputInDir(
|
|
1132
|
+
gitRoot,
|
|
1133
|
+
"git",
|
|
1134
|
+
["log", `-${POSTPROCESS_GIT_LOG_LIMIT}`, "--date=short", "--pretty=format:%h %ad %an %s"],
|
|
1135
|
+
2500,
|
|
1136
|
+
);
|
|
1137
|
+
|
|
1138
|
+
return [
|
|
1139
|
+
`Repository root: ${gitRoot}`,
|
|
1140
|
+
`Recent commits (up to ${POSTPROCESS_GIT_LOG_LIMIT}):`,
|
|
1141
|
+
recentLog ? redactSensitiveText(recentLog) : "(no commits yet)",
|
|
1142
|
+
].join("\n");
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function shouldSkipDirectoryEntry(name: string): boolean {
|
|
1146
|
+
return SKIPPED_DIRECTORY_ENTRIES.has(name);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function safeReadDir(dir: string): string[] {
|
|
1150
|
+
try {
|
|
1151
|
+
return readdirSync(dir).sort((a, b) => a.localeCompare(b));
|
|
1152
|
+
} catch {
|
|
1153
|
+
return [];
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function formatDirectoryEntry(fullPath: string, name: string, isCwd = false): string {
|
|
1158
|
+
try {
|
|
1159
|
+
const stat = lstatSync(fullPath);
|
|
1160
|
+
const suffix = stat.isDirectory() ? "/" : stat.isSymbolicLink() ? "@" : "";
|
|
1161
|
+
return `${name}${suffix}${isCwd ? " <-- current" : ""}`;
|
|
1162
|
+
} catch {
|
|
1163
|
+
return `${name}${isCwd ? " <-- current" : ""}`;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function buildTreeLines(dir: string, depth: number, prefix = "", state = { entries: 0, omitted: 0 }): string[] {
|
|
1168
|
+
if (depth < 0) return [];
|
|
1169
|
+
const names = safeReadDir(dir).filter((name) => !shouldSkipDirectoryEntry(name));
|
|
1170
|
+
const visibleNames = names.slice(0, 120);
|
|
1171
|
+
state.omitted += Math.max(0, names.length - visibleNames.length);
|
|
1172
|
+
const lines: string[] = [];
|
|
1173
|
+
|
|
1174
|
+
visibleNames.forEach((name, index) => {
|
|
1175
|
+
const fullPath = path.join(dir, name);
|
|
1176
|
+
const isLast = index === visibleNames.length - 1;
|
|
1177
|
+
const connector = isLast ? "└── " : "├── ";
|
|
1178
|
+
const childPrefix = `${prefix}${isLast ? " " : "│ "}`;
|
|
1179
|
+
const label = formatDirectoryEntry(fullPath, name);
|
|
1180
|
+
lines.push(`${prefix}${connector}${label}`);
|
|
1181
|
+
state.entries += 1;
|
|
1182
|
+
|
|
1183
|
+
try {
|
|
1184
|
+
const stat = lstatSync(fullPath);
|
|
1185
|
+
if (stat.isDirectory() && !stat.isSymbolicLink() && depth > 0) {
|
|
1186
|
+
lines.push(...buildTreeLines(fullPath, depth - 1, childPrefix, state));
|
|
1187
|
+
}
|
|
1188
|
+
} catch {
|
|
1189
|
+
// ignore unreadable entries
|
|
1190
|
+
}
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
return lines;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
function buildDirectoryContext(cwd: string, maxTokens: number): string {
|
|
1197
|
+
if (maxTokens <= 0) return "";
|
|
1198
|
+
const parent = path.dirname(cwd);
|
|
1199
|
+
const isRootDirectory = path.resolve(parent) === path.resolve(cwd);
|
|
1200
|
+
const cwdName = path.basename(cwd) || cwd;
|
|
1201
|
+
const parentEntries = isRootDirectory
|
|
1202
|
+
? []
|
|
1203
|
+
: safeReadDir(parent)
|
|
1204
|
+
.filter((name) => !shouldSkipDirectoryEntry(name))
|
|
1205
|
+
.slice(0, 160)
|
|
1206
|
+
.map((name) => formatDirectoryEntry(path.join(parent, name), name, path.resolve(parent, name) === path.resolve(cwd)));
|
|
1207
|
+
const state = { entries: 0, omitted: 0 };
|
|
1208
|
+
const cwdTree = buildTreeLines(cwd, POSTPROCESS_DIRECTORY_DEPTH - 1, "", state);
|
|
1209
|
+
const text = [
|
|
1210
|
+
`Current directory: ${cwd}`,
|
|
1211
|
+
isRootDirectory ? "Parent directory: (current directory is filesystem root; no parent directory)" : `Parent directory: ${parent}`,
|
|
1212
|
+
"Parent entries (one level up):",
|
|
1213
|
+
isRootDirectory ? "- (none; current directory is filesystem root)" : parentEntries.length ? parentEntries.map((entry) => `- ${entry}`).join("\n") : "- (empty or unreadable)",
|
|
1214
|
+
"",
|
|
1215
|
+
`Current directory tree (${cwdName}/, ${POSTPROCESS_DIRECTORY_DEPTH} levels deep):`,
|
|
1216
|
+
`${cwdName}/`,
|
|
1217
|
+
cwdTree.length ? cwdTree.join("\n") : "└── (empty or no readable child entries)",
|
|
1218
|
+
state.omitted > 0 ? `…(${state.omitted} entries omitted)` : "",
|
|
1219
|
+
]
|
|
1220
|
+
.filter(Boolean)
|
|
1221
|
+
.join("\n");
|
|
1222
|
+
return limitTokensFromStart(text, maxTokens);
|
|
1006
1223
|
}
|
|
1007
1224
|
|
|
1008
1225
|
function simplifyModelReference(value: string): string {
|
|
@@ -1145,9 +1362,10 @@ function getFullEditorText(ctx: ExtensionContext): string {
|
|
|
1145
1362
|
}
|
|
1146
1363
|
|
|
1147
1364
|
function buildPostprocessPrompt(ctx: ExtensionContext, rawText: string, config: VoiceConfig): string {
|
|
1148
|
-
const
|
|
1149
|
-
const
|
|
1150
|
-
const
|
|
1365
|
+
const { editorContext, sessionContext } = buildConversationContext(ctx, config.postprocessContextTokens);
|
|
1366
|
+
const cwd = process.cwd();
|
|
1367
|
+
const gitContext = buildGitContext(cwd);
|
|
1368
|
+
const directoryContext = buildDirectoryContext(cwd, POSTPROCESS_DIRECTORY_TOKENS);
|
|
1151
1369
|
|
|
1152
1370
|
return [
|
|
1153
1371
|
"Polish only the raw ASR text below, using context only when it helps disambiguate the user's intent.",
|
|
@@ -1160,15 +1378,22 @@ function buildPostprocessPrompt(ctx: ExtensionContext, rawText: string, config:
|
|
|
1160
1378
|
"The true cursor position is not marked in the draft shown here; the pi editor owns the actual insertion point. Do not guess the cursor and synthesize a full surrounding sentence.",
|
|
1161
1379
|
"Preserve layout: if the raw ASR text is one line, output one line unless the user explicitly dictated line breaks or another multiline layout.",
|
|
1162
1380
|
"If the raw speech is an inline insertion, continuation, a few words, or a phrase, output only the newly spoken words or phrase.",
|
|
1381
|
+
"Use the git history and directory structure only as reference context for project names, files, APIs, and intent; never summarize them in the output.",
|
|
1163
1382
|
"Example: draft is `Please make this function async and [cursor].`, raw speech is `add error handling`, correct output is `add error handling`, not `Please make this function async and add error handling.`.",
|
|
1164
1383
|
"Example: draft is `This variable name is [cursor]unclear`, raw speech is `still`, correct output is `still`, not `This variable name is still unclear`.",
|
|
1165
1384
|
"",
|
|
1166
1385
|
"--- Context: current unsent editor draft (context only; do not output wholesale) ---",
|
|
1167
|
-
editorContext
|
|
1386
|
+
editorContext || "(empty)",
|
|
1168
1387
|
"",
|
|
1169
|
-
"--- Context: recent conversation ---",
|
|
1388
|
+
"--- Context: recent conversation (recent tail, capped at 20k estimated tokens with the editor draft) ---",
|
|
1170
1389
|
sessionContext || "(empty)",
|
|
1171
1390
|
"",
|
|
1391
|
+
"--- Context: git history (latest commit summaries only) ---",
|
|
1392
|
+
gitContext || "(empty)",
|
|
1393
|
+
"",
|
|
1394
|
+
"--- Context: directory structure (parent one level; current directory two levels deep) ---",
|
|
1395
|
+
directoryContext || "(empty)",
|
|
1396
|
+
"",
|
|
1172
1397
|
"--- Raw ASR text ---",
|
|
1173
1398
|
rawText.trim(),
|
|
1174
1399
|
].join("\n");
|