pi-voice-input 0.1.0 → 0.1.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 +22 -14
- package/extensions/voice-input.ts +70 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -70,18 +70,18 @@ Planned provider direction:
|
|
|
70
70
|
|
|
71
71
|
## Configure credentials
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
In pi, run:
|
|
74
74
|
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
cp .env.example ~/.pi/agent/voice-input.env
|
|
78
|
-
$EDITOR ~/.pi/agent/voice-input.env
|
|
75
|
+
```text
|
|
76
|
+
/voice key
|
|
79
77
|
```
|
|
80
78
|
|
|
81
|
-
|
|
79
|
+
Paste your VolcEngine Speech API key into the prompt. The extension saves it for future sessions and keeps it out of your project files.
|
|
82
80
|
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
Then verify:
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
/voice config
|
|
85
85
|
```
|
|
86
86
|
|
|
87
87
|
You can get/manage the key here:
|
|
@@ -90,18 +90,25 @@ https://console.volcengine.com/speech/new/setting/apikeys?projectName=default
|
|
|
90
90
|
|
|
91
91
|
If `VOLC_API_KEY` is missing, the extension does not silently fail. It shows an error notification explaining:
|
|
92
92
|
|
|
93
|
-
- that
|
|
94
|
-
-
|
|
95
|
-
- the
|
|
96
|
-
- the Volcengine API-key settings URL
|
|
93
|
+
- that the current provider API key is missing
|
|
94
|
+
- to run `/voice key`
|
|
95
|
+
- the VolcEngine API-key settings URL
|
|
97
96
|
- that `/voice config` can be used to verify detection
|
|
98
97
|
|
|
98
|
+
Manual fallback:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
mkdir -p ~/.pi/agent
|
|
102
|
+
cp .env.example ~/.pi/agent/voice-input.env
|
|
103
|
+
$EDITOR ~/.pi/agent/voice-input.env
|
|
104
|
+
```
|
|
105
|
+
|
|
99
106
|
## Configuration reference
|
|
100
107
|
|
|
101
108
|
Example:
|
|
102
109
|
|
|
103
110
|
```bash
|
|
104
|
-
# Required
|
|
111
|
+
# Required for the current provider. Usually set by /voice key.
|
|
105
112
|
VOLC_API_KEY=your_volcengine_speech_api_key
|
|
106
113
|
|
|
107
114
|
# Current provider: VolcEngine WebSocket ASR endpoint and resource
|
|
@@ -139,7 +146,7 @@ Config loading order, later values override earlier ones:
|
|
|
139
146
|
3. current-working-directory `.env`
|
|
140
147
|
4. shell environment variables
|
|
141
148
|
|
|
142
|
-
Do not commit real credentials.
|
|
149
|
+
Do not commit real credentials. Prefer `/voice key`, or keep private local values in `.env` or `~/.pi/agent/voice-input.env`.
|
|
143
150
|
|
|
144
151
|
## Usage
|
|
145
152
|
|
|
@@ -158,6 +165,7 @@ Slash commands:
|
|
|
158
165
|
/voice cancel # stop recording without transcribing
|
|
159
166
|
/voice status # show recorder state
|
|
160
167
|
/voice config # show effective non-secret config and whether API key is detected
|
|
168
|
+
/voice key # prompt for and save the current provider API key
|
|
161
169
|
```
|
|
162
170
|
|
|
163
171
|
## Notes
|
|
@@ -3,6 +3,7 @@ import { Key } from "@earendil-works/pi-tui";
|
|
|
3
3
|
import { spawn, spawnSync } from "node:child_process";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import {
|
|
6
|
+
chmodSync,
|
|
6
7
|
closeSync,
|
|
7
8
|
existsSync,
|
|
8
9
|
mkdirSync,
|
|
@@ -20,6 +21,7 @@ import WebSocket from "ws";
|
|
|
20
21
|
|
|
21
22
|
const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
22
23
|
const PACKAGE_ROOT = path.resolve(EXTENSION_DIR, "..");
|
|
24
|
+
const PRIVATE_CONFIG_PATH = path.join(homedir(), ".pi", "agent", "voice-input.env");
|
|
23
25
|
const DEFAULT_SHORTCUT = Key.ctrlShift("r");
|
|
24
26
|
|
|
25
27
|
const MSG_TYPE_CLIENT_FULL_REQUEST = 0b0001;
|
|
@@ -101,7 +103,7 @@ function parseEnvText(text: string): EnvMap {
|
|
|
101
103
|
|
|
102
104
|
function loadEnvFiles(): EnvMap {
|
|
103
105
|
const candidates = [
|
|
104
|
-
|
|
106
|
+
PRIVATE_CONFIG_PATH,
|
|
105
107
|
path.join(PACKAGE_ROOT, ".env"),
|
|
106
108
|
path.join(process.cwd(), ".env"),
|
|
107
109
|
];
|
|
@@ -185,6 +187,38 @@ function ensureDir(dir: string) {
|
|
|
185
187
|
mkdirSync(dir, { recursive: true });
|
|
186
188
|
}
|
|
187
189
|
|
|
190
|
+
function envValue(value: string): string {
|
|
191
|
+
if (/^[A-Za-z0-9_./:@+-]*$/.test(value)) return value;
|
|
192
|
+
return JSON.stringify(value);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function writePrivateEnvValue(name: string, value: string) {
|
|
196
|
+
if (/\r|\n/.test(value)) throw new Error(`${name} must be a single-line value`);
|
|
197
|
+
ensureDir(path.dirname(PRIVATE_CONFIG_PATH));
|
|
198
|
+
|
|
199
|
+
const original = existsSync(PRIVATE_CONFIG_PATH) ? readFileSync(PRIVATE_CONFIG_PATH, "utf8") : "";
|
|
200
|
+
const lines = original ? original.split(/\r?\n/) : [];
|
|
201
|
+
const replacement = `${name}=${envValue(value)}`;
|
|
202
|
+
let replaced = false;
|
|
203
|
+
|
|
204
|
+
const nextLines = lines.map((line) => {
|
|
205
|
+
if (new RegExp(`^\\s*${name}\\s*=`).test(line)) {
|
|
206
|
+
replaced = true;
|
|
207
|
+
return replacement;
|
|
208
|
+
}
|
|
209
|
+
return line;
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
if (!replaced) {
|
|
213
|
+
if (nextLines.length > 0 && nextLines[nextLines.length - 1] !== "") nextLines.push("");
|
|
214
|
+
nextLines.push("# Managed by pi-voice-input. You can also update this with /voice key.");
|
|
215
|
+
nextLines.push(replacement);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
writeFileSync(PRIVATE_CONFIG_PATH, nextLines.join("\n").replace(/\n*$/, "\n"), { mode: 0o600 });
|
|
219
|
+
chmodSync(PRIVATE_CONFIG_PATH, 0o600);
|
|
220
|
+
}
|
|
221
|
+
|
|
188
222
|
function timestampForFilename(): string {
|
|
189
223
|
return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "");
|
|
190
224
|
}
|
|
@@ -422,10 +456,8 @@ function parseRecordedWav(filePath: string): { pcm: Buffer; durationMs: number }
|
|
|
422
456
|
|
|
423
457
|
function missingCredentialsMessage(): string {
|
|
424
458
|
return [
|
|
425
|
-
"Missing VOLC_API_KEY for
|
|
426
|
-
"
|
|
427
|
-
" VOLC_API_KEY=your_volcengine_speech_api_key",
|
|
428
|
-
"Optional: copy .env.example from this package as a template.",
|
|
459
|
+
"Missing VOLC_API_KEY for the current VolcEngine ASR provider.",
|
|
460
|
+
"Run /voice key and paste your VolcEngine Speech API key.",
|
|
429
461
|
"API key settings: https://console.volcengine.com/speech/new/setting/apikeys?projectName=default",
|
|
430
462
|
"Run /voice config to verify whether the key is detected.",
|
|
431
463
|
].join("\n");
|
|
@@ -685,10 +717,32 @@ async function toggleRecording(ctx: ExtensionContext) {
|
|
|
685
717
|
else await startRecording(ctx);
|
|
686
718
|
}
|
|
687
719
|
|
|
720
|
+
async function configureApiKey(ctx: ExtensionContext, providedKey = "") {
|
|
721
|
+
let apiKey = providedKey.trim();
|
|
722
|
+
|
|
723
|
+
if (!apiKey) {
|
|
724
|
+
if (!ctx.hasUI) {
|
|
725
|
+
ctx.ui.notify("Run /voice key in interactive pi, or set VOLC_API_KEY in the environment.", "error");
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
const current = getConfig().apiKey;
|
|
729
|
+
const placeholder = current ? "Paste a new VolcEngine API key (current key is already set)" : "Paste VOLC_API_KEY";
|
|
730
|
+
apiKey = (await ctx.ui.input("VolcEngine API key", placeholder))?.trim() ?? "";
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (!apiKey) {
|
|
734
|
+
ctx.ui.notify("API key unchanged.", "warning");
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
writePrivateEnvValue("VOLC_API_KEY", apiKey);
|
|
739
|
+
ctx.ui.notify("VolcEngine API key saved for pi voice input. Run /voice config to verify it is detected.", "info");
|
|
740
|
+
}
|
|
741
|
+
|
|
688
742
|
function configSummary(config: VoiceConfig): string {
|
|
689
743
|
return [
|
|
690
744
|
"Voice input config:",
|
|
691
|
-
`- api key: ${config.apiKey ? "set" : "missing"}`,
|
|
745
|
+
`- api key: ${config.apiKey ? "set" : "missing"} (update with /voice key)`,
|
|
692
746
|
`- ws url: ${config.wsUrl}`,
|
|
693
747
|
`- resource id: ${config.resourceId}`,
|
|
694
748
|
`- language: ${config.language || "auto"}`,
|
|
@@ -697,6 +751,7 @@ function configSummary(config: VoiceConfig): string {
|
|
|
697
751
|
`- recordings: ${config.recordingsDir}`,
|
|
698
752
|
`- state: ${config.statePath}`,
|
|
699
753
|
`- shortcut: ${config.shortcut}`,
|
|
754
|
+
"Run /voice key to save/update the current provider API key.",
|
|
700
755
|
"Config files checked: ~/.pi/agent/voice-input.env, package .env, current .env; shell env overrides them.",
|
|
701
756
|
].join("\n");
|
|
702
757
|
}
|
|
@@ -717,9 +772,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
717
772
|
});
|
|
718
773
|
|
|
719
774
|
pi.registerCommand("voice", {
|
|
720
|
-
description: "Voice input: start | stop | status | toggle | cancel | config",
|
|
775
|
+
description: "Voice input: start | stop | status | toggle | cancel | config | key",
|
|
721
776
|
handler: async (args, ctx) => {
|
|
722
|
-
const
|
|
777
|
+
const input = (args || "toggle").trim();
|
|
778
|
+
const action = (input.split(/\s+/, 1)[0] || "toggle").toLowerCase();
|
|
779
|
+
const rest = input.slice(action.length).trim();
|
|
723
780
|
try {
|
|
724
781
|
if (action === "start") {
|
|
725
782
|
await startRecording(ctx);
|
|
@@ -743,11 +800,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
743
800
|
ctx.ui.notify(configSummary(getConfig()), "info");
|
|
744
801
|
return;
|
|
745
802
|
}
|
|
803
|
+
if (["key", "api-key", "apikey", "setup", "configure"].includes(action)) {
|
|
804
|
+
await configureApiKey(ctx, rest);
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
746
807
|
if (action === "toggle" || action === "") {
|
|
747
808
|
await toggleRecording(ctx);
|
|
748
809
|
return;
|
|
749
810
|
}
|
|
750
|
-
ctx.ui.notify("Usage: /voice start | stop | status | toggle | cancel | config", "error");
|
|
811
|
+
ctx.ui.notify("Usage: /voice start | stop | status | toggle | cancel | config | key", "error");
|
|
751
812
|
} catch (error) {
|
|
752
813
|
ctx.ui.setStatus("voice-input", undefined);
|
|
753
814
|
ctx.ui.notify(`Voice command error: ${error instanceof Error ? error.message : String(error)}`, "error");
|