@codexstar/pi-listen 1.0.4

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.
@@ -0,0 +1,206 @@
1
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
2
+ import * as crypto from "node:crypto";
3
+ import * as fs from "node:fs";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+
7
+ export const SETTINGS_KEY = "voice";
8
+ export const VOICE_CONFIG_VERSION = 2;
9
+
10
+ export type VoiceBackend = "faster-whisper" | "moonshine" | "whisper-cpp" | "deepgram" | "parakeet" | "auto";
11
+ export type VoiceMode = "auto" | "local" | "api";
12
+ export type VoiceSettingsScope = "global" | "project";
13
+ export type VoiceConfigSource = VoiceSettingsScope | "default";
14
+
15
+ export interface VoiceOnboardingState {
16
+ completed: boolean;
17
+ schemaVersion: number;
18
+ completedAt?: string;
19
+ lastValidatedAt?: string;
20
+ source?: "first-run" | "setup-command" | "migration" | "repair";
21
+ skippedAt?: string;
22
+ }
23
+
24
+ export interface VoiceConfig {
25
+ version: number;
26
+ enabled: boolean;
27
+ language: string;
28
+ mode: VoiceMode;
29
+ backend: VoiceBackend;
30
+ model: string;
31
+ scope: VoiceSettingsScope;
32
+ btwEnabled: boolean;
33
+ onboarding: VoiceOnboardingState;
34
+ }
35
+
36
+ export interface LoadedVoiceConfig {
37
+ config: VoiceConfig;
38
+ source: VoiceConfigSource;
39
+ globalSettingsPath: string;
40
+ projectSettingsPath: string;
41
+ }
42
+
43
+ export interface ConfigPathOptions {
44
+ agentDir?: string;
45
+ }
46
+
47
+ export interface SocketPathOptions {
48
+ scope: VoiceSettingsScope;
49
+ cwd: string;
50
+ backend: VoiceBackend;
51
+ model: string;
52
+ }
53
+
54
+ export const DEFAULT_CONFIG: VoiceConfig = {
55
+ version: VOICE_CONFIG_VERSION,
56
+ enabled: true,
57
+ language: "en",
58
+ mode: "auto",
59
+ backend: "auto",
60
+ model: "small",
61
+ scope: "global",
62
+ btwEnabled: true,
63
+ onboarding: {
64
+ completed: false,
65
+ schemaVersion: VOICE_CONFIG_VERSION,
66
+ },
67
+ };
68
+
69
+ export function readJsonFile(filePath: string): any {
70
+ try {
71
+ if (!fs.existsSync(filePath)) return {};
72
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
73
+ } catch {
74
+ return {};
75
+ }
76
+ }
77
+
78
+ export function getGlobalSettingsPath(options: ConfigPathOptions = {}): string {
79
+ return path.join(options.agentDir ?? getAgentDir(), "settings.json");
80
+ }
81
+
82
+ export function getProjectSettingsPath(cwd: string): string {
83
+ return path.join(cwd, ".pi", "settings.json");
84
+ }
85
+
86
+ function inferMode(backend: VoiceBackend): VoiceMode {
87
+ if (backend === "deepgram") return "api";
88
+ if (backend === "auto") return "auto";
89
+ return "local";
90
+ }
91
+
92
+ function normalizeOnboarding(input: any, fallbackCompleted: boolean): VoiceOnboardingState {
93
+ const completed = typeof input?.completed === "boolean" ? input.completed : fallbackCompleted;
94
+ return {
95
+ completed,
96
+ schemaVersion: Number.isFinite(input?.schemaVersion) ? Number(input.schemaVersion) : VOICE_CONFIG_VERSION,
97
+ completedAt: typeof input?.completedAt === "string" ? input.completedAt : undefined,
98
+ lastValidatedAt: typeof input?.lastValidatedAt === "string" ? input.lastValidatedAt : undefined,
99
+ source: typeof input?.source === "string" ? input.source : fallbackCompleted ? "migration" : undefined,
100
+ skippedAt: typeof input?.skippedAt === "string" ? input.skippedAt : undefined,
101
+ };
102
+ }
103
+
104
+ function migrateConfig(rawVoice: any, source: VoiceConfigSource): VoiceConfig {
105
+ if (!rawVoice || typeof rawVoice !== "object") {
106
+ return structuredClone(DEFAULT_CONFIG);
107
+ }
108
+
109
+ const backend = (rawVoice.backend ?? DEFAULT_CONFIG.backend) as VoiceBackend;
110
+ const hasMeaningfulLegacySetup =
111
+ (typeof rawVoice.backend === "string" && typeof rawVoice.model === "string") ||
112
+ rawVoice.onboarding?.completed === true;
113
+ const fallbackCompleted = hasMeaningfulLegacySetup;
114
+
115
+ return {
116
+ version: VOICE_CONFIG_VERSION,
117
+ enabled: typeof rawVoice.enabled === "boolean" ? rawVoice.enabled : DEFAULT_CONFIG.enabled,
118
+ language: typeof rawVoice.language === "string" ? rawVoice.language : DEFAULT_CONFIG.language,
119
+ mode: (rawVoice.mode as VoiceMode | undefined) ?? inferMode(backend),
120
+ backend,
121
+ model: typeof rawVoice.model === "string" ? rawVoice.model : DEFAULT_CONFIG.model,
122
+ scope: (rawVoice.scope as VoiceSettingsScope | undefined) ?? (source === "project" ? "project" : "global"),
123
+ btwEnabled: typeof rawVoice.btwEnabled === "boolean" ? rawVoice.btwEnabled : DEFAULT_CONFIG.btwEnabled,
124
+ onboarding: normalizeOnboarding(rawVoice.onboarding, fallbackCompleted),
125
+ };
126
+ }
127
+
128
+ export function loadConfigWithSource(cwd: string, options: ConfigPathOptions = {}): LoadedVoiceConfig {
129
+ const globalSettingsPath = getGlobalSettingsPath(options);
130
+ const projectSettingsPath = getProjectSettingsPath(cwd);
131
+ const globalVoice = readJsonFile(globalSettingsPath)[SETTINGS_KEY];
132
+ const projectVoice = readJsonFile(projectSettingsPath)[SETTINGS_KEY];
133
+
134
+ if (projectVoice && typeof projectVoice === "object") {
135
+ return {
136
+ config: migrateConfig(projectVoice, "project"),
137
+ source: "project",
138
+ globalSettingsPath,
139
+ projectSettingsPath,
140
+ };
141
+ }
142
+
143
+ if (globalVoice && typeof globalVoice === "object") {
144
+ return {
145
+ config: migrateConfig(globalVoice, "global"),
146
+ source: "global",
147
+ globalSettingsPath,
148
+ projectSettingsPath,
149
+ };
150
+ }
151
+
152
+ return {
153
+ config: structuredClone(DEFAULT_CONFIG),
154
+ source: "default",
155
+ globalSettingsPath,
156
+ projectSettingsPath,
157
+ };
158
+ }
159
+
160
+ function serializeConfig(config: VoiceConfig, scope: VoiceSettingsScope): VoiceConfig {
161
+ return {
162
+ ...config,
163
+ scope,
164
+ onboarding: {
165
+ ...config.onboarding,
166
+ schemaVersion: VOICE_CONFIG_VERSION,
167
+ },
168
+ };
169
+ }
170
+
171
+ export function saveConfig(
172
+ config: VoiceConfig,
173
+ scope: VoiceSettingsScope,
174
+ cwd: string,
175
+ options: ConfigPathOptions = {},
176
+ ): string {
177
+ const settingsPath = scope === "project" ? getProjectSettingsPath(cwd) : getGlobalSettingsPath(options);
178
+ const settings = readJsonFile(settingsPath);
179
+ settings[SETTINGS_KEY] = serializeConfig(config, scope);
180
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
181
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
182
+ return settingsPath;
183
+ }
184
+
185
+ export function needsOnboarding(config: VoiceConfig, source: VoiceConfigSource): boolean {
186
+ const skippedAt = config.onboarding.skippedAt ? Date.parse(config.onboarding.skippedAt) : Number.NaN;
187
+ const deferWindowMs = 1000 * 60 * 60 * 24;
188
+ const recentlyDeferred = Number.isFinite(skippedAt) && Date.now() - skippedAt < deferWindowMs;
189
+ if (recentlyDeferred) return false;
190
+ if (source === "default") return true;
191
+ return !config.onboarding.completed;
192
+ }
193
+
194
+ export function getSocketPath(options: SocketPathOptions): string {
195
+ const fingerprint = crypto
196
+ .createHash("sha1")
197
+ .update(JSON.stringify({
198
+ scope: options.scope,
199
+ cwd: options.scope === "project" ? path.resolve(options.cwd) : "global",
200
+ backend: options.backend,
201
+ model: options.model,
202
+ }))
203
+ .digest("hex")
204
+ .slice(0, 12);
205
+ return path.join(os.tmpdir(), `pi-voice-${fingerprint}.sock`);
206
+ }
@@ -0,0 +1,212 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ export type DiagnosticsPreference = "balanced" | "speed" | "privacy" | "accuracy" | "low-resource";
4
+
5
+ export interface BackendAvailability {
6
+ name: "faster-whisper" | "moonshine" | "whisper-cpp" | "deepgram" | "parakeet";
7
+ available: boolean;
8
+ type: "local" | "cloud";
9
+ default_model: string;
10
+ models: string[];
11
+ installed_models?: string[];
12
+ install_detection?: string;
13
+ install?: string | null;
14
+ }
15
+
16
+ export interface EnvironmentDiagnostics {
17
+ hasPython: boolean;
18
+ hasSox: boolean;
19
+ hasHomebrew: boolean;
20
+ hasDeepgramKey: boolean;
21
+ backends: BackendAvailability[];
22
+ issues: string[];
23
+ }
24
+
25
+ export interface VoiceRecommendation {
26
+ mode: "local" | "api";
27
+ backend: BackendAvailability["name"];
28
+ model: string;
29
+ reason: string;
30
+ fixableIssues: string[];
31
+ }
32
+
33
+ export type ModelReadiness = "installed" | "download required" | "unknown" | "api";
34
+
35
+ function commandExists(command: string): boolean {
36
+ return spawnSync("which", [command], { stdio: "pipe", timeout: 3000 }).status === 0;
37
+ }
38
+
39
+ export function scanEnvironment(transcribeScriptPath: string): EnvironmentDiagnostics {
40
+ const hasPython = commandExists("python3");
41
+ const hasSox = commandExists("rec");
42
+ const hasHomebrew = commandExists("brew");
43
+ const hasDeepgramKey = Boolean(process.env.DEEPGRAM_API_KEY);
44
+
45
+ let backends: BackendAvailability[] = [];
46
+ if (hasPython) {
47
+ const result = spawnSync("python3", [transcribeScriptPath, "--list-backends"], {
48
+ stdio: ["pipe", "pipe", "pipe"],
49
+ timeout: 10000,
50
+ encoding: "utf8",
51
+ });
52
+ try {
53
+ backends = JSON.parse(result.stdout || "[]") as BackendAvailability[];
54
+ } catch {
55
+ backends = [];
56
+ }
57
+ }
58
+
59
+ const issues: string[] = [];
60
+ if (!hasPython) issues.push("python3 is required for all STT backends");
61
+ if (!hasSox) issues.push("Install SoX for microphone recording");
62
+ if (!backends.some((backend) => backend.available)) {
63
+ issues.push("No STT backend is currently installed or configured");
64
+ }
65
+ if (!hasDeepgramKey) {
66
+ issues.push("Deepgram API key is not configured");
67
+ }
68
+
69
+ return {
70
+ hasPython,
71
+ hasSox,
72
+ hasHomebrew,
73
+ hasDeepgramKey,
74
+ backends,
75
+ issues,
76
+ };
77
+ }
78
+
79
+ function getBackend(backends: BackendAvailability[], name: BackendAvailability["name"]): BackendAvailability | undefined {
80
+ return backends.find((backend) => backend.name === name);
81
+ }
82
+
83
+ export function getModelReadiness(backend: BackendAvailability | undefined, model: string): ModelReadiness {
84
+ if (!backend) return "unknown";
85
+ if (backend.type === "cloud") return "api";
86
+ if ((backend.installed_models ?? []).includes(model)) return "installed";
87
+ const highConfidenceDetectors = new Set(["huggingface-cache", "whisper-cpp-model-paths"]);
88
+ if (backend.available && highConfidenceDetectors.has(backend.install_detection ?? "")) {
89
+ return "download required";
90
+ }
91
+ return "unknown";
92
+ }
93
+
94
+ function getPreferredInstalledModel(backend: BackendAvailability | undefined): string | undefined {
95
+ if (!backend) return undefined;
96
+ const installed = backend.installed_models ?? [];
97
+ if (installed.includes(backend.default_model)) return backend.default_model;
98
+ return installed[0];
99
+ }
100
+
101
+ const HIGH_CONFIDENCE_DETECTORS = new Set(["huggingface-cache", "whisper-cpp-model-paths"]);
102
+
103
+ function getDetectorConfidenceKey(backend: BackendAvailability): string {
104
+ if (backend.install_detection) return backend.install_detection;
105
+ if (backend.name === "faster-whisper") return "huggingface-cache";
106
+ if (backend.name === "whisper-cpp") return "whisper-cpp-model-paths";
107
+ return "";
108
+ }
109
+
110
+ function hasHighConfidenceInstalledModel(backend: BackendAvailability): boolean {
111
+ return (backend.installed_models?.length ?? 0) > 0 && HIGH_CONFIDENCE_DETECTORS.has(getDetectorConfidenceKey(backend));
112
+ }
113
+
114
+ function hasHighConfidenceBackendAvailability(backend: BackendAvailability): boolean {
115
+ return backend.available && HIGH_CONFIDENCE_DETECTORS.has(getDetectorConfidenceKey(backend));
116
+ }
117
+
118
+ function getPreferredLocalBackend(backends: BackendAvailability[]): BackendAvailability | undefined {
119
+ const localBackends = backends.filter((backend) => backend.type === "local");
120
+ const withHighConfidenceInstalledModel = localBackends.find((backend) => hasHighConfidenceInstalledModel(backend));
121
+ if (withHighConfidenceInstalledModel) return withHighConfidenceInstalledModel;
122
+ const highConfidenceAvailable = localBackends.find((backend) => hasHighConfidenceBackendAvailability(backend));
123
+ if (highConfidenceAvailable) return highConfidenceAvailable;
124
+ const withHeuristicInstalledModel = localBackends.find((backend) => (backend.installed_models?.length ?? 0) > 0);
125
+ if (withHeuristicInstalledModel) return withHeuristicInstalledModel;
126
+ return localBackends.find((backend) => backend.available);
127
+ }
128
+
129
+ export function recommendVoiceSetup(
130
+ diagnostics: EnvironmentDiagnostics,
131
+ preference: DiagnosticsPreference = "balanced",
132
+ ): VoiceRecommendation {
133
+ const fixableIssues: string[] = [];
134
+ if (!diagnostics.hasSox) {
135
+ fixableIssues.push("Install SoX for microphone recording");
136
+ }
137
+ if (!diagnostics.hasPython) {
138
+ fixableIssues.push("Install python3 for local transcription backends");
139
+ }
140
+
141
+ const fasterWhisper = getBackend(diagnostics.backends, "faster-whisper");
142
+ const deepgram = getBackend(diagnostics.backends, "deepgram");
143
+ const preferredLocalBackend = getPreferredLocalBackend(diagnostics.backends);
144
+ const anyLocalAvailable = diagnostics.backends.some((backend) => backend.type === "local" && backend.available);
145
+
146
+ if (!anyLocalAvailable) {
147
+ fixableIssues.push("Install a local STT backend such as faster-whisper");
148
+ }
149
+
150
+ const installedLocalModel = getPreferredInstalledModel(preferredLocalBackend);
151
+ const installedModelIsHighConfidence = preferredLocalBackend ? hasHighConfidenceInstalledModel(preferredLocalBackend) : false;
152
+ const localRecommendation = {
153
+ mode: "local" as const,
154
+ backend: preferredLocalBackend?.name ?? "faster-whisper",
155
+ model: installedLocalModel ?? preferredLocalBackend?.default_model ?? fasterWhisper?.default_model ?? "small",
156
+ reason: installedLocalModel && installedModelIsHighConfidence
157
+ ? `Recommended local default because ${installedLocalModel} is already installed and ready to configure.`
158
+ : preference === "privacy"
159
+ ? "Best for privacy and offline use with a strong local default."
160
+ : preference === "accuracy"
161
+ ? "Best balance of local quality and maturity."
162
+ : "Recommended local default with good balance of quality and setup effort.",
163
+ fixableIssues,
164
+ };
165
+
166
+ if (preference === "speed" && diagnostics.hasDeepgramKey && deepgram?.available) {
167
+ return {
168
+ mode: "api",
169
+ backend: "deepgram",
170
+ model: deepgram.default_model,
171
+ reason: "The fastest path to a working setup because cloud transcription is already configured.",
172
+ fixableIssues,
173
+ };
174
+ }
175
+
176
+ if (preference === "privacy") {
177
+ return {
178
+ ...localRecommendation,
179
+ reason: "Best for privacy and offline use without sending audio to a cloud API.",
180
+ };
181
+ }
182
+
183
+ if (preference === "balanced") {
184
+ if (preferredLocalBackend?.available && (hasHighConfidenceInstalledModel(preferredLocalBackend) || hasHighConfidenceBackendAvailability(preferredLocalBackend) || !(diagnostics.hasDeepgramKey && deepgram?.available))) {
185
+ return localRecommendation;
186
+ }
187
+ if (diagnostics.hasDeepgramKey && deepgram?.available) {
188
+ return {
189
+ mode: "api",
190
+ backend: "deepgram",
191
+ model: deepgram.default_model,
192
+ reason: "Recommended because cloud transcription is already configured and ready to use.",
193
+ fixableIssues,
194
+ };
195
+ }
196
+ }
197
+
198
+ if (preference === "low-resource") {
199
+ const moonshine = getBackend(diagnostics.backends, "moonshine");
200
+ if (moonshine?.available) {
201
+ return {
202
+ mode: "local",
203
+ backend: "moonshine",
204
+ model: moonshine.default_model,
205
+ reason: "Lightweight local option with lower resource requirements.",
206
+ fixableIssues,
207
+ };
208
+ }
209
+ }
210
+
211
+ return localRecommendation;
212
+ }
@@ -0,0 +1,62 @@
1
+ import type { VoiceConfig } from "./config";
2
+ import type { EnvironmentDiagnostics } from "./diagnostics";
3
+
4
+ export interface ProvisioningPlan {
5
+ ready: boolean;
6
+ summary: string;
7
+ commands: string[];
8
+ manualSteps: string[];
9
+ }
10
+
11
+ export function buildProvisioningPlan(config: VoiceConfig, diagnostics: EnvironmentDiagnostics): ProvisioningPlan {
12
+ const commands: string[] = [];
13
+ const manualSteps: string[] = [];
14
+
15
+ if (!diagnostics.hasSox) {
16
+ commands.push("brew install sox");
17
+ }
18
+
19
+ if (config.mode === "api") {
20
+ if (config.backend === "deepgram" && !diagnostics.hasDeepgramKey) {
21
+ manualSteps.push("Set DEEPGRAM_API_KEY before using Deepgram API mode");
22
+ }
23
+ } else {
24
+ if (!diagnostics.hasPython) {
25
+ manualSteps.push("Install python3 before enabling local STT backends");
26
+ }
27
+
28
+ const selectedBackend = diagnostics.backends.find((backend) => backend.name === config.backend);
29
+ if (!selectedBackend?.available) {
30
+ switch (config.backend) {
31
+ case "faster-whisper":
32
+ commands.push("python3 -m pip install faster-whisper");
33
+ break;
34
+ case "moonshine":
35
+ commands.push("python3 -m pip install 'useful-moonshine[onnx]'");
36
+ break;
37
+ case "whisper-cpp":
38
+ commands.push("brew install whisper-cpp");
39
+ break;
40
+ case "parakeet":
41
+ commands.push("python3 -m pip install 'nemo_toolkit[asr]'");
42
+ break;
43
+ default:
44
+ manualSteps.push(`Install the selected backend: ${config.backend}`);
45
+ }
46
+ } else if (!(selectedBackend.installed_models ?? []).includes(config.model)) {
47
+ const highConfidenceDetectors = new Set(["huggingface-cache", "whisper-cpp-model-paths"]);
48
+ if (highConfidenceDetectors.has(selectedBackend.install_detection ?? "")) {
49
+ manualSteps.push(`Selected model ${config.model} is not installed yet and may need to be downloaded on first use`);
50
+ } else {
51
+ manualSteps.push(`Selected model ${config.model} could not be confirmed locally and may still need a download on first use`);
52
+ }
53
+ }
54
+ }
55
+
56
+ return {
57
+ ready: commands.length === 0 && manualSteps.length === 0,
58
+ summary: config.mode === "api" ? "Provisioning plan for api mode" : "Provisioning plan for local mode",
59
+ commands,
60
+ manualSteps,
61
+ };
62
+ }