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 +8 -18
- package/ROADMAP.md +1 -1
- package/extensions/voice-input.ts +15 -576
- 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,
|
|
@@ -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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
},
|