@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,363 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { parse, stringify } from '@iarna/toml'
|
|
5
|
+
import { DEFAULT_MODEL_BY_PROVIDER, type ExplicitFlags } from '../config'
|
|
6
|
+
import { VOICES, type AppConfig, type LlmModelId, type LlmProvider, type Voice } from '../types'
|
|
7
|
+
|
|
8
|
+
const CONFIG_DIR = '.orb'
|
|
9
|
+
const CONFIG_FILE = 'config.toml'
|
|
10
|
+
|
|
11
|
+
export interface OrbGlobalTtsConfig {
|
|
12
|
+
enabled?: boolean
|
|
13
|
+
streaming?: boolean
|
|
14
|
+
mode?: AppConfig['ttsMode']
|
|
15
|
+
serverUrl?: string
|
|
16
|
+
voice?: Voice
|
|
17
|
+
speed?: number
|
|
18
|
+
bufferSentences?: number
|
|
19
|
+
clauseBoundaries?: boolean
|
|
20
|
+
minChunkLength?: number
|
|
21
|
+
maxWaitMs?: number
|
|
22
|
+
graceWindowMs?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface OrbGlobalConfig {
|
|
26
|
+
provider?: LlmProvider
|
|
27
|
+
model?: LlmModelId
|
|
28
|
+
skipIntro?: boolean
|
|
29
|
+
tts?: OrbGlobalTtsConfig
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface LoadGlobalConfigResult {
|
|
33
|
+
config: OrbGlobalConfig
|
|
34
|
+
explicit: Partial<ExplicitFlags>
|
|
35
|
+
warnings: string[]
|
|
36
|
+
path: string
|
|
37
|
+
exists: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type RawObject = Record<string, unknown>
|
|
41
|
+
|
|
42
|
+
function asObject(value: unknown): RawObject | null {
|
|
43
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? (value as RawObject) : null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validateString(value: unknown, label: string, warnings: string[]): string | undefined {
|
|
47
|
+
if (typeof value !== 'string') {
|
|
48
|
+
warnings.push(`${label} must be a string.`)
|
|
49
|
+
return undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const trimmed = value.trim()
|
|
53
|
+
if (trimmed.length === 0) {
|
|
54
|
+
warnings.push(`${label} must not be empty.`)
|
|
55
|
+
return undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return trimmed
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function validateBoolean(value: unknown, label: string, warnings: string[]): boolean | undefined {
|
|
62
|
+
if (typeof value !== 'boolean') {
|
|
63
|
+
warnings.push(`${label} must be true or false.`)
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return value
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function validatePositiveNumber(
|
|
71
|
+
value: unknown,
|
|
72
|
+
label: string,
|
|
73
|
+
warnings: string[],
|
|
74
|
+
): number | undefined {
|
|
75
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
76
|
+
warnings.push(`${label} must be a positive number.`)
|
|
77
|
+
return undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return value
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function validatePositiveInt(
|
|
84
|
+
value: unknown,
|
|
85
|
+
label: string,
|
|
86
|
+
warnings: string[],
|
|
87
|
+
): number | undefined {
|
|
88
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
|
|
89
|
+
warnings.push(`${label} must be a positive integer.`)
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return value
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function validateNonNegativeInt(
|
|
97
|
+
value: unknown,
|
|
98
|
+
label: string,
|
|
99
|
+
warnings: string[],
|
|
100
|
+
): number | undefined {
|
|
101
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
|
102
|
+
warnings.push(`${label} must be a non-negative integer.`)
|
|
103
|
+
return undefined
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return value
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function validateProvider(
|
|
110
|
+
value: unknown,
|
|
111
|
+
label: string,
|
|
112
|
+
warnings: string[],
|
|
113
|
+
): LlmProvider | undefined {
|
|
114
|
+
if (value === 'anthropic' || value === 'openai') return value
|
|
115
|
+
warnings.push(`${label} must be "anthropic" or "openai".`)
|
|
116
|
+
return undefined
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function validateTtsMode(
|
|
120
|
+
value: unknown,
|
|
121
|
+
label: string,
|
|
122
|
+
warnings: string[],
|
|
123
|
+
): AppConfig['ttsMode'] | undefined {
|
|
124
|
+
if (value === 'generate' || value === 'serve') return value
|
|
125
|
+
warnings.push(`${label} must be "generate" or "serve".`)
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function validateVoice(value: unknown, label: string, warnings: string[]): Voice | undefined {
|
|
130
|
+
if (typeof value !== 'string' || !VOICES.includes(value as Voice)) {
|
|
131
|
+
warnings.push(`${label} must be one of: ${VOICES.join(', ')}.`)
|
|
132
|
+
return undefined
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return value as Voice
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function getGlobalConfigPath(homeDir = os.homedir()): string {
|
|
139
|
+
return path.join(homeDir, CONFIG_DIR, CONFIG_FILE)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function parseGlobalConfigToml(
|
|
143
|
+
contents: string,
|
|
144
|
+
configPath = getGlobalConfigPath(),
|
|
145
|
+
): Omit<LoadGlobalConfigResult, 'path' | 'exists'> {
|
|
146
|
+
const warnings: string[] = []
|
|
147
|
+
const config: OrbGlobalConfig = {}
|
|
148
|
+
const explicit: Partial<ExplicitFlags> = {}
|
|
149
|
+
|
|
150
|
+
let parsed: unknown
|
|
151
|
+
try {
|
|
152
|
+
parsed = parse(contents)
|
|
153
|
+
} catch (error) {
|
|
154
|
+
warnings.push(
|
|
155
|
+
`Failed to parse Orb config "${configPath}": ${error instanceof Error ? error.message : String(error)}`,
|
|
156
|
+
)
|
|
157
|
+
return { config, explicit, warnings }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const root = asObject(parsed)
|
|
161
|
+
if (!root) {
|
|
162
|
+
warnings.push(`Orb config "${configPath}" must contain a TOML table.`)
|
|
163
|
+
return { config, explicit, warnings }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if ('provider' in root) {
|
|
167
|
+
const provider = validateProvider(root.provider, 'provider', warnings)
|
|
168
|
+
if (provider) {
|
|
169
|
+
config.provider = provider
|
|
170
|
+
explicit.provider = true
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if ('model' in root) {
|
|
175
|
+
const model = validateString(root.model, 'model', warnings)
|
|
176
|
+
if (model) {
|
|
177
|
+
config.model = model
|
|
178
|
+
explicit.model = true
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if ('skip_intro' in root) {
|
|
183
|
+
const skipIntro = validateBoolean(root.skip_intro, 'skip_intro', warnings)
|
|
184
|
+
if (skipIntro !== undefined) {
|
|
185
|
+
config.skipIntro = skipIntro
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if ('tts' in root) {
|
|
190
|
+
const rawTts = asObject(root.tts)
|
|
191
|
+
if (!rawTts) {
|
|
192
|
+
warnings.push('tts must be a table.')
|
|
193
|
+
} else {
|
|
194
|
+
const tts: OrbGlobalTtsConfig = {}
|
|
195
|
+
|
|
196
|
+
if ('enabled' in rawTts) {
|
|
197
|
+
const value = validateBoolean(rawTts.enabled, 'tts.enabled', warnings)
|
|
198
|
+
if (value !== undefined) tts.enabled = value
|
|
199
|
+
}
|
|
200
|
+
if ('streaming' in rawTts) {
|
|
201
|
+
const value = validateBoolean(rawTts.streaming, 'tts.streaming', warnings)
|
|
202
|
+
if (value !== undefined) tts.streaming = value
|
|
203
|
+
}
|
|
204
|
+
if ('mode' in rawTts) {
|
|
205
|
+
const value = validateTtsMode(rawTts.mode, 'tts.mode', warnings)
|
|
206
|
+
if (value) tts.mode = value
|
|
207
|
+
}
|
|
208
|
+
if ('server_url' in rawTts) {
|
|
209
|
+
const value = validateString(rawTts.server_url, 'tts.server_url', warnings)
|
|
210
|
+
if (value) tts.serverUrl = value
|
|
211
|
+
}
|
|
212
|
+
if ('voice' in rawTts) {
|
|
213
|
+
const value = validateVoice(rawTts.voice, 'tts.voice', warnings)
|
|
214
|
+
if (value) tts.voice = value
|
|
215
|
+
}
|
|
216
|
+
if ('speed' in rawTts) {
|
|
217
|
+
const value = validatePositiveNumber(rawTts.speed, 'tts.speed', warnings)
|
|
218
|
+
if (value !== undefined) tts.speed = value
|
|
219
|
+
}
|
|
220
|
+
if ('buffer_sentences' in rawTts) {
|
|
221
|
+
const value = validatePositiveInt(rawTts.buffer_sentences, 'tts.buffer_sentences', warnings)
|
|
222
|
+
if (value !== undefined) {
|
|
223
|
+
tts.bufferSentences = value
|
|
224
|
+
explicit.ttsBufferSentences = true
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if ('clause_boundaries' in rawTts) {
|
|
228
|
+
const value = validateBoolean(rawTts.clause_boundaries, 'tts.clause_boundaries', warnings)
|
|
229
|
+
if (value !== undefined) {
|
|
230
|
+
tts.clauseBoundaries = value
|
|
231
|
+
explicit.ttsClauseBoundaries = true
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if ('min_chunk_length' in rawTts) {
|
|
235
|
+
const value = validateNonNegativeInt(
|
|
236
|
+
rawTts.min_chunk_length,
|
|
237
|
+
'tts.min_chunk_length',
|
|
238
|
+
warnings,
|
|
239
|
+
)
|
|
240
|
+
if (value !== undefined) {
|
|
241
|
+
tts.minChunkLength = value
|
|
242
|
+
explicit.ttsMinChunkLength = true
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if ('max_wait_ms' in rawTts) {
|
|
246
|
+
const value = validateNonNegativeInt(rawTts.max_wait_ms, 'tts.max_wait_ms', warnings)
|
|
247
|
+
if (value !== undefined) {
|
|
248
|
+
tts.maxWaitMs = value
|
|
249
|
+
explicit.ttsMaxWaitMs = true
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if ('grace_window_ms' in rawTts) {
|
|
253
|
+
const value = validateNonNegativeInt(
|
|
254
|
+
rawTts.grace_window_ms,
|
|
255
|
+
'tts.grace_window_ms',
|
|
256
|
+
warnings,
|
|
257
|
+
)
|
|
258
|
+
if (value !== undefined) {
|
|
259
|
+
tts.graceWindowMs = value
|
|
260
|
+
explicit.ttsGraceWindowMs = true
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (Object.keys(tts).length > 0) {
|
|
265
|
+
config.tts = tts
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return { config, explicit, warnings }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function loadGlobalConfig(
|
|
274
|
+
configPath = getGlobalConfigPath(),
|
|
275
|
+
): Promise<LoadGlobalConfigResult> {
|
|
276
|
+
try {
|
|
277
|
+
const contents = await fs.readFile(configPath, 'utf8')
|
|
278
|
+
const parsed = parseGlobalConfigToml(contents, configPath)
|
|
279
|
+
return { ...parsed, path: configPath, exists: true }
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
282
|
+
return { config: {}, explicit: {}, warnings: [], path: configPath, exists: false }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
config: {},
|
|
287
|
+
explicit: {},
|
|
288
|
+
warnings: [
|
|
289
|
+
`Failed to read Orb config "${configPath}": ${error instanceof Error ? error.message : String(error)}`,
|
|
290
|
+
],
|
|
291
|
+
path: configPath,
|
|
292
|
+
exists: false,
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function applyGlobalConfig(baseConfig: AppConfig, globalConfig: OrbGlobalConfig): AppConfig {
|
|
298
|
+
const nextConfig: AppConfig = { ...baseConfig }
|
|
299
|
+
|
|
300
|
+
if (globalConfig.provider) {
|
|
301
|
+
nextConfig.llmProvider = globalConfig.provider
|
|
302
|
+
if (!globalConfig.model) {
|
|
303
|
+
nextConfig.llmModel = DEFAULT_MODEL_BY_PROVIDER[globalConfig.provider]
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (globalConfig.model) nextConfig.llmModel = globalConfig.model
|
|
308
|
+
if (globalConfig.skipIntro !== undefined) nextConfig.skipIntro = globalConfig.skipIntro
|
|
309
|
+
|
|
310
|
+
if (globalConfig.tts) {
|
|
311
|
+
const tts = globalConfig.tts
|
|
312
|
+
if (tts.enabled !== undefined) nextConfig.ttsEnabled = tts.enabled
|
|
313
|
+
if (tts.streaming !== undefined) nextConfig.ttsStreamingEnabled = tts.streaming
|
|
314
|
+
if (tts.mode) nextConfig.ttsMode = tts.mode
|
|
315
|
+
if (tts.serverUrl) nextConfig.ttsServerUrl = tts.serverUrl
|
|
316
|
+
if (tts.voice) nextConfig.ttsVoice = tts.voice
|
|
317
|
+
if (tts.speed !== undefined) nextConfig.ttsSpeed = tts.speed
|
|
318
|
+
if (tts.bufferSentences !== undefined) nextConfig.ttsBufferSentences = tts.bufferSentences
|
|
319
|
+
if (tts.clauseBoundaries !== undefined) nextConfig.ttsClauseBoundaries = tts.clauseBoundaries
|
|
320
|
+
if (tts.minChunkLength !== undefined) nextConfig.ttsMinChunkLength = tts.minChunkLength
|
|
321
|
+
if (tts.maxWaitMs !== undefined) nextConfig.ttsMaxWaitMs = tts.maxWaitMs
|
|
322
|
+
if (tts.graceWindowMs !== undefined) nextConfig.ttsGraceWindowMs = tts.graceWindowMs
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return nextConfig
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function serializeGlobalConfig(config: OrbGlobalConfig): string {
|
|
329
|
+
const document: RawObject = {}
|
|
330
|
+
|
|
331
|
+
if (config.provider) document.provider = config.provider
|
|
332
|
+
if (config.model) document.model = config.model
|
|
333
|
+
if (config.skipIntro !== undefined) document.skip_intro = config.skipIntro
|
|
334
|
+
|
|
335
|
+
if (config.tts) {
|
|
336
|
+
const tts: RawObject = {}
|
|
337
|
+
if (config.tts.enabled !== undefined) tts.enabled = config.tts.enabled
|
|
338
|
+
if (config.tts.streaming !== undefined) tts.streaming = config.tts.streaming
|
|
339
|
+
if (config.tts.mode) tts.mode = config.tts.mode
|
|
340
|
+
if (config.tts.serverUrl) tts.server_url = config.tts.serverUrl
|
|
341
|
+
if (config.tts.voice) tts.voice = config.tts.voice
|
|
342
|
+
if (config.tts.speed !== undefined) tts.speed = config.tts.speed
|
|
343
|
+
if (config.tts.bufferSentences !== undefined) tts.buffer_sentences = config.tts.bufferSentences
|
|
344
|
+
if (config.tts.clauseBoundaries !== undefined)
|
|
345
|
+
tts.clause_boundaries = config.tts.clauseBoundaries
|
|
346
|
+
if (config.tts.minChunkLength !== undefined) tts.min_chunk_length = config.tts.minChunkLength
|
|
347
|
+
if (config.tts.maxWaitMs !== undefined) tts.max_wait_ms = config.tts.maxWaitMs
|
|
348
|
+
if (config.tts.graceWindowMs !== undefined) tts.grace_window_ms = config.tts.graceWindowMs
|
|
349
|
+
if (Object.keys(tts).length > 0) document.tts = tts
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// @iarna/toml expects JsonMap; our RawObject is structurally compatible at runtime.
|
|
353
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
354
|
+
return stringify(document as any)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export async function writeGlobalConfig(
|
|
358
|
+
config: OrbGlobalConfig,
|
|
359
|
+
configPath = getGlobalConfigPath(),
|
|
360
|
+
): Promise<void> {
|
|
361
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true })
|
|
362
|
+
await Bun.write(configPath, serializeGlobalConfig(config))
|
|
363
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createOpenAI, type OpenAIProvider } from '@ai-sdk/openai'
|
|
2
|
+
import type { AppConfig } from '../types'
|
|
3
|
+
export type AuthSource = 'api-key'
|
|
4
|
+
|
|
5
|
+
export function getOpenAiApiKey(config: AppConfig): string | null {
|
|
6
|
+
return config.openaiApiKey || Bun.env.OPENAI_API_KEY || null
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function resolveOpenAiProvider(
|
|
10
|
+
config: AppConfig,
|
|
11
|
+
): Promise<{ provider: OpenAIProvider; source: AuthSource }> {
|
|
12
|
+
const apiKey = getOpenAiApiKey(config)
|
|
13
|
+
if (apiKey) {
|
|
14
|
+
return { provider: createOpenAI({ apiKey }), source: 'api-key' }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
throw new Error('OpenAI requires OPENAI_API_KEY for Orb.')
|
|
18
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { basename, join } from 'node:path'
|
|
3
|
+
import type { LlmProvider } from '../types'
|
|
4
|
+
|
|
5
|
+
const PROMPTS_DIR = join(import.meta.dir, '..', '..', 'prompts')
|
|
6
|
+
|
|
7
|
+
const PROVIDER_PROMPT_FILES: Record<LlmProvider, string> = {
|
|
8
|
+
anthropic: 'anthropic.md',
|
|
9
|
+
openai: 'openai.md',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const VOICE_PROMPT_FILE = 'voice.md'
|
|
13
|
+
|
|
14
|
+
interface PromptTemplateValues {
|
|
15
|
+
projectName: string
|
|
16
|
+
projectPath: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PromptBuildOptions {
|
|
20
|
+
provider: LlmProvider
|
|
21
|
+
projectPath: string
|
|
22
|
+
ttsEnabled: boolean
|
|
23
|
+
promptsDir?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getPromptTemplateValues(projectPath: string): PromptTemplateValues {
|
|
27
|
+
return {
|
|
28
|
+
projectName: basename(projectPath) || projectPath,
|
|
29
|
+
projectPath,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function interpolatePrompt(template: string, values: PromptTemplateValues): string {
|
|
34
|
+
return template
|
|
35
|
+
.replaceAll('{{projectName}}', values.projectName)
|
|
36
|
+
.replaceAll('{{projectPath}}', values.projectPath)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function readPromptFile(
|
|
40
|
+
promptsDir: string,
|
|
41
|
+
fileName: string,
|
|
42
|
+
values: PromptTemplateValues,
|
|
43
|
+
): Promise<string> {
|
|
44
|
+
const filePath = join(promptsDir, fileName)
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const contents = await readFile(filePath, 'utf8')
|
|
48
|
+
return interpolatePrompt(contents.trim(), values)
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
51
|
+
throw new Error(`Missing prompt file: ${filePath}`)
|
|
52
|
+
}
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Failed to read prompt file "${filePath}": ${error instanceof Error ? error.message : String(error)}`,
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function buildProviderPrompt({
|
|
60
|
+
provider,
|
|
61
|
+
projectPath,
|
|
62
|
+
ttsEnabled,
|
|
63
|
+
promptsDir = PROMPTS_DIR,
|
|
64
|
+
}: PromptBuildOptions): Promise<string> {
|
|
65
|
+
const values = getPromptTemplateValues(projectPath)
|
|
66
|
+
const fileNames = [
|
|
67
|
+
'base.md',
|
|
68
|
+
PROVIDER_PROMPT_FILES[provider],
|
|
69
|
+
...(ttsEnabled ? [VOICE_PROMPT_FILE] : []),
|
|
70
|
+
]
|
|
71
|
+
const sections = await Promise.all(
|
|
72
|
+
fileNames.map((fileName) => readPromptFile(promptsDir, fileName, values)),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return sections.filter(Boolean).join('\n\n').trim()
|
|
76
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { query } from '@anthropic-ai/claude-agent-sdk'
|
|
2
|
+
import { DEFAULT_MODEL_BY_PROVIDER } from '../config'
|
|
3
|
+
import type { AppConfig, LlmProvider } from '../types'
|
|
4
|
+
import { getOpenAiApiKey } from './openai-auth'
|
|
5
|
+
|
|
6
|
+
type SmartProviderSource = 'claude-oauth' | 'openai-api-key' | 'anthropic-api-key'
|
|
7
|
+
|
|
8
|
+
type ClaudeAuthState = {
|
|
9
|
+
hasOAuth: boolean
|
|
10
|
+
hasApiKey: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const CLAUDE_AUTH_TIMEOUT_MS = 10_000
|
|
14
|
+
|
|
15
|
+
function getAnthropicApiKey(): string | null {
|
|
16
|
+
return Bun.env.ANTHROPIC_API_KEY || Bun.env.CLAUDE_API_KEY || null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Empty async iterable that immediately completes
|
|
20
|
+
const EMPTY_PROMPT: AsyncIterable<never> = {
|
|
21
|
+
[Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined as never }) }),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
|
25
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
|
26
|
+
const timeout = new Promise<T>((_, reject) => {
|
|
27
|
+
timeoutId = setTimeout(() => {
|
|
28
|
+
reject(new Error('timeout'))
|
|
29
|
+
}, timeoutMs)
|
|
30
|
+
})
|
|
31
|
+
try {
|
|
32
|
+
return await Promise.race([promise, timeout])
|
|
33
|
+
} finally {
|
|
34
|
+
if (timeoutId) clearTimeout(timeoutId)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function detectClaudeAuth(config: AppConfig): Promise<ClaudeAuthState> {
|
|
39
|
+
if (!Bun.which('claude')) {
|
|
40
|
+
return { hasOAuth: false, hasApiKey: false }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const abortController = new AbortController()
|
|
44
|
+
const prompt = EMPTY_PROMPT
|
|
45
|
+
const queryInstance = query({
|
|
46
|
+
prompt,
|
|
47
|
+
options: {
|
|
48
|
+
cwd: config.projectPath,
|
|
49
|
+
model: DEFAULT_MODEL_BY_PROVIDER.anthropic,
|
|
50
|
+
maxTurns: 1,
|
|
51
|
+
persistSession: false,
|
|
52
|
+
permissionMode: 'default',
|
|
53
|
+
abortController,
|
|
54
|
+
stderr: () => {},
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const info = await withTimeout(queryInstance.accountInfo(), CLAUDE_AUTH_TIMEOUT_MS)
|
|
60
|
+
return {
|
|
61
|
+
hasOAuth: Boolean(info?.tokenSource || info?.subscriptionType),
|
|
62
|
+
hasApiKey: Boolean(info?.apiKeySource),
|
|
63
|
+
}
|
|
64
|
+
} catch (error) {
|
|
65
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
66
|
+
console.warn(`[orb] Claude credential detection failed: ${reason}`)
|
|
67
|
+
return { hasOAuth: false, hasApiKey: false }
|
|
68
|
+
} finally {
|
|
69
|
+
abortController.abort()
|
|
70
|
+
await queryInstance.interrupt().catch(() => {})
|
|
71
|
+
if (typeof queryInstance.return === 'function') {
|
|
72
|
+
await queryInstance.return()
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function resolveSmartProvider(
|
|
78
|
+
config: AppConfig,
|
|
79
|
+
): Promise<{ provider: LlmProvider; source: SmartProviderSource } | null> {
|
|
80
|
+
const claudeAuth = await detectClaudeAuth(config)
|
|
81
|
+
|
|
82
|
+
if (claudeAuth.hasOAuth) {
|
|
83
|
+
return { provider: 'anthropic', source: 'claude-oauth' }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const openAiApiKey = getOpenAiApiKey(config)
|
|
87
|
+
if (openAiApiKey) {
|
|
88
|
+
return { provider: 'openai', source: 'openai-api-key' }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const anthropicApiKey = getAnthropicApiKey()
|
|
92
|
+
if (claudeAuth.hasApiKey || anthropicApiKey) {
|
|
93
|
+
return { provider: 'anthropic', source: 'anthropic-api-key' }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return null
|
|
97
|
+
}
|