pi-voice-input 0.2.16 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -18
- package/ROADMAP.md +1 -1
- package/extensions/voice-input.ts +14 -574
- 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. If the editor already contains that note, later dictation inserts only the transcript.
|
|
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 concise, location-neutral caveat saying the current conversation may include voice transcription errors, asking the model to correct them from context or ask the user if the meaning is unclear, then appends the raw ASR transcript unchanged. When you dictate multiple times in the same unsent editor draft, the caveat is kept to a single copy.
|
|
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,
|
|
@@ -28,47 +26,7 @@ const VOLC_API_KEY_URL = "https://console.volcengine.com/speech/new/setting/apik
|
|
|
28
26
|
// the local machine, so force those subprocesses to a known-local cwd.
|
|
29
27
|
const LOCAL_AUDIO_PROCESS_CWD = homedir();
|
|
30
28
|
const DEFAULT_SHORTCUT = Key.ctrlShift("r");
|
|
31
|
-
const
|
|
32
|
-
const DEFAULT_POSTPROCESS_CONTEXT_TOKENS = 20000;
|
|
33
|
-
const POSTPROCESS_GIT_LOG_LIMIT = 10;
|
|
34
|
-
const POSTPROCESS_DIRECTORY_DEPTH = 2;
|
|
35
|
-
const POSTPROCESS_DIRECTORY_TOKENS = 20000;
|
|
36
|
-
const SKIPPED_DIRECTORY_ENTRIES = new Set([
|
|
37
|
-
".git",
|
|
38
|
-
".hg",
|
|
39
|
-
".svn",
|
|
40
|
-
"node_modules",
|
|
41
|
-
"dist",
|
|
42
|
-
"build",
|
|
43
|
-
"coverage",
|
|
44
|
-
".next",
|
|
45
|
-
".nuxt",
|
|
46
|
-
".turbo",
|
|
47
|
-
".cache",
|
|
48
|
-
"logs",
|
|
49
|
-
"recordings",
|
|
50
|
-
]);
|
|
51
|
-
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.
|
|
52
|
-
|
|
53
|
-
Interaction contract:
|
|
54
|
-
- The plugin does not replace editor content with your output. It only pastes/inserts your output at the user's current cursor position.
|
|
55
|
-
- 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.
|
|
56
|
-
- 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.
|
|
57
|
-
- 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.
|
|
58
|
-
- 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.
|
|
59
|
-
- 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.
|
|
60
|
-
|
|
61
|
-
Rules:
|
|
62
|
-
- Output only the polished insertion text. Do not output explanations, headings, prefixes, suffixes, quotes, code fences, or greetings.
|
|
63
|
-
- 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.
|
|
64
|
-
- 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.
|
|
65
|
-
- Correct obvious ASR mistakes, homophones, segmentation, and punctuation. Preserve code identifiers, commands, paths, URLs, model names, package names, and proper nouns.
|
|
66
|
-
- 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.
|
|
67
|
-
- 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.
|
|
68
|
-
- 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 "换行".
|
|
69
|
-
- Do not introduce line breaks, bullets, numbered lists, tables, or code fences merely to improve style.
|
|
70
|
-
- Do not invent requirements that the raw speech did not express. If uncertain, keep the original meaning and express it more clearly.
|
|
71
|
-
- 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 = "当前会话中的内容包含语音转写,可能存在识别错误;请结合上下文纠正,若不确定或明显偏离主题无法理解,请先询问用户。";
|
|
72
30
|
|
|
73
31
|
const MSG_TYPE_CLIENT_FULL_REQUEST = 0b0001;
|
|
74
32
|
const MSG_TYPE_CLIENT_AUDIO_ONLY_REQUEST = 0b0010;
|
|
@@ -84,7 +42,6 @@ type JsonObject = Record<string, unknown>;
|
|
|
84
42
|
|
|
85
43
|
type VoiceInputConfigFile = {
|
|
86
44
|
volcApiKey: string;
|
|
87
|
-
polishModel: string;
|
|
88
45
|
duckSystemVolume: boolean;
|
|
89
46
|
duckSystemVolumeFactor: number;
|
|
90
47
|
duckSystemVolumeFadeMs: number;
|
|
@@ -108,11 +65,6 @@ type VoiceConfig = {
|
|
|
108
65
|
enablePunc: boolean;
|
|
109
66
|
enableDdc: boolean;
|
|
110
67
|
showUtterances: boolean;
|
|
111
|
-
postprocessEnabled: boolean;
|
|
112
|
-
postprocessModel: string;
|
|
113
|
-
postprocessTimeoutMs: number;
|
|
114
|
-
postprocessMaxTokens: number;
|
|
115
|
-
postprocessContextTokens: number;
|
|
116
68
|
duckSystemVolume: boolean;
|
|
117
69
|
duckSystemVolumeFactor: number;
|
|
118
70
|
duckSystemVolumeFadeMs: number;
|
|
@@ -162,7 +114,6 @@ function ensureDir(dir: string) {
|
|
|
162
114
|
function defaultConfigFile(): VoiceInputConfigFile {
|
|
163
115
|
return {
|
|
164
116
|
volcApiKey: "",
|
|
165
|
-
polishModel: DEFAULT_POSTPROCESS_MODEL,
|
|
166
117
|
duckSystemVolume: true,
|
|
167
118
|
duckSystemVolumeFactor: 0.5,
|
|
168
119
|
duckSystemVolumeFadeMs: 300,
|
|
@@ -197,7 +148,6 @@ function normalizeConfigFile(input: unknown): VoiceInputConfigFile {
|
|
|
197
148
|
const root = isObject(input) ? input : {};
|
|
198
149
|
return {
|
|
199
150
|
volcApiKey: stringField(root, "volcApiKey", defaults.volcApiKey).trim(),
|
|
200
|
-
polishModel: stringField(root, "polishModel", defaults.polishModel).trim(),
|
|
201
151
|
duckSystemVolume: booleanField(root, "duckSystemVolume", defaults.duckSystemVolume),
|
|
202
152
|
duckSystemVolumeFactor: clamp(numberField(root, "duckSystemVolumeFactor", defaults.duckSystemVolumeFactor), 0, 1),
|
|
203
153
|
duckSystemVolumeFadeMs: Math.round(clamp(numberField(root, "duckSystemVolumeFadeMs", defaults.duckSystemVolumeFadeMs), 0, 3000)),
|
|
@@ -222,7 +172,6 @@ function loadConfigFile(): VoiceInputConfigFile {
|
|
|
222
172
|
function getConfig(): VoiceConfig {
|
|
223
173
|
const fileConfig = loadConfigFile();
|
|
224
174
|
const voiceHome = path.join(homedir(), ".pi", "agent", "voice-input");
|
|
225
|
-
const polishModel = fileConfig.polishModel.trim();
|
|
226
175
|
|
|
227
176
|
return {
|
|
228
177
|
configPath: CONFIG_PATH,
|
|
@@ -242,11 +191,6 @@ function getConfig(): VoiceConfig {
|
|
|
242
191
|
enablePunc: true,
|
|
243
192
|
enableDdc: false,
|
|
244
193
|
showUtterances: false,
|
|
245
|
-
postprocessEnabled: polishModel.length > 0,
|
|
246
|
-
postprocessModel: polishModel,
|
|
247
|
-
postprocessTimeoutMs: 30000,
|
|
248
|
-
postprocessMaxTokens: 2048,
|
|
249
|
-
postprocessContextTokens: DEFAULT_POSTPROCESS_CONTEXT_TOKENS,
|
|
250
194
|
duckSystemVolume: fileConfig.duckSystemVolume,
|
|
251
195
|
duckSystemVolumeFactor: fileConfig.duckSystemVolumeFactor,
|
|
252
196
|
duckSystemVolumeFadeMs: fileConfig.duckSystemVolumeFadeMs,
|
|
@@ -980,498 +924,17 @@ async function transcribePcm(pcm: Buffer, durationMs: number, config: VoiceConfi
|
|
|
980
924
|
};
|
|
981
925
|
}
|
|
982
926
|
|
|
983
|
-
function
|
|
984
|
-
|
|
985
|
-
let asciiRun = 0;
|
|
986
|
-
const flushAscii = () => {
|
|
987
|
-
if (asciiRun > 0) {
|
|
988
|
-
tokens += Math.ceil(asciiRun / 4);
|
|
989
|
-
asciiRun = 0;
|
|
990
|
-
}
|
|
991
|
-
};
|
|
992
|
-
|
|
993
|
-
for (const char of text) {
|
|
994
|
-
if (/\s/u.test(char)) {
|
|
995
|
-
flushAscii();
|
|
996
|
-
} else if (/[^\x00-\x7F]/u.test(char)) {
|
|
997
|
-
flushAscii();
|
|
998
|
-
tokens += 1;
|
|
999
|
-
} else {
|
|
1000
|
-
asciiRun += 1;
|
|
1001
|
-
}
|
|
1002
|
-
}
|
|
1003
|
-
flushAscii();
|
|
1004
|
-
return tokens;
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
function takeTokensFromStart(text: string, maxTokens: number): string {
|
|
1008
|
-
if (maxTokens <= 0 || !text) return "";
|
|
1009
|
-
if (estimateTokens(text) <= maxTokens) return text;
|
|
1010
|
-
|
|
1011
|
-
let low = 0;
|
|
1012
|
-
let high = text.length;
|
|
1013
|
-
while (low < high) {
|
|
1014
|
-
const mid = Math.ceil((low + high) / 2);
|
|
1015
|
-
if (estimateTokens(text.slice(0, mid)) <= maxTokens) low = mid;
|
|
1016
|
-
else high = mid - 1;
|
|
1017
|
-
}
|
|
1018
|
-
return text.slice(0, low);
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
function takeTokensFromEnd(text: string, maxTokens: number): string {
|
|
1022
|
-
if (maxTokens <= 0 || !text) return "";
|
|
1023
|
-
if (estimateTokens(text) <= maxTokens) return text;
|
|
1024
|
-
|
|
1025
|
-
let low = 0;
|
|
1026
|
-
let high = text.length;
|
|
1027
|
-
while (low < high) {
|
|
1028
|
-
const mid = Math.ceil((low + high) / 2);
|
|
1029
|
-
if (estimateTokens(text.slice(text.length - mid)) <= maxTokens) low = mid;
|
|
1030
|
-
else high = mid - 1;
|
|
1031
|
-
}
|
|
1032
|
-
return text.slice(text.length - low);
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
function limitTokensFromStart(text: string, maxTokens: number, marker = "\n…(truncated to fit token budget)"): string {
|
|
1036
|
-
if (maxTokens <= 0) return "";
|
|
1037
|
-
if (estimateTokens(text) <= maxTokens) return text;
|
|
1038
|
-
const markerTokens = estimateTokens(marker);
|
|
1039
|
-
return `${takeTokensFromStart(text, Math.max(0, maxTokens - markerTokens)).trimEnd()}${marker}`;
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
function limitTokensFromEnd(text: string, maxTokens: number, marker = "…(older context omitted to fit token budget)\n"): string {
|
|
1043
|
-
if (maxTokens <= 0) return "";
|
|
1044
|
-
if (estimateTokens(text) <= maxTokens) return text;
|
|
1045
|
-
const markerTokens = estimateTokens(marker);
|
|
1046
|
-
return `${marker}${takeTokensFromEnd(text, Math.max(0, maxTokens - markerTokens)).trimStart()}`;
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
function redactSensitiveText(text: string): string {
|
|
1050
|
-
return text
|
|
1051
|
-
.replace(/(\bVOLC_API_KEY\s*=\s*)[^\s"']+/giu, "$1[redacted]")
|
|
1052
|
-
.replace(/((?:\b|["'])(?:volcApiKey|api[_-]?key|apikey|access[_-]?token|secret|password)(?:\b|["'])\s*[:=]\s*["']?)[^"'\s,}]+/giu, "$1[redacted]")
|
|
1053
|
-
.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]");
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
function textFromContent(content: unknown): string {
|
|
1057
|
-
if (typeof content === "string") return content;
|
|
1058
|
-
if (!Array.isArray(content)) return "";
|
|
1059
|
-
return content
|
|
1060
|
-
.map((part) => {
|
|
1061
|
-
if (!part || typeof part !== "object") return "";
|
|
1062
|
-
const block = part as { type?: unknown; text?: unknown };
|
|
1063
|
-
if (block.type === "text" && typeof block.text === "string") return block.text;
|
|
1064
|
-
return "";
|
|
1065
|
-
})
|
|
1066
|
-
.filter(Boolean)
|
|
1067
|
-
.join("\n");
|
|
927
|
+
function editorHasVoiceTranscriptNotice(editorText: string): boolean {
|
|
928
|
+
return editorText.replace(/\r\n/g, "\n").includes(VOICE_TRANSCRIPT_NOTICE);
|
|
1068
929
|
}
|
|
1069
930
|
|
|
1070
|
-
function
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
}
|
|
1076
|
-
}
|
|
931
|
+
function wrapVoiceTranscript(rawText: string, currentEditorText = ""): string {
|
|
932
|
+
const trimmed = rawText.trim();
|
|
933
|
+
if (!trimmed) return "";
|
|
934
|
+
if (editorHasVoiceTranscriptNotice(currentEditorText)) return trimmed;
|
|
935
|
+
return `${VOICE_TRANSCRIPT_NOTICE}
|
|
1077
936
|
|
|
1078
|
-
|
|
1079
|
-
const lines: string[] = [];
|
|
1080
|
-
for (const entry of ctx.sessionManager.getBranch()) {
|
|
1081
|
-
if (entry.type !== "message") continue;
|
|
1082
|
-
const message = entry.message as { role?: unknown; content?: unknown };
|
|
1083
|
-
if (message.role !== "user" && message.role !== "assistant") continue;
|
|
1084
|
-
const text = redactSensitiveText(textFromContent(message.content)).trim();
|
|
1085
|
-
if (!text) continue;
|
|
1086
|
-
lines.push(`${message.role}: ${text}`);
|
|
1087
|
-
}
|
|
1088
|
-
return lines.join("\n\n");
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
|
-
function buildConversationContext(ctx: ExtensionContext, maxTokens: number): { editorContext: string; sessionContext: string } {
|
|
1092
|
-
if (maxTokens <= 0) return { editorContext: "", sessionContext: "" };
|
|
1093
|
-
|
|
1094
|
-
const editorContext = getEditorContext(ctx);
|
|
1095
|
-
const sessionContext = getRecentSessionContext(ctx);
|
|
1096
|
-
const editorSection = [
|
|
1097
|
-
"--- Context: current unsent editor draft (context only; do not output wholesale) ---",
|
|
1098
|
-
editorContext || "(empty)",
|
|
1099
|
-
].join("\n");
|
|
1100
|
-
const sessionHeader = "--- Context: recent conversation ---\n";
|
|
1101
|
-
const totalTokens = estimateTokens([editorSection, sessionHeader, sessionContext || "(empty)"].join("\n\n"));
|
|
1102
|
-
if (totalTokens <= maxTokens) return { editorContext, sessionContext };
|
|
1103
|
-
|
|
1104
|
-
const editorTokenBudget = Math.min(5000, Math.floor(maxTokens / 4));
|
|
1105
|
-
const limitedEditor = editorContext ? limitTokensFromEnd(editorContext, editorTokenBudget) : "";
|
|
1106
|
-
const fixedTokens = estimateTokens(
|
|
1107
|
-
[
|
|
1108
|
-
"--- Context: current unsent editor draft (context only; do not output wholesale) ---",
|
|
1109
|
-
limitedEditor || "(empty)",
|
|
1110
|
-
sessionHeader,
|
|
1111
|
-
].join("\n\n"),
|
|
1112
|
-
);
|
|
1113
|
-
const sessionBudget = Math.max(0, maxTokens - fixedTokens);
|
|
1114
|
-
return {
|
|
1115
|
-
editorContext: limitedEditor,
|
|
1116
|
-
sessionContext: sessionContext ? limitTokensFromEnd(sessionContext, sessionBudget) : "",
|
|
1117
|
-
};
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
function commandOutputInDir(cwd: string, command: string, args: string[], timeoutMs = 2500): string {
|
|
1121
|
-
const result = spawnSync(command, args, { cwd, encoding: "utf8", timeout: timeoutMs, maxBuffer: 1024 * 1024 * 8 });
|
|
1122
|
-
if (result.status !== 0) return "";
|
|
1123
|
-
return (result.stdout || "").trim();
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
function findGitRoot(startDir: string): string | null {
|
|
1127
|
-
const root = commandOutputInDir(startDir, "git", ["rev-parse", "--show-toplevel"], 1500);
|
|
1128
|
-
return root || null;
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
function buildGitContext(cwd: string): string {
|
|
1132
|
-
const gitRoot = findGitRoot(cwd);
|
|
1133
|
-
if (!gitRoot) return "(not a git repository)";
|
|
1134
|
-
|
|
1135
|
-
const recentLog = commandOutputInDir(
|
|
1136
|
-
gitRoot,
|
|
1137
|
-
"git",
|
|
1138
|
-
["log", `-${POSTPROCESS_GIT_LOG_LIMIT}`, "--date=short", "--pretty=format:%h %ad %an %s"],
|
|
1139
|
-
2500,
|
|
1140
|
-
);
|
|
1141
|
-
|
|
1142
|
-
return [
|
|
1143
|
-
`Repository root: ${gitRoot}`,
|
|
1144
|
-
`Recent commits (up to ${POSTPROCESS_GIT_LOG_LIMIT}):`,
|
|
1145
|
-
recentLog ? redactSensitiveText(recentLog) : "(no commits yet)",
|
|
1146
|
-
].join("\n");
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
function shouldSkipDirectoryEntry(name: string): boolean {
|
|
1150
|
-
return SKIPPED_DIRECTORY_ENTRIES.has(name);
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
|
-
function safeReadDir(dir: string): string[] {
|
|
1154
|
-
try {
|
|
1155
|
-
return readdirSync(dir).sort((a, b) => a.localeCompare(b));
|
|
1156
|
-
} catch {
|
|
1157
|
-
return [];
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
function formatDirectoryEntry(fullPath: string, name: string, isCwd = false): string {
|
|
1162
|
-
try {
|
|
1163
|
-
const stat = lstatSync(fullPath);
|
|
1164
|
-
const suffix = stat.isDirectory() ? "/" : stat.isSymbolicLink() ? "@" : "";
|
|
1165
|
-
return `${name}${suffix}${isCwd ? " <-- current" : ""}`;
|
|
1166
|
-
} catch {
|
|
1167
|
-
return `${name}${isCwd ? " <-- current" : ""}`;
|
|
1168
|
-
}
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
function buildTreeLines(dir: string, depth: number, prefix = "", state = { entries: 0, omitted: 0 }): string[] {
|
|
1172
|
-
if (depth < 0) return [];
|
|
1173
|
-
const names = safeReadDir(dir).filter((name) => !shouldSkipDirectoryEntry(name));
|
|
1174
|
-
const visibleNames = names.slice(0, 120);
|
|
1175
|
-
state.omitted += Math.max(0, names.length - visibleNames.length);
|
|
1176
|
-
const lines: string[] = [];
|
|
1177
|
-
|
|
1178
|
-
visibleNames.forEach((name, index) => {
|
|
1179
|
-
const fullPath = path.join(dir, name);
|
|
1180
|
-
const isLast = index === visibleNames.length - 1;
|
|
1181
|
-
const connector = isLast ? "└── " : "├── ";
|
|
1182
|
-
const childPrefix = `${prefix}${isLast ? " " : "│ "}`;
|
|
1183
|
-
const label = formatDirectoryEntry(fullPath, name);
|
|
1184
|
-
lines.push(`${prefix}${connector}${label}`);
|
|
1185
|
-
state.entries += 1;
|
|
1186
|
-
|
|
1187
|
-
try {
|
|
1188
|
-
const stat = lstatSync(fullPath);
|
|
1189
|
-
if (stat.isDirectory() && !stat.isSymbolicLink() && depth > 0) {
|
|
1190
|
-
lines.push(...buildTreeLines(fullPath, depth - 1, childPrefix, state));
|
|
1191
|
-
}
|
|
1192
|
-
} catch {
|
|
1193
|
-
// ignore unreadable entries
|
|
1194
|
-
}
|
|
1195
|
-
});
|
|
1196
|
-
|
|
1197
|
-
return lines;
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
function buildDirectoryContext(cwd: string, maxTokens: number): string {
|
|
1201
|
-
if (maxTokens <= 0) return "";
|
|
1202
|
-
const parent = path.dirname(cwd);
|
|
1203
|
-
const isRootDirectory = path.resolve(parent) === path.resolve(cwd);
|
|
1204
|
-
const cwdName = path.basename(cwd) || cwd;
|
|
1205
|
-
const parentEntries = isRootDirectory
|
|
1206
|
-
? []
|
|
1207
|
-
: safeReadDir(parent)
|
|
1208
|
-
.filter((name) => !shouldSkipDirectoryEntry(name))
|
|
1209
|
-
.slice(0, 160)
|
|
1210
|
-
.map((name) => formatDirectoryEntry(path.join(parent, name), name, path.resolve(parent, name) === path.resolve(cwd)));
|
|
1211
|
-
const state = { entries: 0, omitted: 0 };
|
|
1212
|
-
const cwdTree = buildTreeLines(cwd, POSTPROCESS_DIRECTORY_DEPTH - 1, "", state);
|
|
1213
|
-
const text = [
|
|
1214
|
-
`Current directory: ${cwd}`,
|
|
1215
|
-
isRootDirectory ? "Parent directory: (current directory is filesystem root; no parent directory)" : `Parent directory: ${parent}`,
|
|
1216
|
-
"Parent entries (one level up):",
|
|
1217
|
-
isRootDirectory ? "- (none; current directory is filesystem root)" : parentEntries.length ? parentEntries.map((entry) => `- ${entry}`).join("\n") : "- (empty or unreadable)",
|
|
1218
|
-
"",
|
|
1219
|
-
`Current directory tree (${cwdName}/, ${POSTPROCESS_DIRECTORY_DEPTH} levels deep):`,
|
|
1220
|
-
`${cwdName}/`,
|
|
1221
|
-
cwdTree.length ? cwdTree.join("\n") : "└── (empty or no readable child entries)",
|
|
1222
|
-
state.omitted > 0 ? `…(${state.omitted} entries omitted)` : "",
|
|
1223
|
-
]
|
|
1224
|
-
.filter(Boolean)
|
|
1225
|
-
.join("\n");
|
|
1226
|
-
return limitTokensFromStart(text, maxTokens);
|
|
1227
|
-
}
|
|
1228
|
-
|
|
1229
|
-
function simplifyModelReference(value: string): string {
|
|
1230
|
-
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1233
|
-
function stripThinkingSuffix(value: string): string {
|
|
1234
|
-
return value.replace(/:(?:off|minimal|low|medium|high|xhigh)$/i, "");
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
function modelLabel(model: Model<Api>): string {
|
|
1238
|
-
return `${model.provider}/${model.id}`;
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
|
-
function resolvePostprocessModel(ctx: ExtensionContext, reference: string): Model<Api> {
|
|
1242
|
-
const requested = stripThinkingSuffix(reference.trim());
|
|
1243
|
-
if (!requested) throw new Error("polishModel is empty in voice input config");
|
|
1244
|
-
|
|
1245
|
-
const models = ctx.modelRegistry.getAll();
|
|
1246
|
-
const lower = requested.toLowerCase();
|
|
1247
|
-
const simple = simplifyModelReference(requested);
|
|
1248
|
-
|
|
1249
|
-
const exactCanonical = models.filter((model) => modelLabel(model).toLowerCase() === lower);
|
|
1250
|
-
if (exactCanonical.length === 1) return exactCanonical[0];
|
|
1251
|
-
|
|
1252
|
-
const exactBare = models.filter((model) => model.id.toLowerCase() === lower || model.name.toLowerCase() === lower);
|
|
1253
|
-
if (exactBare.length === 1) return exactBare[0];
|
|
1254
|
-
if (exactBare.length > 1) {
|
|
1255
|
-
throw new Error(
|
|
1256
|
-
`Ambiguous postprocess model "${reference}". Use provider/model, e.g. ${exactBare.map(modelLabel).slice(0, 5).join(", ")}`,
|
|
1257
|
-
);
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
const exactSimple = models.filter(
|
|
1261
|
-
(model) =>
|
|
1262
|
-
simplifyModelReference(modelLabel(model)) === simple ||
|
|
1263
|
-
simplifyModelReference(model.id) === simple ||
|
|
1264
|
-
simplifyModelReference(model.name) === simple,
|
|
1265
|
-
);
|
|
1266
|
-
if (exactSimple.length === 1) return exactSimple[0];
|
|
1267
|
-
if (exactSimple.length > 1) {
|
|
1268
|
-
throw new Error(
|
|
1269
|
-
`Ambiguous postprocess model "${reference}". Use provider/model, e.g. ${exactSimple.map(modelLabel).slice(0, 5).join(", ")}`,
|
|
1270
|
-
);
|
|
1271
|
-
}
|
|
1272
|
-
|
|
1273
|
-
const fuzzy = models.filter(
|
|
1274
|
-
(model) =>
|
|
1275
|
-
modelLabel(model).toLowerCase().includes(lower) ||
|
|
1276
|
-
model.id.toLowerCase().includes(lower) ||
|
|
1277
|
-
model.name.toLowerCase().includes(lower) ||
|
|
1278
|
-
simplifyModelReference(modelLabel(model)).includes(simple) ||
|
|
1279
|
-
simplifyModelReference(model.id).includes(simple) ||
|
|
1280
|
-
simplifyModelReference(model.name).includes(simple),
|
|
1281
|
-
);
|
|
1282
|
-
if (fuzzy.length === 1) return fuzzy[0];
|
|
1283
|
-
if (fuzzy.length > 1) {
|
|
1284
|
-
throw new Error(
|
|
1285
|
-
`Ambiguous postprocess model "${reference}". Use provider/model, e.g. ${fuzzy.map(modelLabel).slice(0, 5).join(", ")}`,
|
|
1286
|
-
);
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
throw new Error(`Postprocess model "${reference}" not found. Run pi --list-models to see available models.`);
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
function extractAssistantText(message: { content: unknown }): string {
|
|
1293
|
-
const content = message.content;
|
|
1294
|
-
if (typeof content === "string") return content.trim();
|
|
1295
|
-
if (!Array.isArray(content)) return "";
|
|
1296
|
-
return content
|
|
1297
|
-
.map((part) => {
|
|
1298
|
-
if (!part || typeof part !== "object") return "";
|
|
1299
|
-
const block = part as { type?: unknown; text?: unknown };
|
|
1300
|
-
if (block.type === "text" && typeof block.text === "string") return block.text;
|
|
1301
|
-
return "";
|
|
1302
|
-
})
|
|
1303
|
-
.join("")
|
|
1304
|
-
.trim();
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
function cleanPostprocessOutput(output: string): string {
|
|
1308
|
-
let text = output.trim();
|
|
1309
|
-
const fence = text.match(/^```[a-zA-Z0-9_-]*\s*\n([\s\S]*?)\n```$/);
|
|
1310
|
-
if (fence) text = fence[1].trim();
|
|
1311
|
-
text = text.replace(/^(?:polished(?: user)? instruction|refined(?: user)? instruction|rewritten(?: user)? instruction|final(?: insertion)? text)\s*:\s*/iu, "").trim();
|
|
1312
|
-
return text;
|
|
1313
|
-
}
|
|
1314
|
-
|
|
1315
|
-
const EXPLICIT_ENGLISH_MULTILINE_PATTERN =
|
|
1316
|
-
/\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;
|
|
1317
|
-
const EXPLICIT_CHINESE_MULTILINE_PATTERN = /(?:换行|新的一行|另起一行|下一行|回车|分行|多行|逐行|每行|空一行|新段落|另起一段|分段)/u;
|
|
1318
|
-
const CJK_LIKE_PATTERN = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
|
1319
|
-
const CJK_PUNCTUATION_PATTERN = /[,。!?、;:()《》「」『』“”‘’]/u;
|
|
1320
|
-
const CLOSING_PUNCTUATION_PATTERN = /^[,.;:!?,。!?、;:))\]}》」』”’]/u;
|
|
1321
|
-
const OPENING_PUNCTUATION_PATTERN = /[(([{\[《「『“‘]$/u;
|
|
1322
|
-
|
|
1323
|
-
function rawTextRequestsMultiline(rawText: string): boolean {
|
|
1324
|
-
// Existing newlines in raw ASR are not reliable user intent: providers can
|
|
1325
|
-
// insert segment or sentence breaks on their own. Treat only spoken layout
|
|
1326
|
-
// commands as intentional multiline input.
|
|
1327
|
-
return EXPLICIT_ENGLISH_MULTILINE_PATTERN.test(rawText) || EXPLICIT_CHINESE_MULTILINE_PATTERN.test(rawText);
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
|
-
function lineBreakJoiner(left: string, right: string): string {
|
|
1331
|
-
if (!left || !right) return "";
|
|
1332
|
-
if (CLOSING_PUNCTUATION_PATTERN.test(right) || OPENING_PUNCTUATION_PATTERN.test(left)) return "";
|
|
1333
|
-
if (CJK_PUNCTUATION_PATTERN.test(left) || CJK_PUNCTUATION_PATTERN.test(right)) return "";
|
|
1334
|
-
if (CJK_LIKE_PATTERN.test(left) && CJK_LIKE_PATTERN.test(right)) return "";
|
|
1335
|
-
return " ";
|
|
1336
|
-
}
|
|
1337
|
-
|
|
1338
|
-
function collapseUnexpectedLineBreaks(text: string): string {
|
|
1339
|
-
const normalized = text.replace(/\r\n?/g, "\n");
|
|
1340
|
-
return normalized
|
|
1341
|
-
.replace(/[ \t\f\v]*\n+[ \t\f\v]*/g, (match, offset: number, source: string) => {
|
|
1342
|
-
const left = source.slice(0, offset).replace(/[ \t\f\v]+$/g, "").at(-1) ?? "";
|
|
1343
|
-
const right = source.slice(offset + match.length).replace(/^[ \t\f\v]+/g, "").at(0) ?? "";
|
|
1344
|
-
return lineBreakJoiner(left, right);
|
|
1345
|
-
})
|
|
1346
|
-
.replace(/[ \t\f\v]{2,}/g, " ")
|
|
1347
|
-
.trim();
|
|
1348
|
-
}
|
|
1349
|
-
|
|
1350
|
-
function normalizeRawTextForPostprocess(rawText: string): string {
|
|
1351
|
-
const raw = rawText.trim();
|
|
1352
|
-
return rawTextRequestsMultiline(raw) ? raw : collapseUnexpectedLineBreaks(raw);
|
|
1353
|
-
}
|
|
1354
|
-
|
|
1355
|
-
function preserveExpectedPostprocessLayout(rawText: string, output: string): string {
|
|
1356
|
-
if (rawTextRequestsMultiline(rawText)) return output.trim();
|
|
1357
|
-
return collapseUnexpectedLineBreaks(output);
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
function removeEditorDraftEcho(editorText: string, output: string): string {
|
|
1361
|
-
const draft = editorText.trim();
|
|
1362
|
-
const text = output.trim();
|
|
1363
|
-
if (draft.length < 12 || text.length <= draft.length) return output;
|
|
1364
|
-
|
|
1365
|
-
let prefixLength = 0;
|
|
1366
|
-
while (prefixLength < draft.length && prefixLength < text.length && draft[prefixLength] === text[prefixLength]) {
|
|
1367
|
-
prefixLength += 1;
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
let suffixLength = 0;
|
|
1371
|
-
while (
|
|
1372
|
-
suffixLength < draft.length - prefixLength &&
|
|
1373
|
-
suffixLength < text.length - prefixLength &&
|
|
1374
|
-
draft[draft.length - 1 - suffixLength] === text[text.length - 1 - suffixLength]
|
|
1375
|
-
) {
|
|
1376
|
-
suffixLength += 1;
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
if (prefixLength + suffixLength !== draft.length) return output;
|
|
1380
|
-
const insertedText = text.slice(prefixLength, text.length - suffixLength).trim();
|
|
1381
|
-
return insertedText || output;
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
|
-
function getFullEditorText(ctx: ExtensionContext): string {
|
|
1385
|
-
try {
|
|
1386
|
-
return ctx.ui.getEditorText();
|
|
1387
|
-
} catch {
|
|
1388
|
-
return "";
|
|
1389
|
-
}
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
function buildPostprocessPrompt(ctx: ExtensionContext, rawText: string, config: VoiceConfig): string {
|
|
1393
|
-
const { editorContext, sessionContext } = buildConversationContext(ctx, config.postprocessContextTokens);
|
|
1394
|
-
const cwd = process.cwd();
|
|
1395
|
-
const gitContext = buildGitContext(cwd);
|
|
1396
|
-
const directoryContext = buildDirectoryContext(cwd, POSTPROCESS_DIRECTORY_TOKENS);
|
|
1397
|
-
|
|
1398
|
-
return [
|
|
1399
|
-
"Polish only the raw ASR text below, using context only when it helps disambiguate the user's intent.",
|
|
1400
|
-
"If context is empty or irrelevant, polish the raw text directly.",
|
|
1401
|
-
"Do not answer the raw speech, and do not execute its request. Output only the final text that should be inserted into the editor.",
|
|
1402
|
-
"The output language must match the primary language of the raw speech, not the context language and not this English prompt. Do not translate.",
|
|
1403
|
-
"Faithfully preserve the information and details in the raw speech. Do not summarize, compress, or delete details merely for brevity.",
|
|
1404
|
-
"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.",
|
|
1405
|
-
"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.",
|
|
1406
|
-
"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.",
|
|
1407
|
-
"Preserve layout: if the raw ASR text is one line, output one line unless the user explicitly dictated line breaks or another multiline layout.",
|
|
1408
|
-
"If the raw speech is an inline insertion, continuation, a few words, or a phrase, output only the newly spoken words or phrase.",
|
|
1409
|
-
"Use the git history and directory structure only as reference context for project names, files, APIs, and intent; never summarize them in the output.",
|
|
1410
|
-
"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.`.",
|
|
1411
|
-
"Example: draft is `This variable name is [cursor]unclear`, raw speech is `still`, correct output is `still`, not `This variable name is still unclear`.",
|
|
1412
|
-
"",
|
|
1413
|
-
"--- Context: current unsent editor draft (context only; do not output wholesale) ---",
|
|
1414
|
-
editorContext || "(empty)",
|
|
1415
|
-
"",
|
|
1416
|
-
"--- Context: recent conversation (recent tail, capped at 20k estimated tokens with the editor draft) ---",
|
|
1417
|
-
sessionContext || "(empty)",
|
|
1418
|
-
"",
|
|
1419
|
-
"--- Context: git history (latest commit summaries only) ---",
|
|
1420
|
-
gitContext || "(empty)",
|
|
1421
|
-
"",
|
|
1422
|
-
"--- Context: directory structure (parent one level; current directory two levels deep) ---",
|
|
1423
|
-
directoryContext || "(empty)",
|
|
1424
|
-
"",
|
|
1425
|
-
"--- Raw ASR text ---",
|
|
1426
|
-
rawText.trim(),
|
|
1427
|
-
].join("\n");
|
|
1428
|
-
}
|
|
1429
|
-
|
|
1430
|
-
async function postprocessTranscript(ctx: ExtensionContext, rawText: string, config: VoiceConfig): Promise<string> {
|
|
1431
|
-
if (!config.postprocessEnabled) return rawText;
|
|
1432
|
-
|
|
1433
|
-
const raw = rawText.trim();
|
|
1434
|
-
if (!raw) return rawText;
|
|
1435
|
-
|
|
1436
|
-
const model = resolvePostprocessModel(ctx, config.postprocessModel);
|
|
1437
|
-
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
1438
|
-
if (!auth.ok) {
|
|
1439
|
-
throw new Error(`Postprocess model ${modelLabel(model)} is not ready: ${auth.error}`);
|
|
1440
|
-
}
|
|
1441
|
-
|
|
1442
|
-
const response = await completeSimple(
|
|
1443
|
-
model,
|
|
1444
|
-
{
|
|
1445
|
-
systemPrompt: POSTPROCESS_SYSTEM_PROMPT,
|
|
1446
|
-
messages: [
|
|
1447
|
-
{
|
|
1448
|
-
role: "user",
|
|
1449
|
-
content: buildPostprocessPrompt(ctx, normalizeRawTextForPostprocess(raw), config),
|
|
1450
|
-
timestamp: Date.now(),
|
|
1451
|
-
},
|
|
1452
|
-
],
|
|
1453
|
-
tools: [],
|
|
1454
|
-
},
|
|
1455
|
-
{
|
|
1456
|
-
apiKey: auth.apiKey,
|
|
1457
|
-
headers: auth.headers,
|
|
1458
|
-
temperature: 0,
|
|
1459
|
-
maxTokens: config.postprocessMaxTokens,
|
|
1460
|
-
timeoutMs: config.postprocessTimeoutMs,
|
|
1461
|
-
maxRetries: 0,
|
|
1462
|
-
cacheRetention: "none",
|
|
1463
|
-
signal: ctx.signal,
|
|
1464
|
-
},
|
|
1465
|
-
);
|
|
1466
|
-
|
|
1467
|
-
if (response.stopReason === "error" || response.stopReason === "aborted") {
|
|
1468
|
-
throw new Error(response.errorMessage || `Postprocess model stopped with ${response.stopReason}`);
|
|
1469
|
-
}
|
|
1470
|
-
|
|
1471
|
-
const polished = cleanPostprocessOutput(extractAssistantText(response));
|
|
1472
|
-
if (!polished) return rawText;
|
|
1473
|
-
const insertion = removeEditorDraftEcho(getFullEditorText(ctx), polished);
|
|
1474
|
-
return preserveExpectedPostprocessLayout(raw, insertion) || rawText;
|
|
937
|
+
${trimmed}`;
|
|
1475
938
|
}
|
|
1476
939
|
|
|
1477
940
|
function insertIntoEditor(ctx: ExtensionContext, text: string) {
|
|
@@ -1622,35 +1085,12 @@ async function stopRecording(ctx: ExtensionContext, transcribe = true) {
|
|
|
1622
1085
|
return;
|
|
1623
1086
|
}
|
|
1624
1087
|
|
|
1625
|
-
|
|
1626
|
-
let postprocessMs = 0;
|
|
1627
|
-
let postprocessSucceeded = false;
|
|
1628
|
-
let postprocessUsed = false;
|
|
1629
|
-
if (config.postprocessEnabled) {
|
|
1630
|
-
ctx.ui.setStatus("voice-input", ctx.ui.theme.fg("warning", "● polishing"));
|
|
1631
|
-
const postprocessStart = Date.now();
|
|
1632
|
-
try {
|
|
1633
|
-
finalText = await postprocessTranscript(ctx, result.text, config);
|
|
1634
|
-
postprocessMs = Date.now() - postprocessStart;
|
|
1635
|
-
postprocessSucceeded = true;
|
|
1636
|
-
} catch (error) {
|
|
1637
|
-
postprocessMs = Date.now() - postprocessStart;
|
|
1638
|
-
ctx.ui.notify(
|
|
1639
|
-
`Voice postprocess failed; inserting raw transcript. ${error instanceof Error ? error.message : String(error)}`,
|
|
1640
|
-
"warning",
|
|
1641
|
-
);
|
|
1642
|
-
}
|
|
1643
|
-
}
|
|
1644
|
-
|
|
1645
|
-
finalText = preserveExpectedPostprocessLayout(result.text, finalText);
|
|
1646
|
-
postprocessUsed = postprocessSucceeded && finalText.trim() !== result.text.trim();
|
|
1088
|
+
const finalText = wrapVoiceTranscript(result.text, ctx.ui.getEditorText());
|
|
1647
1089
|
|
|
1648
1090
|
ctx.ui.setStatus("voice-input", undefined);
|
|
1649
1091
|
insertIntoEditor(ctx, finalText);
|
|
1650
1092
|
ctx.ui.notify(
|
|
1651
|
-
`Voice text inserted. audio=${(durationMs / 1000).toFixed(2)}s decode=${decodeMs}ms asr=${result.timings.totalMs}ms
|
|
1652
|
-
config.postprocessEnabled ? ` postprocess=${postprocessMs}ms${postprocessUsed ? " polished" : ""}` : ""
|
|
1653
|
-
} packets=${result.packets}`,
|
|
1093
|
+
`Voice text inserted. audio=${(durationMs / 1000).toFixed(2)}s decode=${decodeMs}ms asr=${result.timings.totalMs}ms packets=${result.packets}`,
|
|
1654
1094
|
"info",
|
|
1655
1095
|
);
|
|
1656
1096
|
}
|
|
@@ -1670,7 +1110,7 @@ function setupHelp(config = getConfig()): string {
|
|
|
1670
1110
|
`- API key: ${config.apiKey ? "set" : "missing"}`,
|
|
1671
1111
|
"- To create/update the JSON config file, run: /voice init",
|
|
1672
1112
|
"- To save/update the key, run: /voice key",
|
|
1673
|
-
|
|
1113
|
+
"- Output: raw ASR transcript wrapped with a short voice-input caveat",
|
|
1674
1114
|
`- System volume ducking: ${config.duckSystemVolume ? `${Math.round(config.duckSystemVolumeFactor * 100)}% over ${config.duckSystemVolumeFadeMs}ms` : "disabled"}`,
|
|
1675
1115
|
`- Get/create a VolcEngine Speech API key here: ${VOLC_API_KEY_URL}`,
|
|
1676
1116
|
"- After saving the key, run: /voice config",
|
|
@@ -1707,12 +1147,12 @@ function configSummary(config: VoiceConfig): string {
|
|
|
1707
1147
|
"Voice input config:",
|
|
1708
1148
|
`- config file: ${config.configPath}${existsSync(config.configPath) ? "" : " (missing; run /voice init to create it)"}`,
|
|
1709
1149
|
`- volcApiKey: ${config.apiKey ? "set" : "missing"} (update with /voice key)`,
|
|
1710
|
-
|
|
1150
|
+
"- outputMode: raw transcript with voice-input caveat wrapper",
|
|
1711
1151
|
`- duckSystemVolume: ${config.duckSystemVolume ? "enabled" : "disabled"}`,
|
|
1712
1152
|
`- duckSystemVolumeFactor: ${config.duckSystemVolumeFactor}`,
|
|
1713
1153
|
`- duckSystemVolumeFadeMs: ${config.duckSystemVolumeFadeMs}`,
|
|
1714
1154
|
`- current recording device: ${currentDevice}`,
|
|
1715
|
-
"Config keys: volcApiKey,
|
|
1155
|
+
"Config keys: volcApiKey, duckSystemVolume, duckSystemVolumeFactor, duckSystemVolumeFadeMs.",
|
|
1716
1156
|
`VolcEngine API key URL: ${VOLC_API_KEY_URL}`,
|
|
1717
1157
|
].join("\n");
|
|
1718
1158
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-voice-input",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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
|
},
|