ashlrcode 1.0.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.
Files changed (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -0
  3. package/package.json +46 -0
  4. package/src/__tests__/branded-types.test.ts +47 -0
  5. package/src/__tests__/context.test.ts +163 -0
  6. package/src/__tests__/cost-tracker.test.ts +274 -0
  7. package/src/__tests__/cron.test.ts +197 -0
  8. package/src/__tests__/dream.test.ts +204 -0
  9. package/src/__tests__/error-handler.test.ts +192 -0
  10. package/src/__tests__/features.test.ts +69 -0
  11. package/src/__tests__/file-history.test.ts +177 -0
  12. package/src/__tests__/hooks.test.ts +145 -0
  13. package/src/__tests__/keybindings.test.ts +159 -0
  14. package/src/__tests__/model-patches.test.ts +82 -0
  15. package/src/__tests__/permissions-rules.test.ts +121 -0
  16. package/src/__tests__/permissions.test.ts +108 -0
  17. package/src/__tests__/project-config.test.ts +63 -0
  18. package/src/__tests__/retry.test.ts +321 -0
  19. package/src/__tests__/router.test.ts +158 -0
  20. package/src/__tests__/session-compact.test.ts +191 -0
  21. package/src/__tests__/session.test.ts +145 -0
  22. package/src/__tests__/skill-registry.test.ts +130 -0
  23. package/src/__tests__/speculation.test.ts +196 -0
  24. package/src/__tests__/tasks-v2.test.ts +267 -0
  25. package/src/__tests__/telemetry.test.ts +149 -0
  26. package/src/__tests__/tool-executor.test.ts +141 -0
  27. package/src/__tests__/tool-registry.test.ts +166 -0
  28. package/src/__tests__/undercover.test.ts +93 -0
  29. package/src/__tests__/workflow.test.ts +195 -0
  30. package/src/agent/async-context.ts +64 -0
  31. package/src/agent/context.ts +245 -0
  32. package/src/agent/cron.ts +189 -0
  33. package/src/agent/dream.ts +165 -0
  34. package/src/agent/error-handler.ts +108 -0
  35. package/src/agent/ipc.ts +256 -0
  36. package/src/agent/kairos.ts +207 -0
  37. package/src/agent/loop.ts +314 -0
  38. package/src/agent/model-patches.ts +68 -0
  39. package/src/agent/speculation.ts +219 -0
  40. package/src/agent/sub-agent.ts +125 -0
  41. package/src/agent/system-prompt.ts +231 -0
  42. package/src/agent/team.ts +220 -0
  43. package/src/agent/tool-executor.ts +162 -0
  44. package/src/agent/workflow.ts +189 -0
  45. package/src/agent/worktree-manager.ts +86 -0
  46. package/src/autopilot/queue.ts +186 -0
  47. package/src/autopilot/scanner.ts +245 -0
  48. package/src/autopilot/types.ts +58 -0
  49. package/src/bridge/bridge-client.ts +57 -0
  50. package/src/bridge/bridge-server.ts +81 -0
  51. package/src/cli.ts +1120 -0
  52. package/src/config/features.ts +51 -0
  53. package/src/config/git.ts +137 -0
  54. package/src/config/hooks.ts +201 -0
  55. package/src/config/permissions.ts +251 -0
  56. package/src/config/project-config.ts +63 -0
  57. package/src/config/remote-settings.ts +163 -0
  58. package/src/config/settings-sync.ts +170 -0
  59. package/src/config/settings.ts +113 -0
  60. package/src/config/undercover.ts +76 -0
  61. package/src/config/upgrade-notice.ts +65 -0
  62. package/src/mcp/client.ts +197 -0
  63. package/src/mcp/manager.ts +125 -0
  64. package/src/mcp/oauth.ts +252 -0
  65. package/src/mcp/types.ts +61 -0
  66. package/src/persistence/memory.ts +129 -0
  67. package/src/persistence/session.ts +289 -0
  68. package/src/planning/plan-mode.ts +128 -0
  69. package/src/planning/plan-tools.ts +138 -0
  70. package/src/providers/anthropic.ts +177 -0
  71. package/src/providers/cost-tracker.ts +184 -0
  72. package/src/providers/retry.ts +264 -0
  73. package/src/providers/router.ts +159 -0
  74. package/src/providers/types.ts +79 -0
  75. package/src/providers/xai.ts +217 -0
  76. package/src/repl.tsx +1384 -0
  77. package/src/setup.ts +119 -0
  78. package/src/skills/loader.ts +78 -0
  79. package/src/skills/registry.ts +78 -0
  80. package/src/skills/types.ts +11 -0
  81. package/src/state/file-history.ts +264 -0
  82. package/src/telemetry/event-log.ts +116 -0
  83. package/src/tools/agent.ts +133 -0
  84. package/src/tools/ask-user.ts +229 -0
  85. package/src/tools/bash.ts +146 -0
  86. package/src/tools/config.ts +147 -0
  87. package/src/tools/diff.ts +137 -0
  88. package/src/tools/file-edit.ts +123 -0
  89. package/src/tools/file-read.ts +82 -0
  90. package/src/tools/file-write.ts +82 -0
  91. package/src/tools/glob.ts +76 -0
  92. package/src/tools/grep.ts +187 -0
  93. package/src/tools/ls.ts +77 -0
  94. package/src/tools/lsp.ts +375 -0
  95. package/src/tools/mcp-resources.ts +83 -0
  96. package/src/tools/mcp-tool.ts +47 -0
  97. package/src/tools/memory.ts +148 -0
  98. package/src/tools/notebook-edit.ts +133 -0
  99. package/src/tools/peers.ts +113 -0
  100. package/src/tools/powershell.ts +83 -0
  101. package/src/tools/registry.ts +114 -0
  102. package/src/tools/send-message.ts +75 -0
  103. package/src/tools/sleep.ts +50 -0
  104. package/src/tools/snip.ts +143 -0
  105. package/src/tools/tasks.ts +349 -0
  106. package/src/tools/team.ts +309 -0
  107. package/src/tools/todo-write.ts +93 -0
  108. package/src/tools/tool-search.ts +83 -0
  109. package/src/tools/types.ts +52 -0
  110. package/src/tools/web-browser.ts +263 -0
  111. package/src/tools/web-fetch.ts +118 -0
  112. package/src/tools/web-search.ts +107 -0
  113. package/src/tools/workflow.ts +188 -0
  114. package/src/tools/worktree.ts +143 -0
  115. package/src/types/branded.ts +22 -0
  116. package/src/ui/App.tsx +184 -0
  117. package/src/ui/BuddyPanel.tsx +52 -0
  118. package/src/ui/PermissionPrompt.tsx +29 -0
  119. package/src/ui/banner.ts +217 -0
  120. package/src/ui/buddy-ai.ts +108 -0
  121. package/src/ui/buddy.ts +466 -0
  122. package/src/ui/context-bar.ts +60 -0
  123. package/src/ui/effort.ts +65 -0
  124. package/src/ui/keybindings.ts +143 -0
  125. package/src/ui/markdown.ts +271 -0
  126. package/src/ui/message-renderer.ts +73 -0
  127. package/src/ui/mode.ts +80 -0
  128. package/src/ui/notifications.ts +57 -0
  129. package/src/ui/speech-bubble.ts +95 -0
  130. package/src/ui/spinner.ts +116 -0
  131. package/src/ui/theme.ts +98 -0
  132. package/src/version.ts +5 -0
  133. package/src/voice/voice-mode.ts +169 -0
@@ -0,0 +1,95 @@
1
+ /**
2
+ * ASCII speech bubble renderer.
3
+ * Creates a speech bubble to the left with a tail pointing at the buddy.
4
+ */
5
+
6
+ /**
7
+ * Wrap text into lines of max width.
8
+ */
9
+ function wrapText(text: string, maxWidth: number): string[] {
10
+ const words = text.split(" ");
11
+ const lines: string[] = [];
12
+ let current = "";
13
+
14
+ for (const word of words) {
15
+ if (current.length + word.length + 1 > maxWidth) {
16
+ lines.push(current);
17
+ current = word;
18
+ } else {
19
+ current = current ? `${current} ${word}` : word;
20
+ }
21
+ }
22
+ if (current) lines.push(current);
23
+ return lines;
24
+ }
25
+
26
+ /**
27
+ * Render a speech bubble + buddy art side by side.
28
+ *
29
+ * Output:
30
+ * ```
31
+ * .------------------------.
32
+ * | ship it, no tests | .---.
33
+ * | needed | (•ᴗ•)>
34
+ * '--------. ' /| |\
35
+ * \ " " "
36
+ * `-- Glitch
37
+ * ```
38
+ */
39
+ export function renderBuddyWithBubble(
40
+ quip: string,
41
+ buddyArt: string[],
42
+ buddyName: string,
43
+ gap: number = 3
44
+ ): string[] {
45
+ const maxBubbleWidth = 26;
46
+ const textLines = wrapText(quip, maxBubbleWidth - 4); // 4 for "| " and " |"
47
+ const innerWidth = textLines.reduce((a, l) => Math.max(a, l.length), 8);
48
+ const bubbleWidth = innerWidth + 4; // "| " + text + " |"
49
+
50
+ // Build bubble lines
51
+ const bubbleLines: string[] = [];
52
+
53
+ // Top border
54
+ bubbleLines.push(" ." + "-".repeat(bubbleWidth - 2) + ".");
55
+
56
+ // Content lines
57
+ for (const line of textLines) {
58
+ bubbleLines.push(" | " + line.padEnd(innerWidth) + " |");
59
+ }
60
+
61
+ // Bottom border with tail
62
+ const tailPos = Math.min(10, bubbleWidth - 2);
63
+ bubbleLines.push(" '" + "-".repeat(tailPos) + "." + " ".repeat(Math.max(0, bubbleWidth - tailPos - 3)) + "'");
64
+
65
+ // Tail lines
66
+ bubbleLines.push(" ".repeat(tailPos + 3) + "\\");
67
+
68
+ // Now compose: bubble on left, buddy art on right
69
+ // The buddy should start at the same height as the bottom of the bubble
70
+ const buddyStartLine = Math.max(0, bubbleLines.length - buddyArt.length - 1);
71
+ const totalLines = Math.max(bubbleLines.length, buddyStartLine + buddyArt.length + 1); // +1 for name
72
+
73
+ const result: string[] = [];
74
+ const gapStr = " ".repeat(gap);
75
+
76
+ for (let i = 0; i < totalLines; i++) {
77
+ const bubblePart = i < bubbleLines.length
78
+ ? bubbleLines[i]!.padEnd(bubbleWidth + 1)
79
+ : " ".repeat(bubbleWidth + 1);
80
+
81
+ const artIndex = i - buddyStartLine;
82
+ let buddyPart = "";
83
+ if (artIndex >= 0 && artIndex < buddyArt.length) {
84
+ buddyPart = buddyArt[artIndex]!;
85
+ } else if (artIndex === buddyArt.length) {
86
+ const artWidth = buddyArt[0]?.length ?? 0;
87
+ const leftPad = Math.floor((artWidth - buddyName.length) / 2);
88
+ buddyPart = " ".repeat(Math.max(0, leftPad)) + buddyName;
89
+ }
90
+
91
+ result.push(bubblePart + gapStr + buddyPart);
92
+ }
93
+
94
+ return result;
95
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Terminal spinner with fun, rotating loading phrases.
3
+ */
4
+
5
+ import { theme } from "./theme.ts";
6
+
7
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
8
+
9
+ const THINKING_PHRASES = [
10
+ "Thinking",
11
+ "Pondering the void",
12
+ "Consulting the silicon oracle",
13
+ "Crunching tokens",
14
+ "Reading your code intensely",
15
+ "Judging your variable names",
16
+ "Overthinking this",
17
+ "Building a mental model",
18
+ "Navigating the AST",
19
+ "Vibing with the codebase",
20
+ "Hallucinating responsibly",
21
+ "Assembling electrons",
22
+ "Parsing the matrix",
23
+ "Channeling the stack overflow",
24
+ "Contemplating semicolons",
25
+ "Refactoring my thoughts",
26
+ "Compiling a response",
27
+ "git blame-ing myself",
28
+ "Searching for meaning",
29
+ "Loading personality module",
30
+ "Warming up the GPU hamsters",
31
+ "Asking a smarter AI",
32
+ "Pretending to be sentient",
33
+ "Simulating expertise",
34
+ "Deploying to prod (jk)",
35
+ ];
36
+
37
+ const TOOL_PHRASES: Record<string, string[]> = {
38
+ Bash: ["Running commands", "Executing", "Shell magic"],
39
+ Read: ["Reading", "Scanning", "Absorbing"],
40
+ Write: ["Writing", "Creating", "Crafting"],
41
+ Edit: ["Editing", "Refactoring", "Tweaking"],
42
+ Glob: ["Searching files", "Globbing", "Finding"],
43
+ Grep: ["Searching code", "Grepping", "Hunting"],
44
+ Agent: ["Spawning agent", "Delegating", "Cloning myself"],
45
+ WebFetch: ["Fetching", "Downloading", "Surfing"],
46
+ WebSearch: ["Searching the web", "Googling", "Researching"],
47
+ };
48
+
49
+ export class Spinner {
50
+ private frameIndex = 0;
51
+ private interval: ReturnType<typeof setInterval> | null = null;
52
+ private text: string;
53
+ private startTime: number = 0;
54
+ private phraseIndex: number = 0;
55
+ private isThinking: boolean = true;
56
+
57
+ constructor(text = "Thinking") {
58
+ this.text = text;
59
+ this.phraseIndex = Math.floor(Math.random() * THINKING_PHRASES.length);
60
+ }
61
+
62
+ start(text?: string): void {
63
+ this.isThinking = !text; // If no text given, use thinking phrases
64
+ if (text) {
65
+ this.text = text;
66
+ } else {
67
+ this.text = THINKING_PHRASES[this.phraseIndex % THINKING_PHRASES.length]!;
68
+ }
69
+ this.startTime = Date.now();
70
+ this.frameIndex = 0;
71
+
72
+ let lastPhraseChange = Date.now();
73
+
74
+ this.interval = setInterval(() => {
75
+ const frame = FRAMES[this.frameIndex % FRAMES.length]!;
76
+ const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(1);
77
+
78
+ // Rotate thinking phrases every 3 seconds
79
+ if (this.isThinking && Date.now() - lastPhraseChange > 3000) {
80
+ this.phraseIndex++;
81
+ this.text = THINKING_PHRASES[this.phraseIndex % THINKING_PHRASES.length]!;
82
+ lastPhraseChange = Date.now();
83
+ }
84
+
85
+ // Gradient the spinner frame
86
+ const coloredFrame = theme.accent(frame);
87
+ const coloredText = theme.secondary(this.text);
88
+ const coloredTime = theme.muted(`${elapsed}s`);
89
+
90
+ process.stderr.write(`\r${coloredFrame} ${coloredText} ${coloredTime}`);
91
+ this.frameIndex++;
92
+ }, 80);
93
+ }
94
+
95
+ update(text: string): void {
96
+ this.text = text;
97
+ this.isThinking = false;
98
+ }
99
+
100
+ stop(): void {
101
+ if (this.interval) {
102
+ clearInterval(this.interval);
103
+ this.interval = null;
104
+ process.stderr.write("\r\x1b[K");
105
+ }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Get a fun phrase for a specific tool.
111
+ */
112
+ export function getToolPhrase(toolName: string): string {
113
+ const phrases = TOOL_PHRASES[toolName];
114
+ if (!phrases) return toolName;
115
+ return phrases[Math.floor(Math.random() * phrases.length)]!;
116
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Premium color theme for AshlrCode CLI.
3
+ *
4
+ * Vibrant, warm palette with high contrast and visual hierarchy.
5
+ * Inspired by modern terminal apps (Warp, Fig, Ghostty).
6
+ */
7
+
8
+ import chalk from "chalk";
9
+
10
+ export const theme = {
11
+ // ── Brand accent (vibrant cyan-blue gradient) ──
12
+ accent: chalk.hex("#38BDF8"), // sky-400 — bright, inviting
13
+ accentBold: chalk.hex("#38BDF8").bold,
14
+ accentDim: chalk.hex("#0EA5E9"), // sky-500
15
+
16
+ // ── Success (emerald green) ──
17
+ success: chalk.hex("#34D399"), // emerald-400
18
+ successDim: chalk.hex("#059669"),
19
+
20
+ // ── Warning (amber) ──
21
+ warning: chalk.hex("#FBBF24"), // amber-400
22
+ warningDim: chalk.hex("#D97706"),
23
+
24
+ // ── Error (rose) ──
25
+ error: chalk.hex("#FB7185"), // rose-400
26
+ errorDim: chalk.hex("#E11D48"),
27
+
28
+ // ── Info (violet) ──
29
+ info: chalk.hex("#A78BFA"), // violet-400
30
+ infoDim: chalk.hex("#7C3AED"),
31
+
32
+ // ── Plan mode (fuchsia) ──
33
+ plan: chalk.hex("#E879F9"), // fuchsia-400
34
+ planDim: chalk.hex("#C026D3"),
35
+
36
+ // ── Text hierarchy ──
37
+ primary: chalk.hex("#F1F5F9"), // slate-100 — bright, readable
38
+ secondary: chalk.hex("#94A3B8"), // slate-400 — secondary info
39
+ tertiary: chalk.hex("#64748B"), // slate-500 — de-emphasized
40
+ muted: chalk.hex("#475569"), // slate-600 — very dim
41
+ ghost: chalk.hex("#334155"), // slate-700 — barely visible
42
+
43
+ // ── Semantic colors ──
44
+ cost: chalk.hex("#FCD34D"), // amber-300
45
+ tokens: chalk.hex("#67E8F9"), // cyan-300
46
+ path: chalk.hex("#86EFAC"), // green-300
47
+ keyword: chalk.hex("#38BDF8"), // sky-400 — code keywords
48
+ string: chalk.hex("#34D399"), // emerald-400 — strings
49
+ comment: chalk.hex("#64748B"), // slate-500
50
+
51
+ // ── Tool display ──
52
+ toolName: chalk.hex("#38BDF8").bold, // sky-400 bold
53
+ toolIcon: chalk.hex("#67E8F9"), // cyan-300
54
+ toolResult: chalk.hex("#CBD5E1"), // slate-300
55
+
56
+ // ── Separators & borders ──
57
+ border: chalk.hex("#334155"), // slate-700
58
+ borderBright: chalk.hex("#475569"), // slate-600
59
+
60
+ // ── Prompt (colored ❯ per mode) ──
61
+ prompt: {
62
+ normal: chalk.hex("#34D399")("❯ "), // emerald
63
+ plan: chalk.hex("#E879F9")("❯ "), // fuchsia
64
+ edits: chalk.hex("#FBBF24")("❯ "), // amber
65
+ yolo: chalk.hex("#FB7185")("❯ "), // rose
66
+ },
67
+ } as const;
68
+
69
+ // ── Helper formatters ──
70
+
71
+ export function stylePath(p: string): string {
72
+ return theme.path(p);
73
+ }
74
+
75
+ export function styleCost(usd: number): string {
76
+ return theme.cost(`$${usd < 0.01 ? usd.toFixed(6) : usd.toFixed(4)}`);
77
+ }
78
+
79
+ export function styleTokens(count: number): string {
80
+ if (count >= 1_000_000) return theme.tokens(`${(count / 1_000_000).toFixed(1)}M`);
81
+ if (count >= 1_000) return theme.tokens(`${(count / 1_000).toFixed(0)}K`);
82
+ return theme.tokens(`${count}`);
83
+ }
84
+
85
+ /**
86
+ * Style a label with a colored badge background.
87
+ */
88
+ export function badge(text: string, color: "accent" | "success" | "warning" | "error" | "plan"): string {
89
+ const colors: Record<string, [string, string]> = {
90
+ accent: ["#0EA5E9", "#F1F5F9"],
91
+ success: ["#059669", "#F1F5F9"],
92
+ warning: ["#D97706", "#1C1917"],
93
+ error: ["#E11D48", "#F1F5F9"],
94
+ plan: ["#C026D3", "#F1F5F9"],
95
+ };
96
+ const [bg, fg] = colors[color] ?? ["#334155", "#F1F5F9"];
97
+ return chalk.bgHex(bg!).hex(fg!).bold(` ${text} `);
98
+ }
package/src/version.ts ADDED
@@ -0,0 +1,5 @@
1
+ // Use Bun's built-in JSON import so the version is embedded at compile time.
2
+ // This works both in dev (bun run) and in compiled binaries (bun build --compile).
3
+ import packageJson from "../package.json";
4
+
5
+ export const VERSION: string = packageJson.version;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Voice Mode — push-to-talk voice input.
3
+ *
4
+ * Records audio while a key is held, then sends to a speech-to-text
5
+ * service for transcription. The transcribed text becomes the user's input.
6
+ */
7
+
8
+ import { spawn, type Subprocess } from "bun";
9
+ import { join } from "path";
10
+ import { getConfigDir } from "../config/settings.ts";
11
+ import { mkdir, unlink } from "fs/promises";
12
+ import { existsSync } from "fs";
13
+
14
+ export interface VoiceConfig {
15
+ sttProvider: "whisper-local" | "whisper-api" | "none";
16
+ whisperApiKey?: string;
17
+ whisperModel?: string;
18
+ recordingFormat?: "wav" | "mp3";
19
+ sampleRate?: number;
20
+ }
21
+
22
+ let _recording: Subprocess | null = null;
23
+ let _recordingPath: string | null = null;
24
+
25
+ function getVoiceDir(): string {
26
+ return join(getConfigDir(), "voice");
27
+ }
28
+
29
+ /**
30
+ * Start recording audio via system microphone.
31
+ * Uses sox (rec) on macOS/Linux.
32
+ */
33
+ export async function startRecording(): Promise<void> {
34
+ if (_recording) return; // Already recording
35
+
36
+ const dir = getVoiceDir();
37
+ await mkdir(dir, { recursive: true });
38
+
39
+ _recordingPath = join(dir, `recording-${Date.now()}.wav`);
40
+
41
+ // Use sox/rec for cross-platform recording
42
+ // macOS: brew install sox
43
+ // Linux: apt install sox
44
+ _recording = spawn(["rec", "-q", "-r", "16000", "-c", "1", "-b", "16", _recordingPath], {
45
+ stdout: "pipe",
46
+ stderr: "pipe",
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Stop recording and return the audio file path.
52
+ */
53
+ export async function stopRecording(): Promise<string | null> {
54
+ if (!_recording || !_recordingPath) return null;
55
+
56
+ _recording.kill("SIGINT"); // Graceful stop
57
+ await _recording.exited.catch(() => {});
58
+ _recording = null;
59
+
60
+ const path = _recordingPath;
61
+ _recordingPath = null;
62
+
63
+ return existsSync(path) ? path : null;
64
+ }
65
+
66
+ /**
67
+ * Check if currently recording.
68
+ */
69
+ export function isRecording(): boolean {
70
+ return _recording !== null;
71
+ }
72
+
73
+ /**
74
+ * Transcribe audio file using Whisper API.
75
+ */
76
+ export async function transcribeWhisperAPI(
77
+ audioPath: string,
78
+ apiKey: string,
79
+ model: string = "whisper-1",
80
+ ): Promise<string> {
81
+ const file = Bun.file(audioPath);
82
+ const formData = new FormData();
83
+ formData.append("file", file);
84
+ formData.append("model", model);
85
+
86
+ const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
87
+ method: "POST",
88
+ headers: { Authorization: `Bearer ${apiKey}` },
89
+ body: formData,
90
+ });
91
+
92
+ if (!response.ok) throw new Error(`Whisper API error: ${response.status}`);
93
+ const data = (await response.json()) as { text: string };
94
+ return data.text;
95
+ }
96
+
97
+ /**
98
+ * Transcribe audio file using local Whisper (whisper.cpp or faster-whisper).
99
+ */
100
+ export async function transcribeWhisperLocal(audioPath: string): Promise<string> {
101
+ // Try whisper.cpp first
102
+ const proc = spawn(
103
+ ["whisper", "--model", "base", "--output-format", "txt", "--no-timestamps", audioPath],
104
+ { stdout: "pipe", stderr: "pipe" },
105
+ );
106
+
107
+ const output = (await new Response(proc.stdout).text()).trim();
108
+ const exitCode = await proc.exited;
109
+
110
+ if (exitCode !== 0) {
111
+ // Try faster-whisper as fallback
112
+ const proc2 = spawn(["faster-whisper", audioPath, "--model", "base"], {
113
+ stdout: "pipe",
114
+ stderr: "pipe",
115
+ });
116
+ const output2 = (await new Response(proc2.stdout).text()).trim();
117
+ await proc2.exited;
118
+ return output2 || "Failed to transcribe audio";
119
+ }
120
+
121
+ return output;
122
+ }
123
+
124
+ /**
125
+ * Full voice-to-text pipeline: record -> stop -> transcribe.
126
+ */
127
+ export async function transcribeRecording(config: VoiceConfig): Promise<string | null> {
128
+ const audioPath = await stopRecording();
129
+ if (!audioPath) return null;
130
+
131
+ try {
132
+ let text: string;
133
+ if (config.sttProvider === "whisper-api" && config.whisperApiKey) {
134
+ text = await transcribeWhisperAPI(audioPath, config.whisperApiKey, config.whisperModel);
135
+ } else if (config.sttProvider === "whisper-local") {
136
+ text = await transcribeWhisperLocal(audioPath);
137
+ } else {
138
+ return null;
139
+ }
140
+
141
+ // Cleanup recording
142
+ await unlink(audioPath).catch(() => {});
143
+ return text.trim();
144
+ } catch (err) {
145
+ await unlink(audioPath).catch(() => {});
146
+ throw err;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Check if voice recording tools are available.
152
+ */
153
+ export async function checkVoiceAvailability(): Promise<{ available: boolean; details: string }> {
154
+ try {
155
+ const proc = spawn(["which", "rec"], { stdout: "pipe", stderr: "pipe" });
156
+ const output = (await new Response(proc.stdout).text()).trim();
157
+ await proc.exited;
158
+
159
+ if (output) {
160
+ return { available: true, details: "sox/rec available for audio recording" };
161
+ }
162
+ return {
163
+ available: false,
164
+ details: "Install sox: brew install sox (macOS) or apt install sox (Linux)",
165
+ };
166
+ } catch {
167
+ return { available: false, details: "Cannot detect audio recording tools" };
168
+ }
169
+ }