@andypai/orb 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/LICENSE +21 -0
- package/README.md +349 -0
- package/assets/orb-logo.svg +75 -0
- package/assets/orb-terminal-session.svg +72 -0
- package/assets/orb-wordmark.svg +77 -0
- package/package.json +76 -0
- package/prompts/anthropic.md +2 -0
- package/prompts/base.md +1 -0
- package/prompts/openai.md +7 -0
- package/prompts/voice.md +12 -0
- package/src/cli.ts +9 -0
- package/src/config.ts +270 -0
- package/src/index.ts +82 -0
- package/src/pipeline/adapters/anthropic.ts +111 -0
- package/src/pipeline/adapters/openai.ts +202 -0
- package/src/pipeline/adapters/types.ts +16 -0
- package/src/pipeline/adapters/utils.ts +131 -0
- package/src/pipeline/frames.ts +113 -0
- package/src/pipeline/observer.ts +36 -0
- package/src/pipeline/observers/metrics.ts +95 -0
- package/src/pipeline/pipeline.ts +43 -0
- package/src/pipeline/processor.ts +57 -0
- package/src/pipeline/processors/agent.ts +38 -0
- package/src/pipeline/processors/tts.ts +120 -0
- package/src/pipeline/task.ts +239 -0
- package/src/pipeline/transports/terminal-text.ts +24 -0
- package/src/pipeline/transports/types.ts +33 -0
- package/src/services/auth-utils.ts +149 -0
- package/src/services/global-config.ts +363 -0
- package/src/services/openai-auth.ts +18 -0
- package/src/services/prompts.ts +76 -0
- package/src/services/provider-defaults.ts +97 -0
- package/src/services/session.ts +204 -0
- package/src/services/streaming-tts.ts +483 -0
- package/src/services/tts.ts +309 -0
- package/src/setup.ts +234 -0
- package/src/types/index.ts +108 -0
- package/src/ui/App.tsx +142 -0
- package/src/ui/components/ActivityTimeline.tsx +60 -0
- package/src/ui/components/AsciiOrb.tsx +92 -0
- package/src/ui/components/ConversationRail.tsx +44 -0
- package/src/ui/components/Footer.tsx +61 -0
- package/src/ui/components/InputPrompt.tsx +88 -0
- package/src/ui/components/MicroOrb.tsx +25 -0
- package/src/ui/components/TTSErrorBanner.tsx +36 -0
- package/src/ui/components/TurnRow.tsx +71 -0
- package/src/ui/components/WelcomeSplash.tsx +78 -0
- package/src/ui/hooks/useAnimationFrame.ts +33 -0
- package/src/ui/hooks/useConversation.ts +195 -0
- package/src/ui/hooks/useKeyboardShortcuts.ts +57 -0
- package/src/ui/hooks/usePipeline.ts +83 -0
- package/src/ui/hooks/useTerminalSize.ts +37 -0
- package/src/ui/utils/markdown.ts +89 -0
- package/src/ui/utils/model-label.ts +20 -0
- package/src/ui/utils/text.ts +18 -0
- package/src/ui/utils/tool-format.ts +40 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import { URL } from 'node:url'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { unlink } from 'node:fs/promises'
|
|
6
|
+
import { TTSError, type AppConfig, type TTSErrorType, type Voice } from '../types'
|
|
7
|
+
import { cleanTextForSpeech } from '../ui/utils/markdown'
|
|
8
|
+
|
|
9
|
+
export { cleanTextForSpeech }
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_SERVER_URL = 'http://localhost:8000'
|
|
12
|
+
const DEFAULT_SAY_RATE_WPM = 175
|
|
13
|
+
const SAY_VOICE_BY_ORB_VOICE: Record<Voice, string> = {
|
|
14
|
+
alba: 'Samantha',
|
|
15
|
+
marius: 'Daniel',
|
|
16
|
+
jean: 'Eddy (English (US))',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function categorizeTTSError(err: unknown, context: 'generate' | 'playback'): TTSError {
|
|
20
|
+
if (err instanceof TTSError) return err
|
|
21
|
+
|
|
22
|
+
const error = err instanceof Error ? err : new Error(String(err))
|
|
23
|
+
const nodeError = error as Error & { code?: string }
|
|
24
|
+
|
|
25
|
+
if (nodeError.code === 'ENOENT') {
|
|
26
|
+
const cmd = context === 'generate' ? 'say' : 'afplay'
|
|
27
|
+
return new TTSError(`Command not found: ${cmd}`, 'command_not_found', error)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const type: TTSErrorType = context === 'generate' ? 'generation_failed' : 'audio_playback'
|
|
31
|
+
return new TTSError(error.message, type, error)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let currentPlayProcess: Bun.Subprocess | null = null
|
|
35
|
+
let playbackStoppedManually = false
|
|
36
|
+
|
|
37
|
+
export function wasPlaybackStopped(): boolean {
|
|
38
|
+
return playbackStoppedManually
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function resetPlaybackStoppedFlag(): void {
|
|
42
|
+
playbackStoppedManually = false
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function splitIntoSentences(text: string): string[] {
|
|
46
|
+
const sentences: string[] = []
|
|
47
|
+
let current = ''
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < text.length; i++) {
|
|
50
|
+
const char = text[i]
|
|
51
|
+
if (char === undefined) continue
|
|
52
|
+
|
|
53
|
+
current += char
|
|
54
|
+
|
|
55
|
+
if (['.', '!', '?'].includes(char)) {
|
|
56
|
+
const next = text[i + 1]
|
|
57
|
+
if (next === undefined || next === ' ' || next === '\n') {
|
|
58
|
+
const trimmed = current.trim()
|
|
59
|
+
if (trimmed.length > 0) {
|
|
60
|
+
sentences.push(trimmed)
|
|
61
|
+
}
|
|
62
|
+
current = ''
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const trimmed = current.trim()
|
|
68
|
+
if (trimmed.length > 0) {
|
|
69
|
+
sentences.push(trimmed)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return sentences
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeServerUrl(rawUrl: string): string {
|
|
76
|
+
const trimmed = rawUrl.trim() || DEFAULT_SERVER_URL
|
|
77
|
+
|
|
78
|
+
let url: URL
|
|
79
|
+
try {
|
|
80
|
+
url = new URL(trimmed)
|
|
81
|
+
} catch {
|
|
82
|
+
throw new TTSError('Invalid TTS server URL', 'generation_failed')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!url.pathname || url.pathname === '/') {
|
|
86
|
+
url.pathname = '/tts'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return url.toString()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function readErrorMessage(response: { text: () => Promise<string> }): Promise<string | null> {
|
|
93
|
+
try {
|
|
94
|
+
const text = await response.text()
|
|
95
|
+
return text.trim() || null
|
|
96
|
+
} catch {
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isValidSpeed(speed: number | undefined): speed is number {
|
|
102
|
+
return typeof speed === 'number' && Number.isFinite(speed) && speed > 0
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function getTempAudioExtension(mode: AppConfig['ttsMode']): string {
|
|
106
|
+
return mode === 'generate' ? 'aiff' : 'wav'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function mapVoiceToSayVoice(voice: Voice): string {
|
|
110
|
+
return SAY_VOICE_BY_ORB_VOICE[voice]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function mapSpeedToSayRate(speed: number): number | undefined {
|
|
114
|
+
if (!isValidSpeed(speed)) return undefined
|
|
115
|
+
return Math.max(90, Math.round(DEFAULT_SAY_RATE_WPM * speed))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function buildSpeechFormData(
|
|
119
|
+
text: string,
|
|
120
|
+
voice: string | undefined,
|
|
121
|
+
speed: number,
|
|
122
|
+
): globalThis.FormData {
|
|
123
|
+
const formData = new globalThis.FormData()
|
|
124
|
+
formData.append('text', text)
|
|
125
|
+
if (voice) {
|
|
126
|
+
formData.append('voice', voice)
|
|
127
|
+
}
|
|
128
|
+
if (isValidSpeed(speed)) {
|
|
129
|
+
formData.append('speed', String(speed))
|
|
130
|
+
}
|
|
131
|
+
return formData
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function requestServerSpeech(
|
|
135
|
+
serverUrl: string,
|
|
136
|
+
text: string,
|
|
137
|
+
voice: string,
|
|
138
|
+
speed: number,
|
|
139
|
+
signal?: globalThis.AbortSignal,
|
|
140
|
+
): Promise<Buffer> {
|
|
141
|
+
async function postSpeech(formData: globalThis.FormData): Promise<Response> {
|
|
142
|
+
return await fetch(serverUrl, {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
body: formData,
|
|
145
|
+
signal,
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let response = await postSpeech(buildSpeechFormData(text, voice, speed))
|
|
150
|
+
if (!response.ok && voice) {
|
|
151
|
+
// Some tts-gateway providers use a different voice namespace than Orb's voice presets.
|
|
152
|
+
// Retry once without an explicit voice so the server can fall back to its default.
|
|
153
|
+
response = await postSpeech(buildSpeechFormData(text, undefined, speed))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!response.ok) {
|
|
157
|
+
const message = await readErrorMessage(response)
|
|
158
|
+
const details = message ? `: ${message}` : ''
|
|
159
|
+
throw new TTSError(`TTS server error (${response.status})${details}`, 'generation_failed')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const audioBuffer = await response.arrayBuffer()
|
|
163
|
+
return Buffer.from(audioBuffer)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function runGenerateCommand(
|
|
167
|
+
text: string,
|
|
168
|
+
voice: Voice,
|
|
169
|
+
speed: number,
|
|
170
|
+
outputPath: string,
|
|
171
|
+
): Promise<void> {
|
|
172
|
+
if (process.platform !== 'darwin') {
|
|
173
|
+
throw new TTSError(
|
|
174
|
+
'Generate mode requires macOS say. Use serve mode with tts-gateway on this platform.',
|
|
175
|
+
'command_not_found',
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function runSay(voiceName?: string): Promise<number> {
|
|
180
|
+
const cmd = ['say', '-o', outputPath]
|
|
181
|
+
if (voiceName) {
|
|
182
|
+
cmd.push('-v', voiceName)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const rate = mapSpeedToSayRate(speed)
|
|
186
|
+
if (rate) {
|
|
187
|
+
cmd.push('-r', String(rate))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
cmd.push(text)
|
|
191
|
+
|
|
192
|
+
const proc = Bun.spawn(cmd, { stdout: 'ignore', stderr: 'ignore' })
|
|
193
|
+
return await proc.exited
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const sayVoice = mapVoiceToSayVoice(voice)
|
|
197
|
+
let exitCode = await runSay(sayVoice)
|
|
198
|
+
if (exitCode !== 0 && sayVoice) {
|
|
199
|
+
exitCode = await runSay()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (exitCode !== 0) {
|
|
203
|
+
throw new TTSError(`say exited with code ${exitCode}`, 'generation_failed')
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function generateAudio(
|
|
208
|
+
text: string,
|
|
209
|
+
config: AppConfig,
|
|
210
|
+
outputPath: string,
|
|
211
|
+
signal?: globalThis.AbortSignal,
|
|
212
|
+
): Promise<void> {
|
|
213
|
+
try {
|
|
214
|
+
if (config.ttsMode === 'serve') {
|
|
215
|
+
const serverUrl = normalizeServerUrl(config.ttsServerUrl ?? DEFAULT_SERVER_URL)
|
|
216
|
+
const audio = await requestServerSpeech(
|
|
217
|
+
serverUrl,
|
|
218
|
+
text,
|
|
219
|
+
config.ttsVoice,
|
|
220
|
+
config.ttsSpeed,
|
|
221
|
+
signal,
|
|
222
|
+
)
|
|
223
|
+
await Bun.write(outputPath, audio)
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
await runGenerateCommand(text, config.ttsVoice, config.ttsSpeed, outputPath)
|
|
228
|
+
} catch (err) {
|
|
229
|
+
throw categorizeTTSError(err, 'generate')
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function playAudio(path: string, speed?: number): Promise<void> {
|
|
234
|
+
const args = isValidSpeed(speed) ? [path, '-r', String(speed)] : [path]
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
currentPlayProcess = Bun.spawn(['afplay', ...args], { stdout: 'ignore', stderr: 'ignore' })
|
|
238
|
+
} catch (err) {
|
|
239
|
+
currentPlayProcess = null
|
|
240
|
+
throw categorizeTTSError(err, 'playback')
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const proc = currentPlayProcess
|
|
244
|
+
if (!proc) {
|
|
245
|
+
throw new TTSError('Audio playback failed to start', 'audio_playback')
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const exitCode = await proc.exited
|
|
249
|
+
currentPlayProcess = null
|
|
250
|
+
|
|
251
|
+
const wasManualStop = playbackStoppedManually
|
|
252
|
+
if (wasManualStop) {
|
|
253
|
+
resetPlaybackStoppedFlag()
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (exitCode !== 0 && !wasManualStop) {
|
|
257
|
+
throw new TTSError(`afplay exited with code ${exitCode}`, 'audio_playback')
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function stopSpeaking(): void {
|
|
262
|
+
if (currentPlayProcess) {
|
|
263
|
+
playbackStoppedManually = true
|
|
264
|
+
currentPlayProcess.kill()
|
|
265
|
+
currentPlayProcess = null
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function speak(text: string, config: AppConfig): Promise<void> {
|
|
270
|
+
if (!config.ttsEnabled) return
|
|
271
|
+
|
|
272
|
+
const cleanText = cleanTextForSpeech(text)
|
|
273
|
+
if (!cleanText) return
|
|
274
|
+
|
|
275
|
+
const sentences = splitIntoSentences(cleanText)
|
|
276
|
+
let spokenCount = 0
|
|
277
|
+
let firstError: TTSError | null = null
|
|
278
|
+
|
|
279
|
+
for (const [i, sentence] of sentences.entries()) {
|
|
280
|
+
const audioPath = join(
|
|
281
|
+
tmpdir(),
|
|
282
|
+
`tts-${Date.now()}-${i}.${getTempAudioExtension(config.ttsMode)}`,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
await generateAudio(sentence, config, audioPath)
|
|
287
|
+
await playAudio(audioPath, config.ttsSpeed)
|
|
288
|
+
spokenCount += 1
|
|
289
|
+
} catch (err) {
|
|
290
|
+
if (err instanceof TTSError) {
|
|
291
|
+
if (err.type === 'command_not_found') {
|
|
292
|
+
throw err // Fatal - no point continuing
|
|
293
|
+
}
|
|
294
|
+
firstError ??= err
|
|
295
|
+
if (config.ttsMode === 'serve') {
|
|
296
|
+
throw err
|
|
297
|
+
}
|
|
298
|
+
} else {
|
|
299
|
+
firstError ??= categorizeTTSError(err, 'generate')
|
|
300
|
+
}
|
|
301
|
+
} finally {
|
|
302
|
+
await unlink(audioPath).catch(() => {})
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (spokenCount === 0 && firstError) {
|
|
307
|
+
throw firstError
|
|
308
|
+
}
|
|
309
|
+
}
|
package/src/setup.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { cancel, confirm, intro, isCancel, outro, select, text } from '@clack/prompts'
|
|
2
|
+
import { Command } from 'commander'
|
|
3
|
+
import {
|
|
4
|
+
getGlobalConfigPath,
|
|
5
|
+
loadGlobalConfig,
|
|
6
|
+
writeGlobalConfig,
|
|
7
|
+
type OrbGlobalConfig,
|
|
8
|
+
} from './services/global-config'
|
|
9
|
+
import { DEFAULT_MODEL_BY_PROVIDER } from './config'
|
|
10
|
+
import { VOICES, type LlmProvider, type Voice } from './types'
|
|
11
|
+
|
|
12
|
+
const SETUP_CANCELED = 'Setup canceled.'
|
|
13
|
+
const KOKORO_SPACY_INSTALL =
|
|
14
|
+
'~/.local/share/uv/tools/tts-gateway/bin/python -m spacy download en_core_web_sm'
|
|
15
|
+
|
|
16
|
+
interface RunSetupOptions {
|
|
17
|
+
configPath?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function ensureInteractiveTerminal(): void {
|
|
21
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
22
|
+
throw new Error('Interactive setup requires a TTY.')
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function throwIfCanceled<T>(value: T | symbol): T {
|
|
27
|
+
if (isCancel(value)) {
|
|
28
|
+
cancel(SETUP_CANCELED)
|
|
29
|
+
throw new Error(SETUP_CANCELED)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return value
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function promptText(args: {
|
|
36
|
+
message: string
|
|
37
|
+
initialValue?: string
|
|
38
|
+
placeholder?: string
|
|
39
|
+
validate?: (value: string) => string | undefined
|
|
40
|
+
}): Promise<string> {
|
|
41
|
+
const value = await text({
|
|
42
|
+
message: args.message,
|
|
43
|
+
initialValue: args.initialValue,
|
|
44
|
+
placeholder: args.placeholder,
|
|
45
|
+
validate: args.validate ? (raw) => args.validate?.((raw ?? '').toString().trim()) : undefined,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
return String(throwIfCanceled(value)).trim()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function defaultModelFor(provider: LlmProvider, current?: string): string {
|
|
52
|
+
if (!current?.trim()) return DEFAULT_MODEL_BY_PROVIDER[provider]
|
|
53
|
+
return current
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function mergeSetupConfig(base: OrbGlobalConfig, updates: OrbGlobalConfig): OrbGlobalConfig {
|
|
57
|
+
return {
|
|
58
|
+
...base,
|
|
59
|
+
...updates,
|
|
60
|
+
tts: {
|
|
61
|
+
...base.tts,
|
|
62
|
+
...updates.tts,
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function printTtsSetupNextSteps(config: OrbGlobalConfig): void {
|
|
68
|
+
if (!config.tts?.enabled) return
|
|
69
|
+
|
|
70
|
+
console.info('')
|
|
71
|
+
|
|
72
|
+
if (config.tts.mode === 'generate') {
|
|
73
|
+
console.info('Generate mode uses macOS `say` and `afplay`; no tts-gateway server is required.')
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const serverUrl = config.tts.serverUrl ?? 'http://localhost:8000'
|
|
78
|
+
|
|
79
|
+
console.info('Serve mode quick start:')
|
|
80
|
+
console.info(' uv tool install tts-gateway[kokoro]')
|
|
81
|
+
console.info(' # Required once for Kokoro inside uv tool environments')
|
|
82
|
+
console.info(` ${KOKORO_SPACY_INSTALL}`)
|
|
83
|
+
console.info(' tts serve --provider kokoro --port 8000')
|
|
84
|
+
console.info(`Orb will send speech requests to ${serverUrl}.`)
|
|
85
|
+
console.info('Use --tts-server-url or tts.server_url if your gateway runs elsewhere.')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function runSetup(options: RunSetupOptions = {}): Promise<void> {
|
|
89
|
+
ensureInteractiveTerminal()
|
|
90
|
+
|
|
91
|
+
const configPath = options.configPath ?? getGlobalConfigPath()
|
|
92
|
+
const existing = await loadGlobalConfig(configPath)
|
|
93
|
+
for (const warning of existing.warnings) {
|
|
94
|
+
console.warn(`[orb] ${warning}`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const current = existing.config
|
|
98
|
+
const currentProvider = current.provider ?? 'anthropic'
|
|
99
|
+
const currentModel = defaultModelFor(currentProvider, current.model)
|
|
100
|
+
|
|
101
|
+
intro('orb setup')
|
|
102
|
+
console.info(`Orb will save your defaults to ${configPath}.`)
|
|
103
|
+
|
|
104
|
+
const provider = throwIfCanceled(
|
|
105
|
+
await select({
|
|
106
|
+
message: 'Default provider',
|
|
107
|
+
initialValue: currentProvider,
|
|
108
|
+
options: [
|
|
109
|
+
{ value: 'anthropic', label: 'Anthropic' },
|
|
110
|
+
{ value: 'openai', label: 'OpenAI' },
|
|
111
|
+
],
|
|
112
|
+
}),
|
|
113
|
+
) as LlmProvider
|
|
114
|
+
|
|
115
|
+
const model = await promptText({
|
|
116
|
+
message: 'Default model',
|
|
117
|
+
initialValue:
|
|
118
|
+
current.provider === provider ? currentModel : DEFAULT_MODEL_BY_PROVIDER[provider],
|
|
119
|
+
validate: (value) => (value.length === 0 ? 'Model is required.' : undefined),
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const skipIntro = throwIfCanceled(
|
|
123
|
+
await confirm({
|
|
124
|
+
message: 'Skip the welcome animation by default?',
|
|
125
|
+
initialValue: current.skipIntro ?? false,
|
|
126
|
+
}),
|
|
127
|
+
) as boolean
|
|
128
|
+
|
|
129
|
+
const ttsEnabled = throwIfCanceled(
|
|
130
|
+
await confirm({
|
|
131
|
+
message: 'Enable text-to-speech by default?',
|
|
132
|
+
initialValue: current.tts?.enabled ?? true,
|
|
133
|
+
}),
|
|
134
|
+
) as boolean
|
|
135
|
+
|
|
136
|
+
const streamingEnabled = throwIfCanceled(
|
|
137
|
+
await confirm({
|
|
138
|
+
message: 'Enable streaming TTS by default?',
|
|
139
|
+
initialValue: current.tts?.streaming ?? true,
|
|
140
|
+
}),
|
|
141
|
+
) as boolean
|
|
142
|
+
|
|
143
|
+
const ttsMode = throwIfCanceled(
|
|
144
|
+
await select({
|
|
145
|
+
message: 'Default TTS mode',
|
|
146
|
+
initialValue: current.tts?.mode ?? 'serve',
|
|
147
|
+
options: [
|
|
148
|
+
{ value: 'serve', label: 'Serve (HTTP TTS server)' },
|
|
149
|
+
{ value: 'generate', label: 'Generate (local macOS say fallback)' },
|
|
150
|
+
],
|
|
151
|
+
}),
|
|
152
|
+
) as 'serve' | 'generate'
|
|
153
|
+
|
|
154
|
+
const serverUrl =
|
|
155
|
+
ttsMode === 'serve'
|
|
156
|
+
? await promptText({
|
|
157
|
+
message: 'Default TTS server URL',
|
|
158
|
+
initialValue: current.tts?.serverUrl ?? 'http://localhost:8000',
|
|
159
|
+
validate: (value) =>
|
|
160
|
+
value.length === 0 ? 'Server URL is required in serve mode.' : undefined,
|
|
161
|
+
})
|
|
162
|
+
: undefined
|
|
163
|
+
|
|
164
|
+
const voice = throwIfCanceled(
|
|
165
|
+
await select({
|
|
166
|
+
message: 'Default voice',
|
|
167
|
+
initialValue: current.tts?.voice ?? 'alba',
|
|
168
|
+
options: VOICES.map((value) => ({ value, label: value })),
|
|
169
|
+
}),
|
|
170
|
+
) as Voice
|
|
171
|
+
|
|
172
|
+
const speedRaw = await promptText({
|
|
173
|
+
message: 'Default speech speed',
|
|
174
|
+
initialValue: String(current.tts?.speed ?? 1.5),
|
|
175
|
+
validate: (value) => {
|
|
176
|
+
const num = Number(value)
|
|
177
|
+
return Number.isFinite(num) && num > 0 ? undefined : 'Enter a positive number.'
|
|
178
|
+
},
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const nextConfig = mergeSetupConfig(current, {
|
|
182
|
+
provider,
|
|
183
|
+
model,
|
|
184
|
+
skipIntro,
|
|
185
|
+
tts: {
|
|
186
|
+
enabled: ttsEnabled,
|
|
187
|
+
streaming: streamingEnabled,
|
|
188
|
+
mode: ttsMode,
|
|
189
|
+
serverUrl: serverUrl ?? current.tts?.serverUrl,
|
|
190
|
+
voice,
|
|
191
|
+
speed: Number(speedRaw),
|
|
192
|
+
},
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
if (ttsMode !== 'serve' && nextConfig.tts) {
|
|
196
|
+
delete nextConfig.tts.serverUrl
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (existing.exists) {
|
|
200
|
+
const shouldWrite = throwIfCanceled(
|
|
201
|
+
await confirm({
|
|
202
|
+
message: `Overwrite ${configPath}?`,
|
|
203
|
+
initialValue: true,
|
|
204
|
+
}),
|
|
205
|
+
) as boolean
|
|
206
|
+
|
|
207
|
+
if (!shouldWrite) {
|
|
208
|
+
outro('No changes made.')
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
await writeGlobalConfig(nextConfig, configPath)
|
|
214
|
+
outro(`Saved config to ${configPath}`)
|
|
215
|
+
printTtsSetupNextSteps(nextConfig)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function runSetupCommand(
|
|
219
|
+
args: string[],
|
|
220
|
+
options: RunSetupOptions = {},
|
|
221
|
+
): Promise<void> {
|
|
222
|
+
const program = new Command()
|
|
223
|
+
.name('orb setup')
|
|
224
|
+
.description('Create or update ~/.orb/config.toml')
|
|
225
|
+
.exitOverride()
|
|
226
|
+
.allowExcessArguments(false)
|
|
227
|
+
.configureOutput({
|
|
228
|
+
writeOut: (str) => process.stdout.write(str),
|
|
229
|
+
writeErr: (str) => process.stderr.write(str),
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
program.parse(args, { from: 'user' })
|
|
233
|
+
await runSetup(options)
|
|
234
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export type AppState = 'idle' | 'processing' | 'processing_speaking' | 'speaking'
|
|
2
|
+
|
|
3
|
+
export type DetailMode = 'compact' | 'expanded'
|
|
4
|
+
|
|
5
|
+
export type TTSErrorType = 'command_not_found' | 'audio_playback' | 'generation_failed'
|
|
6
|
+
|
|
7
|
+
export class TTSError extends Error {
|
|
8
|
+
constructor(
|
|
9
|
+
message: string,
|
|
10
|
+
public readonly type: TTSErrorType,
|
|
11
|
+
public readonly originalError?: Error,
|
|
12
|
+
) {
|
|
13
|
+
super(message)
|
|
14
|
+
this.name = 'TTSError'
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ToolCall {
|
|
19
|
+
id: string
|
|
20
|
+
index: number
|
|
21
|
+
name: string
|
|
22
|
+
input: Record<string, unknown>
|
|
23
|
+
status: 'running' | 'complete' | 'error'
|
|
24
|
+
result?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface HistoryEntry {
|
|
28
|
+
id: string
|
|
29
|
+
question: string
|
|
30
|
+
toolCalls: ToolCall[]
|
|
31
|
+
answer: string
|
|
32
|
+
error?: string | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type LlmProvider = 'anthropic' | 'openai'
|
|
36
|
+
|
|
37
|
+
export const ANTHROPIC_MODELS = [
|
|
38
|
+
'claude-haiku-4-5-20251001',
|
|
39
|
+
'claude-sonnet-4-6',
|
|
40
|
+
'claude-opus-4-6',
|
|
41
|
+
'claude-opus-4-5-20251101',
|
|
42
|
+
'claude-sonnet-4-5-20250929',
|
|
43
|
+
'claude-opus-4-1-20250805',
|
|
44
|
+
'claude-opus-4-20250514',
|
|
45
|
+
'claude-sonnet-4-20250514',
|
|
46
|
+
'claude-3-haiku-20240307',
|
|
47
|
+
] as const
|
|
48
|
+
|
|
49
|
+
export const VOICES = ['alba', 'marius', 'jean'] as const
|
|
50
|
+
|
|
51
|
+
export type AnthropicModel = (typeof ANTHROPIC_MODELS)[number]
|
|
52
|
+
export type LlmModelId = string
|
|
53
|
+
export type Voice = (typeof VOICES)[number]
|
|
54
|
+
|
|
55
|
+
export interface OpenAiSession {
|
|
56
|
+
provider: 'openai'
|
|
57
|
+
previousResponseId: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type AgentSession = { provider: 'anthropic'; sessionId: string } | OpenAiSession
|
|
61
|
+
|
|
62
|
+
export interface SavedSession {
|
|
63
|
+
version: 2
|
|
64
|
+
projectPath: string
|
|
65
|
+
llmProvider: LlmProvider
|
|
66
|
+
llmModel: LlmModelId
|
|
67
|
+
agentSession?: AgentSession
|
|
68
|
+
lastModified: string
|
|
69
|
+
history: HistoryEntry[]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface AppConfig {
|
|
73
|
+
projectPath: string
|
|
74
|
+
llmProvider: LlmProvider
|
|
75
|
+
llmModel: LlmModelId
|
|
76
|
+
openaiApiKey?: string
|
|
77
|
+
ttsVoice: Voice
|
|
78
|
+
ttsMode: 'generate' | 'serve'
|
|
79
|
+
ttsServerUrl?: string
|
|
80
|
+
ttsSpeed: number
|
|
81
|
+
ttsEnabled: boolean
|
|
82
|
+
ttsStreamingEnabled: boolean
|
|
83
|
+
ttsBufferSentences: number
|
|
84
|
+
ttsClauseBoundaries: boolean
|
|
85
|
+
ttsMinChunkLength: number
|
|
86
|
+
ttsMaxWaitMs: number
|
|
87
|
+
ttsGraceWindowMs: number
|
|
88
|
+
startFresh: boolean
|
|
89
|
+
skipIntro: boolean
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const DEFAULT_CONFIG: AppConfig = {
|
|
93
|
+
projectPath: process.cwd(),
|
|
94
|
+
llmProvider: 'anthropic',
|
|
95
|
+
llmModel: 'claude-haiku-4-5-20251001',
|
|
96
|
+
ttsVoice: 'alba',
|
|
97
|
+
ttsMode: 'serve',
|
|
98
|
+
ttsSpeed: 1.5,
|
|
99
|
+
ttsEnabled: true,
|
|
100
|
+
ttsStreamingEnabled: true,
|
|
101
|
+
ttsBufferSentences: 1,
|
|
102
|
+
ttsClauseBoundaries: false,
|
|
103
|
+
ttsMinChunkLength: 15,
|
|
104
|
+
ttsMaxWaitMs: 150,
|
|
105
|
+
ttsGraceWindowMs: 50,
|
|
106
|
+
startFresh: false,
|
|
107
|
+
skipIntro: false,
|
|
108
|
+
}
|