pi-voice-input 0.2.15 → 0.3.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 +8 -18
- package/ROADMAP.md +1 -1
- package/extensions/voice-input.ts +23 -579
- package/package.json +1 -6
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Typing long prompts can slow you down. `pi-voice-input` lets you:
|
|
|
11
11
|
- speak naturally in Chinese, English, or a mix of both
|
|
12
12
|
- keep your hands on the keyboard with a simple toggle shortcut
|
|
13
13
|
- review or edit the inserted text before you submit it
|
|
14
|
-
-
|
|
14
|
+
- pass the raw transcript to the model with an explicit voice-input caveat
|
|
15
15
|
|
|
16
16
|
## Features
|
|
17
17
|
|
|
@@ -20,7 +20,7 @@ Typing long prompts can slow you down. `pi-voice-input` lets you:
|
|
|
20
20
|
- **Chinese/English mixed input**: handles prompts that switch between Chinese, English, product names, and technical terms.
|
|
21
21
|
- **Works on Linux and macOS**: uses common system recording tools.
|
|
22
22
|
- **Lowers sound while you speak**: automatically turns down system audio during recording, then restores it afterwards.
|
|
23
|
-
- **
|
|
23
|
+
- **No hidden rewriting**: inserts the raw ASR transcript, prefixed with a short note that it may contain voice-recognition errors.
|
|
24
24
|
- **Simple setup commands**: configure from inside pi with `/voice init` and `/voice key`.
|
|
25
25
|
|
|
26
26
|
Current speech provider: **VolcEngine Speech ASR**. A VolcEngine Speech API key is required.
|
|
@@ -96,31 +96,21 @@ Useful commands:
|
|
|
96
96
|
/voice help show setup help
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
-
##
|
|
99
|
+
## Inserted text format
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
The extension does not call a model to modify or translate your transcript. It inserts a short caveat telling the downstream model that the following text came from voice input and may contain recognition errors, then appends the raw ASR transcript unchanged.
|
|
102
102
|
|
|
103
|
-
|
|
104
|
-
~/.pi/agent/voice-input.config.json
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
Use any model name shown by:
|
|
108
|
-
|
|
109
|
-
```bash
|
|
110
|
-
pi --list-models
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
Example:
|
|
103
|
+
User config keys are:
|
|
114
104
|
|
|
115
105
|
```json
|
|
116
106
|
{
|
|
117
107
|
"volcApiKey": "",
|
|
118
|
-
"
|
|
108
|
+
"duckSystemVolume": true,
|
|
109
|
+
"duckSystemVolumeFactor": 0.5,
|
|
110
|
+
"duckSystemVolumeFadeMs": 300
|
|
119
111
|
}
|
|
120
112
|
```
|
|
121
113
|
|
|
122
|
-
If polishing fails, the raw transcript is inserted instead.
|
|
123
|
-
|
|
124
114
|
## System requirements
|
|
125
115
|
|
|
126
116
|
Linux needs one recording tool:
|
package/ROADMAP.md
CHANGED
|
@@ -7,7 +7,7 @@ This roadmap lists user-visible work planned for pi Voice Input. It is intention
|
|
|
7
7
|
- Linux voice input through `pw-record` or `arecord`
|
|
8
8
|
- macOS voice input through the system `afrecord` command
|
|
9
9
|
- VolcEngine WebSocket ASR
|
|
10
|
-
-
|
|
10
|
+
- Raw ASR transcript insertion with a voice-input caveat wrapper
|
|
11
11
|
|
|
12
12
|
## Planned
|
|
13
13
|
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { completeSimple, type Api, type Model } from "@earendil-works/pi-ai";
|
|
3
2
|
import { Key } from "@earendil-works/pi-tui";
|
|
4
3
|
import { spawn, spawnSync } from "node:child_process";
|
|
5
4
|
import { randomUUID } from "node:crypto";
|
|
6
5
|
import {
|
|
7
6
|
chmodSync,
|
|
8
7
|
existsSync,
|
|
9
|
-
lstatSync,
|
|
10
8
|
mkdirSync,
|
|
11
9
|
mkdtempSync,
|
|
12
10
|
readFileSync,
|
|
@@ -23,48 +21,16 @@ import WebSocket from "ws";
|
|
|
23
21
|
|
|
24
22
|
const CONFIG_PATH = path.join(homedir(), ".pi", "agent", "voice-input.config.json");
|
|
25
23
|
const VOLC_API_KEY_URL = "https://console.volcengine.com/speech/new/setting/apikeys?projectName=default";
|
|
24
|
+
// pi-bridge redirects child_process calls whose cwd is inside the bridged fake
|
|
25
|
+
// project directory. Audio capture and system volume control must always use
|
|
26
|
+
// the local machine, so force those subprocesses to a known-local cwd.
|
|
27
|
+
const LOCAL_AUDIO_PROCESS_CWD = homedir();
|
|
26
28
|
const DEFAULT_SHORTCUT = Key.ctrlShift("r");
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
]);
|
|
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.
|
|
48
|
-
|
|
49
|
-
Interaction contract:
|
|
50
|
-
- The plugin does not replace editor content with your output. It only pastes/inserts your output at the user's current cursor position.
|
|
51
|
-
- The current editor draft and recent conversation are context only. Use them to understand omitted references, the current task, file/project names, and intent. They are not text for you to rewrite and output as a whole.
|
|
52
|
-
- Do not output the draft, a context sentence, or a full sentence/paragraph that represents the draft after insertion. Doing so would duplicate existing editor content.
|
|
53
|
-
- You may not know the real cursor position. Do not guess the cursor location and synthesize a full surrounding sentence; the editor owns the real insertion point.
|
|
54
|
-
- If the raw speech is adding a few words, half a sentence, a phrase, a condition, or a modifier, output only those newly spoken words. Let the paste operation merge them with the existing draft.
|
|
55
|
-
- Only when the raw speech itself explicitly dictates a complete passage to insert may you output that complete passage. Even then, do not add draft text that the user did not speak.
|
|
56
|
-
|
|
57
|
-
Rules:
|
|
58
|
-
- Output only the polished insertion text. Do not output explanations, headings, prefixes, suffixes, quotes, code fences, or greetings.
|
|
59
|
-
- Never answer, execute, or solve anything asked in the user's speech. If the raw speech is a question, only clean up the question text itself; do not provide an answer, plan, code, or conclusion.
|
|
60
|
-
- Preserve the user's information faithfully. Do not over-summarize or compress. Do not delete constraints, examples, numbers, filenames, errors, multiple requests, ordering, or emphasis.
|
|
61
|
-
- Correct obvious ASR mistakes, homophones, segmentation, and punctuation. Preserve code identifiers, commands, paths, URLs, model names, package names, and proper nouns.
|
|
62
|
-
- If the user self-corrects, keep only the corrected intent and remove the false start, correction process, filler, and chatter. Do not lose any other substantive information.
|
|
63
|
-
- Make the output complete relative to the raw speech, logically clear, and actionable, but do not drop raw-speech information or repeat existing draft text.
|
|
64
|
-
- Preserve the raw speech layout. If the raw speech is a single line, output a single line unless the user explicitly dictates line breaks or another multiline layout, for example by saying "new line" or "换行".
|
|
65
|
-
- Do not introduce line breaks, bullets, numbered lists, tables, or code fences merely to improve style.
|
|
66
|
-
- Do not invent requirements that the raw speech did not express. If uncertain, keep the original meaning and express it more clearly.
|
|
67
|
-
- The output language must match the primary language of the raw speech, not the context language and not this English prompt. Do not translate just because the instructions are in English.`;
|
|
29
|
+
const VOICE_TRANSCRIPT_NOTICE = [
|
|
30
|
+
"以下内容来自用户通过语音输入的原始转写,可能包含语音识别错误、错别字、同音词、断句或标点不准确,以及中英文、术语、代码名、文件名误识别等问题。",
|
|
31
|
+
"请明确注意:这段转写不一定完全准确。请结合上下文理解用户意图;必要时可以先澄清、翻译或推断,再继续处理。",
|
|
32
|
+
"原始语音转写如下:",
|
|
33
|
+
].join("\n");
|
|
68
34
|
|
|
69
35
|
const MSG_TYPE_CLIENT_FULL_REQUEST = 0b0001;
|
|
70
36
|
const MSG_TYPE_CLIENT_AUDIO_ONLY_REQUEST = 0b0010;
|
|
@@ -80,7 +46,6 @@ type JsonObject = Record<string, unknown>;
|
|
|
80
46
|
|
|
81
47
|
type VoiceInputConfigFile = {
|
|
82
48
|
volcApiKey: string;
|
|
83
|
-
polishModel: string;
|
|
84
49
|
duckSystemVolume: boolean;
|
|
85
50
|
duckSystemVolumeFactor: number;
|
|
86
51
|
duckSystemVolumeFadeMs: number;
|
|
@@ -104,11 +69,6 @@ type VoiceConfig = {
|
|
|
104
69
|
enablePunc: boolean;
|
|
105
70
|
enableDdc: boolean;
|
|
106
71
|
showUtterances: boolean;
|
|
107
|
-
postprocessEnabled: boolean;
|
|
108
|
-
postprocessModel: string;
|
|
109
|
-
postprocessTimeoutMs: number;
|
|
110
|
-
postprocessMaxTokens: number;
|
|
111
|
-
postprocessContextTokens: number;
|
|
112
72
|
duckSystemVolume: boolean;
|
|
113
73
|
duckSystemVolumeFactor: number;
|
|
114
74
|
duckSystemVolumeFadeMs: number;
|
|
@@ -158,7 +118,6 @@ function ensureDir(dir: string) {
|
|
|
158
118
|
function defaultConfigFile(): VoiceInputConfigFile {
|
|
159
119
|
return {
|
|
160
120
|
volcApiKey: "",
|
|
161
|
-
polishModel: DEFAULT_POSTPROCESS_MODEL,
|
|
162
121
|
duckSystemVolume: true,
|
|
163
122
|
duckSystemVolumeFactor: 0.5,
|
|
164
123
|
duckSystemVolumeFadeMs: 300,
|
|
@@ -193,7 +152,6 @@ function normalizeConfigFile(input: unknown): VoiceInputConfigFile {
|
|
|
193
152
|
const root = isObject(input) ? input : {};
|
|
194
153
|
return {
|
|
195
154
|
volcApiKey: stringField(root, "volcApiKey", defaults.volcApiKey).trim(),
|
|
196
|
-
polishModel: stringField(root, "polishModel", defaults.polishModel).trim(),
|
|
197
155
|
duckSystemVolume: booleanField(root, "duckSystemVolume", defaults.duckSystemVolume),
|
|
198
156
|
duckSystemVolumeFactor: clamp(numberField(root, "duckSystemVolumeFactor", defaults.duckSystemVolumeFactor), 0, 1),
|
|
199
157
|
duckSystemVolumeFadeMs: Math.round(clamp(numberField(root, "duckSystemVolumeFadeMs", defaults.duckSystemVolumeFadeMs), 0, 3000)),
|
|
@@ -218,7 +176,6 @@ function loadConfigFile(): VoiceInputConfigFile {
|
|
|
218
176
|
function getConfig(): VoiceConfig {
|
|
219
177
|
const fileConfig = loadConfigFile();
|
|
220
178
|
const voiceHome = path.join(homedir(), ".pi", "agent", "voice-input");
|
|
221
|
-
const polishModel = fileConfig.polishModel.trim();
|
|
222
179
|
|
|
223
180
|
return {
|
|
224
181
|
configPath: CONFIG_PATH,
|
|
@@ -238,11 +195,6 @@ function getConfig(): VoiceConfig {
|
|
|
238
195
|
enablePunc: true,
|
|
239
196
|
enableDdc: false,
|
|
240
197
|
showUtterances: false,
|
|
241
|
-
postprocessEnabled: polishModel.length > 0,
|
|
242
|
-
postprocessModel: polishModel,
|
|
243
|
-
postprocessTimeoutMs: 30000,
|
|
244
|
-
postprocessMaxTokens: 2048,
|
|
245
|
-
postprocessContextTokens: DEFAULT_POSTPROCESS_CONTEXT_TOKENS,
|
|
246
198
|
duckSystemVolume: fileConfig.duckSystemVolume,
|
|
247
199
|
duckSystemVolumeFactor: fileConfig.duckSystemVolumeFactor,
|
|
248
200
|
duckSystemVolumeFadeMs: fileConfig.duckSystemVolumeFadeMs,
|
|
@@ -267,17 +219,17 @@ function timestampForFilename(): string {
|
|
|
267
219
|
}
|
|
268
220
|
|
|
269
221
|
function commandExists(command: string): boolean {
|
|
270
|
-
return spawnSync("sh", ["-lc", `command -v ${command}`], { stdio: "ignore" }).status === 0;
|
|
222
|
+
return spawnSync("sh", ["-lc", `command -v ${command}`], { cwd: LOCAL_AUDIO_PROCESS_CWD, stdio: "ignore" }).status === 0;
|
|
271
223
|
}
|
|
272
224
|
|
|
273
225
|
function commandOutput(command: string, args: string[], timeoutMs = 1500): string {
|
|
274
|
-
const result = spawnSync(command, args, { encoding: "utf8", timeout: timeoutMs });
|
|
226
|
+
const result = spawnSync(command, args, { cwd: LOCAL_AUDIO_PROCESS_CWD, encoding: "utf8", timeout: timeoutMs });
|
|
275
227
|
if (result.status !== 0) return "";
|
|
276
228
|
return (result.stdout || "").trim();
|
|
277
229
|
}
|
|
278
230
|
|
|
279
231
|
function runCommand(command: string, args: string[], timeoutMs = 1500): boolean {
|
|
280
|
-
return spawnSync(command, args, { stdio: "ignore", timeout: timeoutMs }).status === 0;
|
|
232
|
+
return spawnSync(command, args, { cwd: LOCAL_AUDIO_PROCESS_CWD, stdio: "ignore", timeout: timeoutMs }).status === 0;
|
|
281
233
|
}
|
|
282
234
|
|
|
283
235
|
function formatPercent(value: number): string {
|
|
@@ -976,498 +928,12 @@ async function transcribePcm(pcm: Buffer, durationMs: number, config: VoiceConfi
|
|
|
976
928
|
};
|
|
977
929
|
}
|
|
978
930
|
|
|
979
|
-
function
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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()}`;
|
|
1043
|
-
}
|
|
1044
|
-
|
|
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]");
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
function textFromContent(content: unknown): string {
|
|
1053
|
-
if (typeof content === "string") return content;
|
|
1054
|
-
if (!Array.isArray(content)) return "";
|
|
1055
|
-
return content
|
|
1056
|
-
.map((part) => {
|
|
1057
|
-
if (!part || typeof part !== "object") return "";
|
|
1058
|
-
const block = part as { type?: unknown; text?: unknown };
|
|
1059
|
-
if (block.type === "text" && typeof block.text === "string") return block.text;
|
|
1060
|
-
return "";
|
|
1061
|
-
})
|
|
1062
|
-
.filter(Boolean)
|
|
1063
|
-
.join("\n");
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
function getEditorContext(ctx: ExtensionContext): string {
|
|
1067
|
-
try {
|
|
1068
|
-
return redactSensitiveText(ctx.ui.getEditorText()).trim();
|
|
1069
|
-
} catch {
|
|
1070
|
-
return "";
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
function getRecentSessionContext(ctx: ExtensionContext): string {
|
|
1075
|
-
const lines: string[] = [];
|
|
1076
|
-
for (const entry of ctx.sessionManager.getBranch()) {
|
|
1077
|
-
if (entry.type !== "message") continue;
|
|
1078
|
-
const message = entry.message as { role?: unknown; content?: unknown };
|
|
1079
|
-
if (message.role !== "user" && message.role !== "assistant") continue;
|
|
1080
|
-
const text = redactSensitiveText(textFromContent(message.content)).trim();
|
|
1081
|
-
if (!text) continue;
|
|
1082
|
-
lines.push(`${message.role}: ${text}`);
|
|
1083
|
-
}
|
|
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
|
-
}
|
|
931
|
+
function wrapVoiceTranscript(rawText: string): string {
|
|
932
|
+
const trimmed = rawText.trim();
|
|
933
|
+
if (!trimmed) return "";
|
|
934
|
+
return `${VOICE_TRANSCRIPT_NOTICE}
|
|
1156
935
|
|
|
1157
|
-
|
|
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);
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
function simplifyModelReference(value: string): string {
|
|
1226
|
-
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
1227
|
-
}
|
|
1228
|
-
|
|
1229
|
-
function stripThinkingSuffix(value: string): string {
|
|
1230
|
-
return value.replace(/:(?:off|minimal|low|medium|high|xhigh)$/i, "");
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1233
|
-
function modelLabel(model: Model<Api>): string {
|
|
1234
|
-
return `${model.provider}/${model.id}`;
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
function resolvePostprocessModel(ctx: ExtensionContext, reference: string): Model<Api> {
|
|
1238
|
-
const requested = stripThinkingSuffix(reference.trim());
|
|
1239
|
-
if (!requested) throw new Error("polishModel is empty in voice input config");
|
|
1240
|
-
|
|
1241
|
-
const models = ctx.modelRegistry.getAll();
|
|
1242
|
-
const lower = requested.toLowerCase();
|
|
1243
|
-
const simple = simplifyModelReference(requested);
|
|
1244
|
-
|
|
1245
|
-
const exactCanonical = models.filter((model) => modelLabel(model).toLowerCase() === lower);
|
|
1246
|
-
if (exactCanonical.length === 1) return exactCanonical[0];
|
|
1247
|
-
|
|
1248
|
-
const exactBare = models.filter((model) => model.id.toLowerCase() === lower || model.name.toLowerCase() === lower);
|
|
1249
|
-
if (exactBare.length === 1) return exactBare[0];
|
|
1250
|
-
if (exactBare.length > 1) {
|
|
1251
|
-
throw new Error(
|
|
1252
|
-
`Ambiguous postprocess model "${reference}". Use provider/model, e.g. ${exactBare.map(modelLabel).slice(0, 5).join(", ")}`,
|
|
1253
|
-
);
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
|
-
const exactSimple = models.filter(
|
|
1257
|
-
(model) =>
|
|
1258
|
-
simplifyModelReference(modelLabel(model)) === simple ||
|
|
1259
|
-
simplifyModelReference(model.id) === simple ||
|
|
1260
|
-
simplifyModelReference(model.name) === simple,
|
|
1261
|
-
);
|
|
1262
|
-
if (exactSimple.length === 1) return exactSimple[0];
|
|
1263
|
-
if (exactSimple.length > 1) {
|
|
1264
|
-
throw new Error(
|
|
1265
|
-
`Ambiguous postprocess model "${reference}". Use provider/model, e.g. ${exactSimple.map(modelLabel).slice(0, 5).join(", ")}`,
|
|
1266
|
-
);
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
const fuzzy = models.filter(
|
|
1270
|
-
(model) =>
|
|
1271
|
-
modelLabel(model).toLowerCase().includes(lower) ||
|
|
1272
|
-
model.id.toLowerCase().includes(lower) ||
|
|
1273
|
-
model.name.toLowerCase().includes(lower) ||
|
|
1274
|
-
simplifyModelReference(modelLabel(model)).includes(simple) ||
|
|
1275
|
-
simplifyModelReference(model.id).includes(simple) ||
|
|
1276
|
-
simplifyModelReference(model.name).includes(simple),
|
|
1277
|
-
);
|
|
1278
|
-
if (fuzzy.length === 1) return fuzzy[0];
|
|
1279
|
-
if (fuzzy.length > 1) {
|
|
1280
|
-
throw new Error(
|
|
1281
|
-
`Ambiguous postprocess model "${reference}". Use provider/model, e.g. ${fuzzy.map(modelLabel).slice(0, 5).join(", ")}`,
|
|
1282
|
-
);
|
|
1283
|
-
}
|
|
1284
|
-
|
|
1285
|
-
throw new Error(`Postprocess model "${reference}" not found. Run pi --list-models to see available models.`);
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
function extractAssistantText(message: { content: unknown }): string {
|
|
1289
|
-
const content = message.content;
|
|
1290
|
-
if (typeof content === "string") return content.trim();
|
|
1291
|
-
if (!Array.isArray(content)) return "";
|
|
1292
|
-
return content
|
|
1293
|
-
.map((part) => {
|
|
1294
|
-
if (!part || typeof part !== "object") return "";
|
|
1295
|
-
const block = part as { type?: unknown; text?: unknown };
|
|
1296
|
-
if (block.type === "text" && typeof block.text === "string") return block.text;
|
|
1297
|
-
return "";
|
|
1298
|
-
})
|
|
1299
|
-
.join("")
|
|
1300
|
-
.trim();
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
function cleanPostprocessOutput(output: string): string {
|
|
1304
|
-
let text = output.trim();
|
|
1305
|
-
const fence = text.match(/^```[a-zA-Z0-9_-]*\s*\n([\s\S]*?)\n```$/);
|
|
1306
|
-
if (fence) text = fence[1].trim();
|
|
1307
|
-
text = text.replace(/^(?:polished(?: user)? instruction|refined(?: user)? instruction|rewritten(?: user)? instruction|final(?: insertion)? text)\s*:\s*/iu, "").trim();
|
|
1308
|
-
return text;
|
|
1309
|
-
}
|
|
1310
|
-
|
|
1311
|
-
const EXPLICIT_ENGLISH_MULTILINE_PATTERN =
|
|
1312
|
-
/\b(?:new\s*line|newline|line break|next line|new paragraph|paragraph break|carriage return|press enter|separate lines?|multi[- ]line|multiple lines)\b/i;
|
|
1313
|
-
const EXPLICIT_CHINESE_MULTILINE_PATTERN = /(?:换行|新的一行|另起一行|下一行|回车|分行|多行|逐行|每行|空一行|新段落|另起一段|分段)/u;
|
|
1314
|
-
const CJK_LIKE_PATTERN = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
|
1315
|
-
const CJK_PUNCTUATION_PATTERN = /[,。!?、;:()《》「」『』“”‘’]/u;
|
|
1316
|
-
const CLOSING_PUNCTUATION_PATTERN = /^[,.;:!?,。!?、;:))\]}》」』”’]/u;
|
|
1317
|
-
const OPENING_PUNCTUATION_PATTERN = /[(([{\[《「『“‘]$/u;
|
|
1318
|
-
|
|
1319
|
-
function rawTextRequestsMultiline(rawText: string): boolean {
|
|
1320
|
-
// Existing newlines in raw ASR are not reliable user intent: providers can
|
|
1321
|
-
// insert segment or sentence breaks on their own. Treat only spoken layout
|
|
1322
|
-
// commands as intentional multiline input.
|
|
1323
|
-
return EXPLICIT_ENGLISH_MULTILINE_PATTERN.test(rawText) || EXPLICIT_CHINESE_MULTILINE_PATTERN.test(rawText);
|
|
1324
|
-
}
|
|
1325
|
-
|
|
1326
|
-
function lineBreakJoiner(left: string, right: string): string {
|
|
1327
|
-
if (!left || !right) return "";
|
|
1328
|
-
if (CLOSING_PUNCTUATION_PATTERN.test(right) || OPENING_PUNCTUATION_PATTERN.test(left)) return "";
|
|
1329
|
-
if (CJK_PUNCTUATION_PATTERN.test(left) || CJK_PUNCTUATION_PATTERN.test(right)) return "";
|
|
1330
|
-
if (CJK_LIKE_PATTERN.test(left) && CJK_LIKE_PATTERN.test(right)) return "";
|
|
1331
|
-
return " ";
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
function collapseUnexpectedLineBreaks(text: string): string {
|
|
1335
|
-
const normalized = text.replace(/\r\n?/g, "\n");
|
|
1336
|
-
return normalized
|
|
1337
|
-
.replace(/[ \t\f\v]*\n+[ \t\f\v]*/g, (match, offset: number, source: string) => {
|
|
1338
|
-
const left = source.slice(0, offset).replace(/[ \t\f\v]+$/g, "").at(-1) ?? "";
|
|
1339
|
-
const right = source.slice(offset + match.length).replace(/^[ \t\f\v]+/g, "").at(0) ?? "";
|
|
1340
|
-
return lineBreakJoiner(left, right);
|
|
1341
|
-
})
|
|
1342
|
-
.replace(/[ \t\f\v]{2,}/g, " ")
|
|
1343
|
-
.trim();
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
function normalizeRawTextForPostprocess(rawText: string): string {
|
|
1347
|
-
const raw = rawText.trim();
|
|
1348
|
-
return rawTextRequestsMultiline(raw) ? raw : collapseUnexpectedLineBreaks(raw);
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
function preserveExpectedPostprocessLayout(rawText: string, output: string): string {
|
|
1352
|
-
if (rawTextRequestsMultiline(rawText)) return output.trim();
|
|
1353
|
-
return collapseUnexpectedLineBreaks(output);
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
function removeEditorDraftEcho(editorText: string, output: string): string {
|
|
1357
|
-
const draft = editorText.trim();
|
|
1358
|
-
const text = output.trim();
|
|
1359
|
-
if (draft.length < 12 || text.length <= draft.length) return output;
|
|
1360
|
-
|
|
1361
|
-
let prefixLength = 0;
|
|
1362
|
-
while (prefixLength < draft.length && prefixLength < text.length && draft[prefixLength] === text[prefixLength]) {
|
|
1363
|
-
prefixLength += 1;
|
|
1364
|
-
}
|
|
1365
|
-
|
|
1366
|
-
let suffixLength = 0;
|
|
1367
|
-
while (
|
|
1368
|
-
suffixLength < draft.length - prefixLength &&
|
|
1369
|
-
suffixLength < text.length - prefixLength &&
|
|
1370
|
-
draft[draft.length - 1 - suffixLength] === text[text.length - 1 - suffixLength]
|
|
1371
|
-
) {
|
|
1372
|
-
suffixLength += 1;
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
|
-
if (prefixLength + suffixLength !== draft.length) return output;
|
|
1376
|
-
const insertedText = text.slice(prefixLength, text.length - suffixLength).trim();
|
|
1377
|
-
return insertedText || output;
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
|
-
function getFullEditorText(ctx: ExtensionContext): string {
|
|
1381
|
-
try {
|
|
1382
|
-
return ctx.ui.getEditorText();
|
|
1383
|
-
} catch {
|
|
1384
|
-
return "";
|
|
1385
|
-
}
|
|
1386
|
-
}
|
|
1387
|
-
|
|
1388
|
-
function buildPostprocessPrompt(ctx: ExtensionContext, rawText: string, config: VoiceConfig): string {
|
|
1389
|
-
const { editorContext, sessionContext } = buildConversationContext(ctx, config.postprocessContextTokens);
|
|
1390
|
-
const cwd = process.cwd();
|
|
1391
|
-
const gitContext = buildGitContext(cwd);
|
|
1392
|
-
const directoryContext = buildDirectoryContext(cwd, POSTPROCESS_DIRECTORY_TOKENS);
|
|
1393
|
-
|
|
1394
|
-
return [
|
|
1395
|
-
"Polish only the raw ASR text below, using context only when it helps disambiguate the user's intent.",
|
|
1396
|
-
"If context is empty or irrelevant, polish the raw text directly.",
|
|
1397
|
-
"Do not answer the raw speech, and do not execute its request. Output only the final text that should be inserted into the editor.",
|
|
1398
|
-
"The output language must match the primary language of the raw speech, not the context language and not this English prompt. Do not translate.",
|
|
1399
|
-
"Faithfully preserve the information and details in the raw speech. Do not summarize, compress, or delete details merely for brevity.",
|
|
1400
|
-
"IMPORTANT: your output will be pasted verbatim at the current cursor position. It is not a replacement and not a rewrite of the whole editor draft.",
|
|
1401
|
-
"The current editor draft is context only. Do not rewrite, repeat, complete, delete, or replace existing draft text. Do not output the full sentence after insertion.",
|
|
1402
|
-
"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.",
|
|
1403
|
-
"Preserve layout: if the raw ASR text is one line, output one line unless the user explicitly dictated line breaks or another multiline layout.",
|
|
1404
|
-
"If the raw speech is an inline insertion, continuation, a few words, or a phrase, output only the newly spoken words or phrase.",
|
|
1405
|
-
"Use the git history and directory structure only as reference context for project names, files, APIs, and intent; never summarize them in the output.",
|
|
1406
|
-
"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.`.",
|
|
1407
|
-
"Example: draft is `This variable name is [cursor]unclear`, raw speech is `still`, correct output is `still`, not `This variable name is still unclear`.",
|
|
1408
|
-
"",
|
|
1409
|
-
"--- Context: current unsent editor draft (context only; do not output wholesale) ---",
|
|
1410
|
-
editorContext || "(empty)",
|
|
1411
|
-
"",
|
|
1412
|
-
"--- Context: recent conversation (recent tail, capped at 20k estimated tokens with the editor draft) ---",
|
|
1413
|
-
sessionContext || "(empty)",
|
|
1414
|
-
"",
|
|
1415
|
-
"--- Context: git history (latest commit summaries only) ---",
|
|
1416
|
-
gitContext || "(empty)",
|
|
1417
|
-
"",
|
|
1418
|
-
"--- Context: directory structure (parent one level; current directory two levels deep) ---",
|
|
1419
|
-
directoryContext || "(empty)",
|
|
1420
|
-
"",
|
|
1421
|
-
"--- Raw ASR text ---",
|
|
1422
|
-
rawText.trim(),
|
|
1423
|
-
].join("\n");
|
|
1424
|
-
}
|
|
1425
|
-
|
|
1426
|
-
async function postprocessTranscript(ctx: ExtensionContext, rawText: string, config: VoiceConfig): Promise<string> {
|
|
1427
|
-
if (!config.postprocessEnabled) return rawText;
|
|
1428
|
-
|
|
1429
|
-
const raw = rawText.trim();
|
|
1430
|
-
if (!raw) return rawText;
|
|
1431
|
-
|
|
1432
|
-
const model = resolvePostprocessModel(ctx, config.postprocessModel);
|
|
1433
|
-
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
1434
|
-
if (!auth.ok) {
|
|
1435
|
-
throw new Error(`Postprocess model ${modelLabel(model)} is not ready: ${auth.error}`);
|
|
1436
|
-
}
|
|
1437
|
-
|
|
1438
|
-
const response = await completeSimple(
|
|
1439
|
-
model,
|
|
1440
|
-
{
|
|
1441
|
-
systemPrompt: POSTPROCESS_SYSTEM_PROMPT,
|
|
1442
|
-
messages: [
|
|
1443
|
-
{
|
|
1444
|
-
role: "user",
|
|
1445
|
-
content: buildPostprocessPrompt(ctx, normalizeRawTextForPostprocess(raw), config),
|
|
1446
|
-
timestamp: Date.now(),
|
|
1447
|
-
},
|
|
1448
|
-
],
|
|
1449
|
-
tools: [],
|
|
1450
|
-
},
|
|
1451
|
-
{
|
|
1452
|
-
apiKey: auth.apiKey,
|
|
1453
|
-
headers: auth.headers,
|
|
1454
|
-
temperature: 0,
|
|
1455
|
-
maxTokens: config.postprocessMaxTokens,
|
|
1456
|
-
timeoutMs: config.postprocessTimeoutMs,
|
|
1457
|
-
maxRetries: 0,
|
|
1458
|
-
cacheRetention: "none",
|
|
1459
|
-
signal: ctx.signal,
|
|
1460
|
-
},
|
|
1461
|
-
);
|
|
1462
|
-
|
|
1463
|
-
if (response.stopReason === "error" || response.stopReason === "aborted") {
|
|
1464
|
-
throw new Error(response.errorMessage || `Postprocess model stopped with ${response.stopReason}`);
|
|
1465
|
-
}
|
|
1466
|
-
|
|
1467
|
-
const polished = cleanPostprocessOutput(extractAssistantText(response));
|
|
1468
|
-
if (!polished) return rawText;
|
|
1469
|
-
const insertion = removeEditorDraftEcho(getFullEditorText(ctx), polished);
|
|
1470
|
-
return preserveExpectedPostprocessLayout(raw, insertion) || rawText;
|
|
936
|
+
${trimmed}`;
|
|
1471
937
|
}
|
|
1472
938
|
|
|
1473
939
|
function insertIntoEditor(ctx: ExtensionContext, text: string) {
|
|
@@ -1527,6 +993,7 @@ async function startRecording(ctx: ExtensionContext) {
|
|
|
1527
993
|
let child: ReturnType<typeof spawn>;
|
|
1528
994
|
try {
|
|
1529
995
|
child = spawn(cmd[0], cmd.slice(1), {
|
|
996
|
+
cwd: LOCAL_AUDIO_PROCESS_CWD,
|
|
1530
997
|
detached: true,
|
|
1531
998
|
stdio: ["ignore", "ignore", "ignore"],
|
|
1532
999
|
});
|
|
@@ -1617,35 +1084,12 @@ async function stopRecording(ctx: ExtensionContext, transcribe = true) {
|
|
|
1617
1084
|
return;
|
|
1618
1085
|
}
|
|
1619
1086
|
|
|
1620
|
-
|
|
1621
|
-
let postprocessMs = 0;
|
|
1622
|
-
let postprocessSucceeded = false;
|
|
1623
|
-
let postprocessUsed = false;
|
|
1624
|
-
if (config.postprocessEnabled) {
|
|
1625
|
-
ctx.ui.setStatus("voice-input", ctx.ui.theme.fg("warning", "● polishing"));
|
|
1626
|
-
const postprocessStart = Date.now();
|
|
1627
|
-
try {
|
|
1628
|
-
finalText = await postprocessTranscript(ctx, result.text, config);
|
|
1629
|
-
postprocessMs = Date.now() - postprocessStart;
|
|
1630
|
-
postprocessSucceeded = true;
|
|
1631
|
-
} catch (error) {
|
|
1632
|
-
postprocessMs = Date.now() - postprocessStart;
|
|
1633
|
-
ctx.ui.notify(
|
|
1634
|
-
`Voice postprocess failed; inserting raw transcript. ${error instanceof Error ? error.message : String(error)}`,
|
|
1635
|
-
"warning",
|
|
1636
|
-
);
|
|
1637
|
-
}
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
|
-
finalText = preserveExpectedPostprocessLayout(result.text, finalText);
|
|
1641
|
-
postprocessUsed = postprocessSucceeded && finalText.trim() !== result.text.trim();
|
|
1087
|
+
const finalText = wrapVoiceTranscript(result.text);
|
|
1642
1088
|
|
|
1643
1089
|
ctx.ui.setStatus("voice-input", undefined);
|
|
1644
1090
|
insertIntoEditor(ctx, finalText);
|
|
1645
1091
|
ctx.ui.notify(
|
|
1646
|
-
`Voice text inserted. audio=${(durationMs / 1000).toFixed(2)}s decode=${decodeMs}ms asr=${result.timings.totalMs}ms
|
|
1647
|
-
config.postprocessEnabled ? ` postprocess=${postprocessMs}ms${postprocessUsed ? " polished" : ""}` : ""
|
|
1648
|
-
} packets=${result.packets}`,
|
|
1092
|
+
`Voice text inserted. audio=${(durationMs / 1000).toFixed(2)}s decode=${decodeMs}ms asr=${result.timings.totalMs}ms packets=${result.packets}`,
|
|
1649
1093
|
"info",
|
|
1650
1094
|
);
|
|
1651
1095
|
}
|
|
@@ -1665,7 +1109,7 @@ function setupHelp(config = getConfig()): string {
|
|
|
1665
1109
|
`- API key: ${config.apiKey ? "set" : "missing"}`,
|
|
1666
1110
|
"- To create/update the JSON config file, run: /voice init",
|
|
1667
1111
|
"- To save/update the key, run: /voice key",
|
|
1668
|
-
|
|
1112
|
+
"- Output: raw ASR transcript wrapped with a short voice-input caveat",
|
|
1669
1113
|
`- System volume ducking: ${config.duckSystemVolume ? `${Math.round(config.duckSystemVolumeFactor * 100)}% over ${config.duckSystemVolumeFadeMs}ms` : "disabled"}`,
|
|
1670
1114
|
`- Get/create a VolcEngine Speech API key here: ${VOLC_API_KEY_URL}`,
|
|
1671
1115
|
"- After saving the key, run: /voice config",
|
|
@@ -1702,12 +1146,12 @@ function configSummary(config: VoiceConfig): string {
|
|
|
1702
1146
|
"Voice input config:",
|
|
1703
1147
|
`- config file: ${config.configPath}${existsSync(config.configPath) ? "" : " (missing; run /voice init to create it)"}`,
|
|
1704
1148
|
`- volcApiKey: ${config.apiKey ? "set" : "missing"} (update with /voice key)`,
|
|
1705
|
-
|
|
1149
|
+
"- outputMode: raw transcript with voice-input caveat wrapper",
|
|
1706
1150
|
`- duckSystemVolume: ${config.duckSystemVolume ? "enabled" : "disabled"}`,
|
|
1707
1151
|
`- duckSystemVolumeFactor: ${config.duckSystemVolumeFactor}`,
|
|
1708
1152
|
`- duckSystemVolumeFadeMs: ${config.duckSystemVolumeFadeMs}`,
|
|
1709
1153
|
`- current recording device: ${currentDevice}`,
|
|
1710
|
-
"Config keys: volcApiKey,
|
|
1154
|
+
"Config keys: volcApiKey, duckSystemVolume, duckSystemVolumeFactor, duckSystemVolumeFadeMs.",
|
|
1711
1155
|
`VolcEngine API key URL: ${VOLC_API_KEY_URL}`,
|
|
1712
1156
|
].join("\n");
|
|
1713
1157
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-voice-input",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Press Ctrl+Shift+R to dictate prompts into Pi using VolcEngine ASR",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -41,7 +41,6 @@
|
|
|
41
41
|
"ws": "^8.20.1"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@earendil-works/pi-ai": "*",
|
|
45
44
|
"@earendil-works/pi-coding-agent": "*",
|
|
46
45
|
"@earendil-works/pi-tui": "*",
|
|
47
46
|
"@types/node": "^25.8.0",
|
|
@@ -49,14 +48,10 @@
|
|
|
49
48
|
"typescript": "^6.0.3"
|
|
50
49
|
},
|
|
51
50
|
"peerDependencies": {
|
|
52
|
-
"@earendil-works/pi-ai": "*",
|
|
53
51
|
"@earendil-works/pi-coding-agent": "*",
|
|
54
52
|
"@earendil-works/pi-tui": "*"
|
|
55
53
|
},
|
|
56
54
|
"peerDependenciesMeta": {
|
|
57
|
-
"@earendil-works/pi-ai": {
|
|
58
|
-
"optional": true
|
|
59
|
-
},
|
|
60
55
|
"@earendil-works/pi-coding-agent": {
|
|
61
56
|
"optional": true
|
|
62
57
|
},
|