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.
- package/LICENSE +21 -0
- package/README.md +295 -0
- package/package.json +46 -0
- package/src/__tests__/branded-types.test.ts +47 -0
- package/src/__tests__/context.test.ts +163 -0
- package/src/__tests__/cost-tracker.test.ts +274 -0
- package/src/__tests__/cron.test.ts +197 -0
- package/src/__tests__/dream.test.ts +204 -0
- package/src/__tests__/error-handler.test.ts +192 -0
- package/src/__tests__/features.test.ts +69 -0
- package/src/__tests__/file-history.test.ts +177 -0
- package/src/__tests__/hooks.test.ts +145 -0
- package/src/__tests__/keybindings.test.ts +159 -0
- package/src/__tests__/model-patches.test.ts +82 -0
- package/src/__tests__/permissions-rules.test.ts +121 -0
- package/src/__tests__/permissions.test.ts +108 -0
- package/src/__tests__/project-config.test.ts +63 -0
- package/src/__tests__/retry.test.ts +321 -0
- package/src/__tests__/router.test.ts +158 -0
- package/src/__tests__/session-compact.test.ts +191 -0
- package/src/__tests__/session.test.ts +145 -0
- package/src/__tests__/skill-registry.test.ts +130 -0
- package/src/__tests__/speculation.test.ts +196 -0
- package/src/__tests__/tasks-v2.test.ts +267 -0
- package/src/__tests__/telemetry.test.ts +149 -0
- package/src/__tests__/tool-executor.test.ts +141 -0
- package/src/__tests__/tool-registry.test.ts +166 -0
- package/src/__tests__/undercover.test.ts +93 -0
- package/src/__tests__/workflow.test.ts +195 -0
- package/src/agent/async-context.ts +64 -0
- package/src/agent/context.ts +245 -0
- package/src/agent/cron.ts +189 -0
- package/src/agent/dream.ts +165 -0
- package/src/agent/error-handler.ts +108 -0
- package/src/agent/ipc.ts +256 -0
- package/src/agent/kairos.ts +207 -0
- package/src/agent/loop.ts +314 -0
- package/src/agent/model-patches.ts +68 -0
- package/src/agent/speculation.ts +219 -0
- package/src/agent/sub-agent.ts +125 -0
- package/src/agent/system-prompt.ts +231 -0
- package/src/agent/team.ts +220 -0
- package/src/agent/tool-executor.ts +162 -0
- package/src/agent/workflow.ts +189 -0
- package/src/agent/worktree-manager.ts +86 -0
- package/src/autopilot/queue.ts +186 -0
- package/src/autopilot/scanner.ts +245 -0
- package/src/autopilot/types.ts +58 -0
- package/src/bridge/bridge-client.ts +57 -0
- package/src/bridge/bridge-server.ts +81 -0
- package/src/cli.ts +1120 -0
- package/src/config/features.ts +51 -0
- package/src/config/git.ts +137 -0
- package/src/config/hooks.ts +201 -0
- package/src/config/permissions.ts +251 -0
- package/src/config/project-config.ts +63 -0
- package/src/config/remote-settings.ts +163 -0
- package/src/config/settings-sync.ts +170 -0
- package/src/config/settings.ts +113 -0
- package/src/config/undercover.ts +76 -0
- package/src/config/upgrade-notice.ts +65 -0
- package/src/mcp/client.ts +197 -0
- package/src/mcp/manager.ts +125 -0
- package/src/mcp/oauth.ts +252 -0
- package/src/mcp/types.ts +61 -0
- package/src/persistence/memory.ts +129 -0
- package/src/persistence/session.ts +289 -0
- package/src/planning/plan-mode.ts +128 -0
- package/src/planning/plan-tools.ts +138 -0
- package/src/providers/anthropic.ts +177 -0
- package/src/providers/cost-tracker.ts +184 -0
- package/src/providers/retry.ts +264 -0
- package/src/providers/router.ts +159 -0
- package/src/providers/types.ts +79 -0
- package/src/providers/xai.ts +217 -0
- package/src/repl.tsx +1384 -0
- package/src/setup.ts +119 -0
- package/src/skills/loader.ts +78 -0
- package/src/skills/registry.ts +78 -0
- package/src/skills/types.ts +11 -0
- package/src/state/file-history.ts +264 -0
- package/src/telemetry/event-log.ts +116 -0
- package/src/tools/agent.ts +133 -0
- package/src/tools/ask-user.ts +229 -0
- package/src/tools/bash.ts +146 -0
- package/src/tools/config.ts +147 -0
- package/src/tools/diff.ts +137 -0
- package/src/tools/file-edit.ts +123 -0
- package/src/tools/file-read.ts +82 -0
- package/src/tools/file-write.ts +82 -0
- package/src/tools/glob.ts +76 -0
- package/src/tools/grep.ts +187 -0
- package/src/tools/ls.ts +77 -0
- package/src/tools/lsp.ts +375 -0
- package/src/tools/mcp-resources.ts +83 -0
- package/src/tools/mcp-tool.ts +47 -0
- package/src/tools/memory.ts +148 -0
- package/src/tools/notebook-edit.ts +133 -0
- package/src/tools/peers.ts +113 -0
- package/src/tools/powershell.ts +83 -0
- package/src/tools/registry.ts +114 -0
- package/src/tools/send-message.ts +75 -0
- package/src/tools/sleep.ts +50 -0
- package/src/tools/snip.ts +143 -0
- package/src/tools/tasks.ts +349 -0
- package/src/tools/team.ts +309 -0
- package/src/tools/todo-write.ts +93 -0
- package/src/tools/tool-search.ts +83 -0
- package/src/tools/types.ts +52 -0
- package/src/tools/web-browser.ts +263 -0
- package/src/tools/web-fetch.ts +118 -0
- package/src/tools/web-search.ts +107 -0
- package/src/tools/workflow.ts +188 -0
- package/src/tools/worktree.ts +143 -0
- package/src/types/branded.ts +22 -0
- package/src/ui/App.tsx +184 -0
- package/src/ui/BuddyPanel.tsx +52 -0
- package/src/ui/PermissionPrompt.tsx +29 -0
- package/src/ui/banner.ts +217 -0
- package/src/ui/buddy-ai.ts +108 -0
- package/src/ui/buddy.ts +466 -0
- package/src/ui/context-bar.ts +60 -0
- package/src/ui/effort.ts +65 -0
- package/src/ui/keybindings.ts +143 -0
- package/src/ui/markdown.ts +271 -0
- package/src/ui/message-renderer.ts +73 -0
- package/src/ui/mode.ts +80 -0
- package/src/ui/notifications.ts +57 -0
- package/src/ui/speech-bubble.ts +95 -0
- package/src/ui/spinner.ts +116 -0
- package/src/ui/theme.ts +98 -0
- package/src/version.ts +5 -0
- 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
|
+
}
|
package/src/ui/theme.ts
ADDED
|
@@ -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,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
|
+
}
|