pi-voice-input 0.2.16 → 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 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
- - optionally polish dictated text with one of your configured pi models
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
- - **Optional transcript polish**: use a pi model to clean up punctuation and wording before insertion.
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
- ## Optional: polish dictated text
99
+ ## Inserted text format
100
100
 
101
- By default, pi inserts the raw transcript. To let a pi model clean up punctuation and wording, set `polishModel` in:
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
- ```text
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
- "polishModel": "your-model-name"
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
- - Optional transcript polish through a configured pi model
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,11 @@ 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 DEFAULT_POSTPROCESS_MODEL = "";
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 = [
30
+ "以下内容来自用户通过语音输入的原始转写,可能包含语音识别错误、错别字、同音词、断句或标点不准确,以及中英文、术语、代码名、文件名误识别等问题。",
31
+ "请明确注意:这段转写不一定完全准确。请结合上下文理解用户意图;必要时可以先澄清、翻译或推断,再继续处理。",
32
+ "原始语音转写如下:",
33
+ ].join("\n");
72
34
 
73
35
  const MSG_TYPE_CLIENT_FULL_REQUEST = 0b0001;
74
36
  const MSG_TYPE_CLIENT_AUDIO_ONLY_REQUEST = 0b0010;
@@ -84,7 +46,6 @@ type JsonObject = Record<string, unknown>;
84
46
 
85
47
  type VoiceInputConfigFile = {
86
48
  volcApiKey: string;
87
- polishModel: string;
88
49
  duckSystemVolume: boolean;
89
50
  duckSystemVolumeFactor: number;
90
51
  duckSystemVolumeFadeMs: number;
@@ -108,11 +69,6 @@ type VoiceConfig = {
108
69
  enablePunc: boolean;
109
70
  enableDdc: boolean;
110
71
  showUtterances: boolean;
111
- postprocessEnabled: boolean;
112
- postprocessModel: string;
113
- postprocessTimeoutMs: number;
114
- postprocessMaxTokens: number;
115
- postprocessContextTokens: number;
116
72
  duckSystemVolume: boolean;
117
73
  duckSystemVolumeFactor: number;
118
74
  duckSystemVolumeFadeMs: number;
@@ -162,7 +118,6 @@ function ensureDir(dir: string) {
162
118
  function defaultConfigFile(): VoiceInputConfigFile {
163
119
  return {
164
120
  volcApiKey: "",
165
- polishModel: DEFAULT_POSTPROCESS_MODEL,
166
121
  duckSystemVolume: true,
167
122
  duckSystemVolumeFactor: 0.5,
168
123
  duckSystemVolumeFadeMs: 300,
@@ -197,7 +152,6 @@ function normalizeConfigFile(input: unknown): VoiceInputConfigFile {
197
152
  const root = isObject(input) ? input : {};
198
153
  return {
199
154
  volcApiKey: stringField(root, "volcApiKey", defaults.volcApiKey).trim(),
200
- polishModel: stringField(root, "polishModel", defaults.polishModel).trim(),
201
155
  duckSystemVolume: booleanField(root, "duckSystemVolume", defaults.duckSystemVolume),
202
156
  duckSystemVolumeFactor: clamp(numberField(root, "duckSystemVolumeFactor", defaults.duckSystemVolumeFactor), 0, 1),
203
157
  duckSystemVolumeFadeMs: Math.round(clamp(numberField(root, "duckSystemVolumeFadeMs", defaults.duckSystemVolumeFadeMs), 0, 3000)),
@@ -222,7 +176,6 @@ function loadConfigFile(): VoiceInputConfigFile {
222
176
  function getConfig(): VoiceConfig {
223
177
  const fileConfig = loadConfigFile();
224
178
  const voiceHome = path.join(homedir(), ".pi", "agent", "voice-input");
225
- const polishModel = fileConfig.polishModel.trim();
226
179
 
227
180
  return {
228
181
  configPath: CONFIG_PATH,
@@ -242,11 +195,6 @@ function getConfig(): VoiceConfig {
242
195
  enablePunc: true,
243
196
  enableDdc: false,
244
197
  showUtterances: false,
245
- postprocessEnabled: polishModel.length > 0,
246
- postprocessModel: polishModel,
247
- postprocessTimeoutMs: 30000,
248
- postprocessMaxTokens: 2048,
249
- postprocessContextTokens: DEFAULT_POSTPROCESS_CONTEXT_TOKENS,
250
198
  duckSystemVolume: fileConfig.duckSystemVolume,
251
199
  duckSystemVolumeFactor: fileConfig.duckSystemVolumeFactor,
252
200
  duckSystemVolumeFadeMs: fileConfig.duckSystemVolumeFadeMs,
@@ -980,498 +928,12 @@ async function transcribePcm(pcm: Buffer, durationMs: number, config: VoiceConfi
980
928
  };
981
929
  }
982
930
 
983
- function estimateTokens(text: string): number {
984
- let tokens = 0;
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");
1068
- }
1069
-
1070
- function getEditorContext(ctx: ExtensionContext): string {
1071
- try {
1072
- return redactSensitiveText(ctx.ui.getEditorText()).trim();
1073
- } catch {
1074
- return "";
1075
- }
1076
- }
1077
-
1078
- function getRecentSessionContext(ctx: ExtensionContext): string {
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
- }
931
+ function wrapVoiceTranscript(rawText: string): string {
932
+ const trimmed = rawText.trim();
933
+ if (!trimmed) return "";
934
+ return `${VOICE_TRANSCRIPT_NOTICE}
1160
935
 
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;
936
+ ${trimmed}`;
1475
937
  }
1476
938
 
1477
939
  function insertIntoEditor(ctx: ExtensionContext, text: string) {
@@ -1622,35 +1084,12 @@ async function stopRecording(ctx: ExtensionContext, transcribe = true) {
1622
1084
  return;
1623
1085
  }
1624
1086
 
1625
- let finalText = result.text;
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();
1087
+ const finalText = wrapVoiceTranscript(result.text);
1647
1088
 
1648
1089
  ctx.ui.setStatus("voice-input", undefined);
1649
1090
  insertIntoEditor(ctx, finalText);
1650
1091
  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}`,
