@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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +349 -0
  3. package/assets/orb-logo.svg +75 -0
  4. package/assets/orb-terminal-session.svg +72 -0
  5. package/assets/orb-wordmark.svg +77 -0
  6. package/package.json +76 -0
  7. package/prompts/anthropic.md +2 -0
  8. package/prompts/base.md +1 -0
  9. package/prompts/openai.md +7 -0
  10. package/prompts/voice.md +12 -0
  11. package/src/cli.ts +9 -0
  12. package/src/config.ts +270 -0
  13. package/src/index.ts +82 -0
  14. package/src/pipeline/adapters/anthropic.ts +111 -0
  15. package/src/pipeline/adapters/openai.ts +202 -0
  16. package/src/pipeline/adapters/types.ts +16 -0
  17. package/src/pipeline/adapters/utils.ts +131 -0
  18. package/src/pipeline/frames.ts +113 -0
  19. package/src/pipeline/observer.ts +36 -0
  20. package/src/pipeline/observers/metrics.ts +95 -0
  21. package/src/pipeline/pipeline.ts +43 -0
  22. package/src/pipeline/processor.ts +57 -0
  23. package/src/pipeline/processors/agent.ts +38 -0
  24. package/src/pipeline/processors/tts.ts +120 -0
  25. package/src/pipeline/task.ts +239 -0
  26. package/src/pipeline/transports/terminal-text.ts +24 -0
  27. package/src/pipeline/transports/types.ts +33 -0
  28. package/src/services/auth-utils.ts +149 -0
  29. package/src/services/global-config.ts +363 -0
  30. package/src/services/openai-auth.ts +18 -0
  31. package/src/services/prompts.ts +76 -0
  32. package/src/services/provider-defaults.ts +97 -0
  33. package/src/services/session.ts +204 -0
  34. package/src/services/streaming-tts.ts +483 -0
  35. package/src/services/tts.ts +309 -0
  36. package/src/setup.ts +234 -0
  37. package/src/types/index.ts +108 -0
  38. package/src/ui/App.tsx +142 -0
  39. package/src/ui/components/ActivityTimeline.tsx +60 -0
  40. package/src/ui/components/AsciiOrb.tsx +92 -0
  41. package/src/ui/components/ConversationRail.tsx +44 -0
  42. package/src/ui/components/Footer.tsx +61 -0
  43. package/src/ui/components/InputPrompt.tsx +88 -0
  44. package/src/ui/components/MicroOrb.tsx +25 -0
  45. package/src/ui/components/TTSErrorBanner.tsx +36 -0
  46. package/src/ui/components/TurnRow.tsx +71 -0
  47. package/src/ui/components/WelcomeSplash.tsx +78 -0
  48. package/src/ui/hooks/useAnimationFrame.ts +33 -0
  49. package/src/ui/hooks/useConversation.ts +195 -0
  50. package/src/ui/hooks/useKeyboardShortcuts.ts +57 -0
  51. package/src/ui/hooks/usePipeline.ts +83 -0
  52. package/src/ui/hooks/useTerminalSize.ts +37 -0
  53. package/src/ui/utils/markdown.ts +89 -0
  54. package/src/ui/utils/model-label.ts +20 -0
  55. package/src/ui/utils/text.ts +18 -0
  56. 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
+ }