@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,33 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
interface UseAnimationFrameOptions {
|
|
4
|
+
fps?: number
|
|
5
|
+
active?: boolean
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Custom hook for frame-based animation with configurable FPS.
|
|
10
|
+
* Returns an incrementing frame counter that triggers re-renders at the specified rate.
|
|
11
|
+
* Uses setInterval for Node.js compatibility (no requestAnimationFrame in terminal).
|
|
12
|
+
*/
|
|
13
|
+
export function useAnimationFrame({
|
|
14
|
+
fps = 30,
|
|
15
|
+
active = true,
|
|
16
|
+
}: UseAnimationFrameOptions = {}): number {
|
|
17
|
+
const [frame, setFrame] = useState(0)
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!active) return
|
|
21
|
+
|
|
22
|
+
const intervalMs = Math.floor(1000 / fps)
|
|
23
|
+
const intervalId = setInterval(() => {
|
|
24
|
+
setFrame((f) => f + 1)
|
|
25
|
+
}, intervalMs)
|
|
26
|
+
|
|
27
|
+
return () => {
|
|
28
|
+
clearInterval(intervalId)
|
|
29
|
+
}
|
|
30
|
+
}, [fps, active])
|
|
31
|
+
|
|
32
|
+
return frame
|
|
33
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { OutboundFrame } from '../../pipeline/transports/types'
|
|
4
|
+
import type { RunResult } from '../../pipeline/task'
|
|
5
|
+
import { saveSession } from '../../services/session'
|
|
6
|
+
import {
|
|
7
|
+
ANTHROPIC_MODELS,
|
|
8
|
+
type AgentSession,
|
|
9
|
+
type AnthropicModel,
|
|
10
|
+
type AppConfig,
|
|
11
|
+
type AppState,
|
|
12
|
+
type HistoryEntry,
|
|
13
|
+
type LlmModelId,
|
|
14
|
+
type SavedSession,
|
|
15
|
+
type TTSErrorType,
|
|
16
|
+
} from '../../types'
|
|
17
|
+
|
|
18
|
+
interface UseConversationConfig {
|
|
19
|
+
config: AppConfig
|
|
20
|
+
initialSession?: SavedSession | null
|
|
21
|
+
taskState: AppState
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useConversation({ config, initialSession, taskState }: UseConversationConfig) {
|
|
25
|
+
const sessionMatchesProvider = initialSession?.llmProvider === config.llmProvider
|
|
26
|
+
const initialHistory = initialSession?.history ?? []
|
|
27
|
+
const initialModel =
|
|
28
|
+
(sessionMatchesProvider ? initialSession?.llmModel : undefined) ?? config.llmModel
|
|
29
|
+
const initialAgentSession = sessionMatchesProvider ? initialSession?.agentSession : undefined
|
|
30
|
+
|
|
31
|
+
const [completedTurns, setCompletedTurns] = useState<HistoryEntry[]>(initialHistory)
|
|
32
|
+
const [liveTurn, setLiveTurn] = useState<HistoryEntry | null>(null)
|
|
33
|
+
const [ttsError, setTtsError] = useState<{ type: TTSErrorType; message: string } | null>(null)
|
|
34
|
+
const [activeModel, setActiveModel] = useState<LlmModelId>(initialModel)
|
|
35
|
+
|
|
36
|
+
const liveTurnRef = useRef<HistoryEntry | null>(null)
|
|
37
|
+
const activeEntryIdRef = useRef<string | null>(null)
|
|
38
|
+
const agentSessionRef = useRef<AgentSession | undefined>(initialAgentSession)
|
|
39
|
+
const pendingSaveRef = useRef(false)
|
|
40
|
+
|
|
41
|
+
/** Update both ref (synchronous, for archive guards) and state (async, for render). */
|
|
42
|
+
const updateLiveTurn = useCallback((turn: HistoryEntry | null) => {
|
|
43
|
+
liveTurnRef.current = turn
|
|
44
|
+
setLiveTurn(turn)
|
|
45
|
+
}, [])
|
|
46
|
+
|
|
47
|
+
const getHistorySnapshot = useCallback(
|
|
48
|
+
() => [...completedTurns, ...(liveTurnRef.current ? [liveTurnRef.current] : [])],
|
|
49
|
+
[completedTurns],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
const persistSession = useCallback(
|
|
53
|
+
async (modelOverride?: LlmModelId, historyOverride?: HistoryEntry[]) => {
|
|
54
|
+
const history = historyOverride ?? getHistorySnapshot()
|
|
55
|
+
const payload: SavedSession = {
|
|
56
|
+
version: 2,
|
|
57
|
+
projectPath: config.projectPath,
|
|
58
|
+
llmProvider: config.llmProvider,
|
|
59
|
+
llmModel: modelOverride ?? activeModel,
|
|
60
|
+
agentSession: agentSessionRef.current,
|
|
61
|
+
lastModified: new Date().toISOString(),
|
|
62
|
+
history,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
await saveSession(payload)
|
|
67
|
+
} catch (err) {
|
|
68
|
+
console.warn('Failed to save session:', err)
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
[activeModel, config.llmProvider, config.projectPath, getHistorySnapshot],
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!pendingSaveRef.current) return
|
|
76
|
+
if (taskState !== 'idle') return
|
|
77
|
+
pendingSaveRef.current = false
|
|
78
|
+
void persistSession()
|
|
79
|
+
}, [completedTurns, persistSession, taskState])
|
|
80
|
+
|
|
81
|
+
const startEntry = useCallback((query: string) => {
|
|
82
|
+
const trimmed = query.trim()
|
|
83
|
+
if (!trimmed) return null
|
|
84
|
+
|
|
85
|
+
// Archive any existing live turn (safety net for edge cases)
|
|
86
|
+
const existingLiveTurn = liveTurnRef.current
|
|
87
|
+
if (existingLiveTurn !== null) {
|
|
88
|
+
liveTurnRef.current = null
|
|
89
|
+
setCompletedTurns((prev) => [...prev, existingLiveTurn])
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const entryId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
93
|
+
const newTurn: HistoryEntry = {
|
|
94
|
+
id: entryId,
|
|
95
|
+
question: trimmed,
|
|
96
|
+
toolCalls: [],
|
|
97
|
+
answer: '',
|
|
98
|
+
error: null,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
activeEntryIdRef.current = entryId
|
|
102
|
+
updateLiveTurn(newTurn)
|
|
103
|
+
setTtsError(null)
|
|
104
|
+
|
|
105
|
+
return { entryId, query: trimmed }
|
|
106
|
+
}, [])
|
|
107
|
+
|
|
108
|
+
const handleFrame = useCallback(
|
|
109
|
+
(frame: OutboundFrame) => {
|
|
110
|
+
if (!liveTurnRef.current) return
|
|
111
|
+
const cur = liveTurnRef.current
|
|
112
|
+
|
|
113
|
+
switch (frame.kind) {
|
|
114
|
+
case 'agent-text-delta':
|
|
115
|
+
updateLiveTurn({ ...cur, answer: frame.accumulatedText })
|
|
116
|
+
break
|
|
117
|
+
|
|
118
|
+
case 'agent-text-complete':
|
|
119
|
+
updateLiveTurn({ ...cur, answer: frame.text })
|
|
120
|
+
break
|
|
121
|
+
|
|
122
|
+
case 'tool-call-start':
|
|
123
|
+
updateLiveTurn({ ...cur, toolCalls: [...cur.toolCalls, frame.toolCall] })
|
|
124
|
+
break
|
|
125
|
+
|
|
126
|
+
case 'tool-call-result':
|
|
127
|
+
updateLiveTurn({
|
|
128
|
+
...cur,
|
|
129
|
+
toolCalls: cur.toolCalls.map((tc) =>
|
|
130
|
+
tc.index === frame.toolIndex
|
|
131
|
+
? { ...tc, status: frame.status, result: frame.result }
|
|
132
|
+
: tc,
|
|
133
|
+
),
|
|
134
|
+
})
|
|
135
|
+
break
|
|
136
|
+
|
|
137
|
+
case 'agent-error':
|
|
138
|
+
updateLiveTurn({ ...cur, error: frame.error.message })
|
|
139
|
+
break
|
|
140
|
+
|
|
141
|
+
case 'tts-error':
|
|
142
|
+
setTtsError({ type: frame.errorType, message: frame.message })
|
|
143
|
+
break
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
[updateLiveTurn],
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
const handleRunComplete = useCallback(
|
|
150
|
+
(result: RunResult) => {
|
|
151
|
+
// Guard: only archive the turn this run belongs to.
|
|
152
|
+
// If startEntry already replaced it with a new turn, skip.
|
|
153
|
+
if (liveTurnRef.current === null) return
|
|
154
|
+
if (activeEntryIdRef.current !== result.entryId) return
|
|
155
|
+
|
|
156
|
+
if (result.session) {
|
|
157
|
+
agentSessionRef.current = result.session
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const turnToArchive = liveTurnRef.current
|
|
161
|
+
activeEntryIdRef.current = null
|
|
162
|
+
setCompletedTurns((prev) => [...prev, turnToArchive])
|
|
163
|
+
updateLiveTurn(null)
|
|
164
|
+
|
|
165
|
+
if (!result.cancelled) {
|
|
166
|
+
pendingSaveRef.current = true
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
[updateLiveTurn],
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
const cycleModel = useCallback(() => {
|
|
173
|
+
if (config.llmProvider !== 'anthropic') return
|
|
174
|
+
|
|
175
|
+
const currentIndex = ANTHROPIC_MODELS.indexOf(activeModel as AnthropicModel)
|
|
176
|
+
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % ANTHROPIC_MODELS.length
|
|
177
|
+
const nextModel = ANTHROPIC_MODELS[nextIndex] ?? ANTHROPIC_MODELS[0]
|
|
178
|
+
setActiveModel(nextModel)
|
|
179
|
+
|
|
180
|
+
void persistSession(nextModel, getHistorySnapshot())
|
|
181
|
+
}, [activeModel, config.llmProvider, getHistorySnapshot, persistSession])
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
liveTurn,
|
|
185
|
+
activeModel,
|
|
186
|
+
completedTurns,
|
|
187
|
+
handleFrame,
|
|
188
|
+
handleRunComplete,
|
|
189
|
+
initialAgentSession,
|
|
190
|
+
initialModel,
|
|
191
|
+
startEntry,
|
|
192
|
+
ttsError,
|
|
193
|
+
cycleModel,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useInput } from 'ink'
|
|
2
|
+
|
|
3
|
+
import type { AppState } from '../../types'
|
|
4
|
+
|
|
5
|
+
interface UseKeyboardShortcutsConfig {
|
|
6
|
+
canCycleModel: boolean
|
|
7
|
+
onCancel(): void
|
|
8
|
+
onCycleModel(): void
|
|
9
|
+
onToggleDetailMode(): void
|
|
10
|
+
state: AppState
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useKeyboardShortcuts({
|
|
14
|
+
canCycleModel,
|
|
15
|
+
onCancel,
|
|
16
|
+
onCycleModel,
|
|
17
|
+
onToggleDetailMode,
|
|
18
|
+
state,
|
|
19
|
+
}: UseKeyboardShortcutsConfig) {
|
|
20
|
+
useInput(
|
|
21
|
+
(input, key) => {
|
|
22
|
+
if (key.escape || (key.ctrl && input === 's')) {
|
|
23
|
+
onCancel()
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
{ isActive: state !== 'idle' },
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
useInput(
|
|
30
|
+
(input, key) => {
|
|
31
|
+
if (key.ctrl && input === 'o') {
|
|
32
|
+
onToggleDetailMode()
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
{ isActive: true },
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
useInput(
|
|
39
|
+
(input, key) => {
|
|
40
|
+
const isShiftTab =
|
|
41
|
+
(key.shift && key.tab) || input === '\u001b[Z' || (key.shift && input === '\t')
|
|
42
|
+
if (isShiftTab) {
|
|
43
|
+
onCycleModel()
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
{ isActive: canCycleModel },
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
useInput(
|
|
50
|
+
(input, key) => {
|
|
51
|
+
if (key.ctrl && input === 'c') {
|
|
52
|
+
process.exit(0)
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
{ isActive: true },
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { createPipelineTask } from '../../pipeline/task'
|
|
4
|
+
import type { RunResult, TaskState } from '../../pipeline/task'
|
|
5
|
+
import { createTerminalTextTransport } from '../../pipeline/transports/terminal-text'
|
|
6
|
+
import type { OutboundFrame, Transport } from '../../pipeline/transports/types'
|
|
7
|
+
import type { AgentSession, AppConfig } from '../../types'
|
|
8
|
+
|
|
9
|
+
interface PendingRun {
|
|
10
|
+
entryId: string
|
|
11
|
+
query: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface UsePipelineConfig {
|
|
15
|
+
config: AppConfig
|
|
16
|
+
activeModel: string
|
|
17
|
+
initialModel: string
|
|
18
|
+
initialSession?: AgentSession
|
|
19
|
+
onFrame(frame: OutboundFrame): void
|
|
20
|
+
onRunComplete(result: RunResult): void
|
|
21
|
+
onStateChange(state: TaskState): void
|
|
22
|
+
startEntry(query: string): PendingRun | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function usePipeline({
|
|
26
|
+
config,
|
|
27
|
+
activeModel,
|
|
28
|
+
initialModel,
|
|
29
|
+
initialSession,
|
|
30
|
+
onFrame,
|
|
31
|
+
onRunComplete,
|
|
32
|
+
onStateChange,
|
|
33
|
+
startEntry,
|
|
34
|
+
}: UsePipelineConfig) {
|
|
35
|
+
const [state, setState] = useState<TaskState>('idle')
|
|
36
|
+
const activeConfig = useMemo(() => ({ ...config, llmModel: activeModel }), [config, activeModel])
|
|
37
|
+
|
|
38
|
+
const { task, transport } = useMemo(() => {
|
|
39
|
+
const nextTransport = createTerminalTextTransport()
|
|
40
|
+
const nextTask = createPipelineTask({
|
|
41
|
+
appConfig: { ...config, llmModel: initialModel },
|
|
42
|
+
session: initialSession,
|
|
43
|
+
transport: nextTransport,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
task: nextTask,
|
|
48
|
+
transport: nextTransport as Transport,
|
|
49
|
+
}
|
|
50
|
+
}, []) as { task: ReturnType<typeof createPipelineTask>; transport: Transport }
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
task.updateConfig(activeConfig)
|
|
54
|
+
}, [task, activeConfig])
|
|
55
|
+
|
|
56
|
+
useEffect(
|
|
57
|
+
() =>
|
|
58
|
+
task.onStateChange((nextState) => {
|
|
59
|
+
setState(nextState)
|
|
60
|
+
onStateChange(nextState)
|
|
61
|
+
}),
|
|
62
|
+
[onStateChange, task],
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
useEffect(() => transport.onOutbound(onFrame), [onFrame, transport])
|
|
66
|
+
|
|
67
|
+
const cancel = useCallback(() => {
|
|
68
|
+
task.cancel()
|
|
69
|
+
}, [task])
|
|
70
|
+
|
|
71
|
+
const submit = useCallback(
|
|
72
|
+
async (query: string) => {
|
|
73
|
+
const pendingRun = startEntry(query)
|
|
74
|
+
if (!pendingRun) return
|
|
75
|
+
|
|
76
|
+
const result = await task.run(pendingRun.query, pendingRun.entryId)
|
|
77
|
+
onRunComplete(result)
|
|
78
|
+
},
|
|
79
|
+
[onRunComplete, startEntry, task],
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return { cancel, state, submit }
|
|
83
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
interface TerminalSize {
|
|
4
|
+
columns: number
|
|
5
|
+
rows: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const DEFAULT_SIZE: TerminalSize = { columns: 80, rows: 24 }
|
|
9
|
+
|
|
10
|
+
function getCurrentSize(): TerminalSize {
|
|
11
|
+
if (!process.stdout.isTTY) {
|
|
12
|
+
return DEFAULT_SIZE
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
columns: process.stdout.columns ?? DEFAULT_SIZE.columns,
|
|
16
|
+
rows: process.stdout.rows ?? DEFAULT_SIZE.rows,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function useTerminalSize(): TerminalSize {
|
|
21
|
+
const [size, setSize] = useState<TerminalSize>(getCurrentSize)
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!process.stdout.isTTY) return
|
|
25
|
+
|
|
26
|
+
const handleResize = () => {
|
|
27
|
+
setSize(getCurrentSize())
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
process.stdout.on('resize', handleResize)
|
|
31
|
+
return () => {
|
|
32
|
+
process.stdout.off('resize', handleResize)
|
|
33
|
+
}
|
|
34
|
+
}, [])
|
|
35
|
+
|
|
36
|
+
return size
|
|
37
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip markdown formatting for clean terminal display.
|
|
3
|
+
* Keeps code blocks/inline code visible since they're meaningful in a code context.
|
|
4
|
+
*/
|
|
5
|
+
export function stripMarkdown(text: string): string {
|
|
6
|
+
const preserved: string[] = []
|
|
7
|
+
|
|
8
|
+
function preserve(input: string, pattern: RegExp): string {
|
|
9
|
+
return input.replace(pattern, (match) => {
|
|
10
|
+
const token = `@@PRESERVE_${preserved.length}@@`
|
|
11
|
+
preserved.push(match)
|
|
12
|
+
return token
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function restore(input: string): string {
|
|
17
|
+
return input.replace(/@@PRESERVE_(\d+)@@/g, (_, index) => preserved[Number(index)] ?? '')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const withoutCode = preserve(preserve(text, /```[\s\S]*?```/g), /`[^`]*`/g)
|
|
21
|
+
|
|
22
|
+
const stripped = withoutCode
|
|
23
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Links
|
|
24
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1') // Bold **
|
|
25
|
+
.replace(/__([^_]+)__/g, '$1') // Bold __
|
|
26
|
+
.replace(/\*([^*]+)\*/g, '$1') // Italic *
|
|
27
|
+
.replace(/(?<!\w)_([^_]+)_(?!\w)/g, '$1') // Italic _
|
|
28
|
+
.replace(/~~([^~]+)~~/g, '$1') // Strikethrough
|
|
29
|
+
.replace(/^#{1,6}\s*/gm, '') // Headers
|
|
30
|
+
.replace(/^\s*>+\s?/gm, '') // Blockquotes
|
|
31
|
+
.replace(/^\s*[-*]\s+/gm, '') // Unordered lists
|
|
32
|
+
.replace(/^\s*\d+\.\s+/gm, '') // Ordered lists
|
|
33
|
+
.replace(/^[-*]{3,}\s*$/gm, '') // Horizontal rules
|
|
34
|
+
|
|
35
|
+
return restore(stripped)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Replace delimited sections with a placeholder.
|
|
40
|
+
* Handles both paired delimiters (open/close) and single-character delimiters.
|
|
41
|
+
*/
|
|
42
|
+
function replaceDelimited(
|
|
43
|
+
input: string,
|
|
44
|
+
openDelimiter: string,
|
|
45
|
+
closeDelimiter: string,
|
|
46
|
+
replacement: string,
|
|
47
|
+
): string {
|
|
48
|
+
let result = ''
|
|
49
|
+
let cursor = 0
|
|
50
|
+
|
|
51
|
+
while (cursor < input.length) {
|
|
52
|
+
const start = input.indexOf(openDelimiter, cursor)
|
|
53
|
+
if (start === -1) {
|
|
54
|
+
result += input.slice(cursor)
|
|
55
|
+
break
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const searchFrom = start + openDelimiter.length
|
|
59
|
+
const end = input.indexOf(closeDelimiter, searchFrom)
|
|
60
|
+
|
|
61
|
+
result += input.slice(cursor, start) + replacement
|
|
62
|
+
|
|
63
|
+
if (end === -1) {
|
|
64
|
+
result += input.slice(searchFrom)
|
|
65
|
+
break
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
cursor = end + closeDelimiter.length
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return result
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Clean text for speech synthesis.
|
|
76
|
+
* Removes code blocks/inline code (replacing with spoken placeholders),
|
|
77
|
+
* strips markdown, and collapses whitespace.
|
|
78
|
+
*/
|
|
79
|
+
export function cleanTextForSpeech(text: string): string {
|
|
80
|
+
const withoutCodeBlocks = replaceDelimited(text, '```', '```', ' code block ')
|
|
81
|
+
const withoutInlineCode = replaceDelimited(withoutCodeBlocks, '`', '`', ' code ')
|
|
82
|
+
|
|
83
|
+
return withoutInlineCode
|
|
84
|
+
.split('\n')
|
|
85
|
+
.map((line) => stripMarkdown(line))
|
|
86
|
+
.join('\n')
|
|
87
|
+
.replace(/\s+/g, ' ')
|
|
88
|
+
.trim()
|
|
89
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { AnthropicModel, LlmModelId, LlmProvider } from '../../types'
|
|
2
|
+
import { ANTHROPIC_MODELS } from '../../types'
|
|
3
|
+
|
|
4
|
+
export const MODEL_LABELS: Record<AnthropicModel, string> = {
|
|
5
|
+
'claude-haiku-4-5-20251001': 'Haiku',
|
|
6
|
+
'claude-sonnet-4-6': 'Sonnet 4.6',
|
|
7
|
+
'claude-opus-4-6': 'Opus 4.6',
|
|
8
|
+
'claude-opus-4-5-20251101': 'Opus 4.5',
|
|
9
|
+
'claude-sonnet-4-5-20250929': 'Sonnet 4.5',
|
|
10
|
+
'claude-opus-4-1-20250805': 'Opus 4.1',
|
|
11
|
+
'claude-opus-4-20250514': 'Opus 4',
|
|
12
|
+
'claude-sonnet-4-20250514': 'Sonnet 4',
|
|
13
|
+
'claude-3-haiku-20240307': 'Haiku 3',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function formatModelLabel(provider: LlmProvider, model: LlmModelId): string {
|
|
17
|
+
if (provider !== 'anthropic') return model
|
|
18
|
+
if (!ANTHROPIC_MODELS.includes(model as AnthropicModel)) return model
|
|
19
|
+
return MODEL_LABELS[model as AnthropicModel] ?? model
|
|
20
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Truncate text to a maximum number of lines, keeping the last N lines (tail).
|
|
3
|
+
* Useful for streaming content where the most recent output is most relevant.
|
|
4
|
+
*/
|
|
5
|
+
export function truncateLines(
|
|
6
|
+
text: string,
|
|
7
|
+
maxLines: number,
|
|
8
|
+
): { text: string; truncatedCount: number } {
|
|
9
|
+
const lines = text.split('\n')
|
|
10
|
+
if (lines.length <= maxLines) {
|
|
11
|
+
return { text, truncatedCount: 0 }
|
|
12
|
+
}
|
|
13
|
+
const truncated = lines.slice(-maxLines)
|
|
14
|
+
return {
|
|
15
|
+
text: truncated.join('\n'),
|
|
16
|
+
truncatedCount: lines.length - maxLines,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export const TOOL_INPUT_KEYS: Record<string, string> = {
|
|
2
|
+
Glob: 'pattern',
|
|
3
|
+
Grep: 'pattern',
|
|
4
|
+
Read: 'file_path',
|
|
5
|
+
Bash: 'command',
|
|
6
|
+
LS: 'path',
|
|
7
|
+
bash: 'command',
|
|
8
|
+
readFile: 'path',
|
|
9
|
+
writeFile: 'path',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function truncate(text: string, maxLen: number, mode: 'start' | 'end' = 'end'): string {
|
|
13
|
+
if (text.length <= maxLen) return text
|
|
14
|
+
if (mode === 'start') {
|
|
15
|
+
return '...' + text.slice(-(maxLen - 3))
|
|
16
|
+
}
|
|
17
|
+
return text.slice(0, maxLen - 3) + '...'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function formatToolInput(name: string, input: Record<string, unknown>): string {
|
|
21
|
+
const key = TOOL_INPUT_KEYS[name] ?? Object.keys(input)[0]
|
|
22
|
+
if (!key || input[key] === undefined) {
|
|
23
|
+
if ('value' in input && input.value !== undefined) {
|
|
24
|
+
return truncate(String(input.value), 40, 'end')
|
|
25
|
+
}
|
|
26
|
+
return ''
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const value = String(input[key])
|
|
30
|
+
const truncateMode = name === 'Read' ? 'start' : 'end'
|
|
31
|
+
return truncate(value, 40, truncateMode)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const STATUS_CONFIG = {
|
|
35
|
+
running: { icon: '⠋', color: 'yellow' },
|
|
36
|
+
error: { icon: '✗', color: 'red' },
|
|
37
|
+
complete: { icon: '✓', color: 'green' },
|
|
38
|
+
} as const
|
|
39
|
+
|
|
40
|
+
export type ToolStatus = keyof typeof STATUS_CONFIG
|