1092
+ `Voice text inserted. audio=${(durationMs / 1000).toFixed(2)}s decode=${decodeMs}ms asr=${result.timings.totalMs}ms packets=${result.packets}`,
1654
1093
  "info",
1655
1094
  );
1656
1095
  }
@@ -1670,7 +1109,7 @@ function setupHelp(config = getConfig()): string {
1670
1109
  `- API key: ${config.apiKey ? "set" : "missing"}`,
1671
1110
  "- To create/update the JSON config file, run: /voice init",
1672
1111
  "- To save/update the key, run: /voice key",
1673
- `- Polish: ${config.postprocessEnabled ? config.postprocessModel : "disabled"}`,
1112
+ "- Output: raw ASR transcript wrapped with a short voice-input caveat",
1674
1113
  `- System volume ducking: ${config.duckSystemVolume ? `${Math.round(config.duckSystemVolumeFactor * 100)}% over ${config.duckSystemVolumeFadeMs}ms` : "disabled"}`,
1675
1114
  `- Get/create a VolcEngine Speech API key here: ${VOLC_API_KEY_URL}`,
1676
1115
  "- After saving the key, run: /voice config",
@@ -1707,12 +1146,12 @@ function configSummary(config: VoiceConfig): string {
1707
1146
  "Voice input config:",
1708
1147
  `- config file: ${config.configPath}${existsSync(config.configPath) ? "" : " (missing; run /voice init to create it)"}`,
1709
1148
  `- volcApiKey: ${config.apiKey ? "set" : "missing"} (update with /voice key)`,
1710
- `- polishModel: ${config.postprocessEnabled ? config.postprocessModel : "disabled"}`,
1149
+ "- outputMode: raw transcript with voice-input caveat wrapper",
1711
1150
  `- duckSystemVolume: ${config.duckSystemVolume ? "enabled" : "disabled"}`,
1712
1151
  `- duckSystemVolumeFactor: ${config.duckSystemVolumeFactor}`,
1713
1152
  `- duckSystemVolumeFadeMs: ${config.duckSystemVolumeFadeMs}`,
1714
1153
  `- current recording device: ${currentDevice}`,
1715
- "Config keys: volcApiKey, polishModel, duckSystemVolume, duckSystemVolumeFactor, duckSystemVolumeFadeMs. Leave polishModel empty to disable polish.",
1154
+ "Config keys: volcApiKey, duckSystemVolume, duckSystemVolumeFactor, duckSystemVolumeFadeMs.",
1716
1155
  `VolcEngine API key URL: ${VOLC_API_KEY_URL}`,
1717
1156
  ].join("\n");
1718
1157
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-voice-input",
3
- "version": "0.2.16",
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
  },