@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,202 @@
1
+ import { createBashTool } from 'bash-tool'
2
+ import { ToolLoopAgent, stepCountIs, type ToolSet, type StepResult } from 'ai'
3
+ import { buildProviderPrompt } from '../../services/prompts'
4
+ import type { Frame } from '../frames'
5
+ import { createFrame } from '../frames'
6
+ import type { AgentAdapter, AgentAdapterConfig } from './types'
7
+ import { normalizeToolInput, isToolError, formatToolResult } from './utils'
8
+ import { resolveOpenAiProvider } from '../../services/openai-auth'
9
+
10
+ interface OaiToolCall {
11
+ toolCallId: string
12
+ toolName: string
13
+ input: unknown
14
+ }
15
+
16
+ interface OaiToolResult {
17
+ toolCallId: string
18
+ toolName: string
19
+ output: unknown
20
+ }
21
+
22
+ export function createOpenAiAdapter(config: AgentAdapterConfig): AgentAdapter {
23
+ return {
24
+ async *stream(prompt: string): AsyncIterable<Frame> {
25
+ const { appConfig, session, abortController } = config
26
+ const previousResponseId =
27
+ session?.provider === 'openai' ? session.previousResponseId : undefined
28
+ const instructions = await buildProviderPrompt({
29
+ provider: 'openai',
30
+ projectPath: appConfig.projectPath,
31
+ ttsEnabled: appConfig.ttsEnabled,
32
+ })
33
+
34
+ const { tools, sandbox } = await createBashTool({
35
+ uploadDirectory: {
36
+ source: appConfig.projectPath,
37
+ include: '**/*',
38
+ },
39
+ maxFiles: 5000,
40
+ })
41
+
42
+ const { bash, readFile, writeFile } = tools
43
+ const allowedTools: ToolSet = { bash, readFile, writeFile }
44
+
45
+ let accumulatedText = ''
46
+ let toolIndex = 0
47
+ const toolIdToIndex = new Map<string, number>()
48
+ const pendingFrames: Frame[] = []
49
+
50
+ function getOrCreateIndex(toolId: string): number {
51
+ const existing = toolIdToIndex.get(toolId)
52
+ if (existing !== undefined) return existing
53
+ const index = toolIndex++
54
+ toolIdToIndex.set(toolId, index)
55
+ return index
56
+ }
57
+
58
+ function registerToolCall(call: OaiToolCall): void {
59
+ const index = getOrCreateIndex(call.toolCallId)
60
+ pendingFrames.push(
61
+ createFrame('tool-call-start', {
62
+ toolCall: {
63
+ id: call.toolCallId,
64
+ index,
65
+ name: call.toolName,
66
+ input: normalizeToolInput(call.input),
67
+ status: 'running',
68
+ },
69
+ }),
70
+ )
71
+ }
72
+
73
+ function registerToolResult(result: OaiToolResult): void {
74
+ const existingIndex = toolIdToIndex.get(result.toolCallId)
75
+ const index = getOrCreateIndex(result.toolCallId)
76
+
77
+ if (existingIndex === undefined) {
78
+ pendingFrames.push(
79
+ createFrame('tool-call-start', {
80
+ toolCall: {
81
+ id: result.toolCallId,
82
+ index,
83
+ name: result.toolName,
84
+ input: {},
85
+ status: 'running',
86
+ },
87
+ }),
88
+ )
89
+ }
90
+
91
+ const text = formatToolResult(result.output)
92
+ pendingFrames.push(
93
+ createFrame('tool-call-result', {
94
+ toolIndex: index,
95
+ result: text,
96
+ status: isToolError(result.output) ? 'error' : 'complete',
97
+ }),
98
+ )
99
+ }
100
+
101
+ const { provider } = await resolveOpenAiProvider(appConfig)
102
+ const model = provider.responses(appConfig.llmModel as never)
103
+
104
+ let finalResponseId: string | undefined
105
+
106
+ const runStream = async function* (continuationResponseId?: string): AsyncIterable<Frame> {
107
+ accumulatedText = ''
108
+ toolIndex = 0
109
+ toolIdToIndex.clear()
110
+ pendingFrames.length = 0
111
+
112
+ const agent = new ToolLoopAgent({
113
+ model,
114
+ ...(continuationResponseId ? {} : { instructions }),
115
+ tools: allowedTools,
116
+ stopWhen: stepCountIs(20),
117
+ providerOptions: {
118
+ openai: {
119
+ truncation: 'auto',
120
+ ...(continuationResponseId
121
+ ? {
122
+ previousResponseId: continuationResponseId,
123
+ instructions,
124
+ }
125
+ : {}),
126
+ },
127
+ },
128
+ })
129
+
130
+ const agentStream = await agent.stream({
131
+ prompt,
132
+ onStepFinish: (stepResult: StepResult<ToolSet>) => {
133
+ for (const call of stepResult.toolCalls) {
134
+ registerToolCall(call)
135
+ }
136
+ for (const result of stepResult.toolResults) {
137
+ registerToolResult(result)
138
+ }
139
+ },
140
+ abortSignal: abortController.signal,
141
+ })
142
+
143
+ for await (const chunk of agentStream.textStream) {
144
+ // Drain any tool frames that arrived via onStepFinish
145
+ while (pendingFrames.length > 0) {
146
+ yield pendingFrames.shift()!
147
+ }
148
+
149
+ accumulatedText += chunk
150
+ yield createFrame('agent-text-delta', {
151
+ delta: chunk,
152
+ accumulatedText,
153
+ })
154
+ }
155
+
156
+ // Drain remaining tool frames after text stream ends
157
+ while (pendingFrames.length > 0) {
158
+ yield pendingFrames.shift()!
159
+ }
160
+
161
+ finalResponseId = (await agentStream.response).id
162
+ }
163
+
164
+ const isInvalidContinuationError = (err: unknown): boolean => {
165
+ if (!previousResponseId) return false
166
+ const message = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase()
167
+ const statusCode =
168
+ typeof err === 'object' && err !== null && 'statusCode' in err
169
+ ? (err as { statusCode?: number }).statusCode
170
+ : undefined
171
+
172
+ return (
173
+ statusCode === 400 ||
174
+ statusCode === 404 ||
175
+ message.includes('previous_response_id') ||
176
+ message.includes('previous response') ||
177
+ message.includes('conversation')
178
+ )
179
+ }
180
+
181
+ try {
182
+ try {
183
+ yield* runStream(previousResponseId)
184
+ } catch (err) {
185
+ if (!isInvalidContinuationError(err)) throw err
186
+ yield* runStream()
187
+ }
188
+
189
+ if (finalResponseId) {
190
+ yield createFrame('agent-text-complete', {
191
+ text: accumulatedText,
192
+ session: { provider: 'openai', previousResponseId: finalResponseId },
193
+ })
194
+ }
195
+ } finally {
196
+ if ('stop' in sandbox && typeof sandbox.stop === 'function') {
197
+ await (sandbox.stop as () => Promise<void>)().catch(() => {})
198
+ }
199
+ }
200
+ },
201
+ }
202
+ }
@@ -0,0 +1,16 @@
1
+ import type { Frame } from '../frames'
2
+ import type { AgentSession, AppConfig } from '../../types'
3
+
4
+ /**
5
+ * Normalized interface for agent providers.
6
+ * Each adapter wraps a provider SDK and yields frames instead of calling callbacks.
7
+ */
8
+ export interface AgentAdapter {
9
+ stream(prompt: string): AsyncIterable<Frame>
10
+ }
11
+
12
+ export interface AgentAdapterConfig {
13
+ appConfig: AppConfig
14
+ session: AgentSession | undefined
15
+ abortController: AbortController
16
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Shared parsing helpers extracted from existing agent implementations.
3
+ * Used by both Anthropic and OpenAI adapters.
4
+ */
5
+
6
+ // ── Anthropic SDK message parsing ──
7
+
8
+ export type TextBlock = { type: 'text'; text: string }
9
+ export type ToolUseBlock = {
10
+ type: 'tool_use'
11
+ id?: string
12
+ tool_use_id?: string
13
+ name: string
14
+ input?: Record<string, unknown>
15
+ }
16
+ export type ToolResultBlock = {
17
+ type: 'tool_result'
18
+ tool_use_id?: string
19
+ id?: string
20
+ content?: unknown
21
+ is_error?: boolean
22
+ }
23
+
24
+ export function getContentBlocks(message: unknown): unknown[] {
25
+ if (typeof message === 'string') {
26
+ return [{ type: 'text', text: message }]
27
+ }
28
+ if (!message || typeof message !== 'object') return []
29
+ const content = (message as { content?: unknown }).content
30
+ return Array.isArray(content) ? content : []
31
+ }
32
+
33
+ function hasType(value: unknown, type: string): value is { type: string } {
34
+ return value !== null && typeof value === 'object' && (value as { type?: unknown }).type === type
35
+ }
36
+
37
+ export function isTextBlock(value: unknown): value is TextBlock {
38
+ return hasType(value, 'text')
39
+ }
40
+
41
+ export function isToolUseBlock(value: unknown): value is ToolUseBlock {
42
+ return hasType(value, 'tool_use') && typeof (value as ToolUseBlock).name === 'string'
43
+ }
44
+
45
+ export function isToolResultBlock(value: unknown): value is ToolResultBlock {
46
+ return hasType(value, 'tool_result')
47
+ }
48
+
49
+ export function extractToolResultText(content: unknown): string {
50
+ if (typeof content === 'string') return content
51
+ if (Array.isArray(content)) {
52
+ return content
53
+ .map((block) => {
54
+ if (!block || typeof block !== 'object') return ''
55
+ const typedBlock = block as { type?: string; text?: string }
56
+ if (typedBlock.type === 'text' && typeof typedBlock.text === 'string') {
57
+ return typedBlock.text
58
+ }
59
+ return ''
60
+ })
61
+ .join('')
62
+ }
63
+ if (content && typeof content === 'object' && 'text' in (content as Record<string, unknown>)) {
64
+ return String((content as { text?: unknown }).text ?? '')
65
+ }
66
+ return ''
67
+ }
68
+
69
+ // ── OpenAI tool result parsing ──
70
+
71
+ export function normalizeToolInput(value: unknown): Record<string, unknown> {
72
+ if (!value) return {}
73
+ if (typeof value === 'object') return value as Record<string, unknown>
74
+ if (typeof value === 'string') {
75
+ try {
76
+ const parsed = JSON.parse(value) as unknown
77
+ if (parsed && typeof parsed === 'object') {
78
+ return parsed as Record<string, unknown>
79
+ }
80
+ } catch {
81
+ return { value }
82
+ }
83
+ }
84
+ return { value }
85
+ }
86
+
87
+ export function isToolError(value: unknown): boolean {
88
+ if (!value || typeof value !== 'object') return false
89
+ const typed = value as Record<string, unknown>
90
+ if (typed.isError === true || typed.is_error === true) return true
91
+ if (typeof typed.exitCode === 'number' && typed.exitCode !== 0) return true
92
+ if (typeof typed.success === 'boolean' && typed.success === false) return true
93
+ if (typed.error instanceof Error) return true
94
+ if (typeof typed.error === 'string' && typed.error.length > 0) return true
95
+ return false
96
+ }
97
+
98
+ export function formatToolResult(value: unknown): string {
99
+ if (typeof value === 'string') return value
100
+ if (!value || typeof value !== 'object') return String(value ?? '')
101
+
102
+ const typed = value as Record<string, unknown>
103
+
104
+ const stdout = typeof typed.stdout === 'string' ? typed.stdout : ''
105
+ const stderr = typeof typed.stderr === 'string' ? typed.stderr : ''
106
+ if (stdout || stderr) return [stdout, stderr].filter(Boolean).join('\n').trim()
107
+
108
+ if (typeof typed.content === 'string') return typed.content
109
+ if (typeof typed.result === 'string') return typed.result
110
+ if (typeof typed.success === 'boolean') return typed.success ? 'success' : 'error'
111
+
112
+ try {
113
+ return JSON.stringify(value)
114
+ } catch {
115
+ return String(value)
116
+ }
117
+ }
118
+
119
+ // ── Shared ──
120
+
121
+ export function isAbortError(err: unknown): boolean {
122
+ if (
123
+ typeof globalThis.DOMException !== 'undefined' &&
124
+ err instanceof globalThis.DOMException &&
125
+ err.name === 'AbortError'
126
+ )
127
+ return true
128
+ if (err instanceof Error && err.name === 'AbortError') return true
129
+ if (err instanceof Error && err.message.includes('aborted')) return true
130
+ return false
131
+ }
@@ -0,0 +1,113 @@
1
+ import type { AgentSession, ToolCall, TTSErrorType } from '../types'
2
+
3
+ // ── Base ──
4
+
5
+ interface BaseFrame {
6
+ id: number
7
+ timestamp: number
8
+ }
9
+
10
+ // ── User Input ──
11
+
12
+ export interface UserTextFrame extends BaseFrame {
13
+ kind: 'user-text'
14
+ text: string
15
+ entryId: string
16
+ }
17
+
18
+ // ── Agent Output ──
19
+
20
+ export interface AgentTextDeltaFrame extends BaseFrame {
21
+ kind: 'agent-text-delta'
22
+ delta: string
23
+ accumulatedText: string
24
+ }
25
+
26
+ export interface AgentTextCompleteFrame extends BaseFrame {
27
+ kind: 'agent-text-complete'
28
+ text: string
29
+ session?: AgentSession
30
+ }
31
+
32
+ export interface ToolCallStartFrame extends BaseFrame {
33
+ kind: 'tool-call-start'
34
+ toolCall: ToolCall
35
+ }
36
+
37
+ export interface ToolCallResultFrame extends BaseFrame {
38
+ kind: 'tool-call-result'
39
+ toolIndex: number
40
+ result: string
41
+ status: 'complete' | 'error'
42
+ }
43
+
44
+ export interface AgentSessionFrame extends BaseFrame {
45
+ kind: 'agent-session'
46
+ session: AgentSession
47
+ }
48
+
49
+ export interface AgentErrorFrame extends BaseFrame {
50
+ kind: 'agent-error'
51
+ error: Error
52
+ }
53
+
54
+ // ── TTS ──
55
+
56
+ export interface TTSSpeakingStartFrame extends BaseFrame {
57
+ kind: 'tts-speaking-start'
58
+ }
59
+
60
+ export interface TTSSpeakingEndFrame extends BaseFrame {
61
+ kind: 'tts-speaking-end'
62
+ }
63
+
64
+ export interface TTSErrorFrame extends BaseFrame {
65
+ kind: 'tts-error'
66
+ errorType: TTSErrorType
67
+ message: string
68
+ }
69
+
70
+ // ── Control ──
71
+
72
+ export interface CancelFrame extends BaseFrame {
73
+ kind: 'cancel'
74
+ }
75
+
76
+ // ── Union ──
77
+
78
+ export type Frame =
79
+ | UserTextFrame
80
+ | AgentTextDeltaFrame
81
+ | AgentTextCompleteFrame
82
+ | ToolCallStartFrame
83
+ | ToolCallResultFrame
84
+ | AgentSessionFrame
85
+ | AgentErrorFrame
86
+ | TTSSpeakingStartFrame
87
+ | TTSSpeakingEndFrame
88
+ | TTSErrorFrame
89
+ | CancelFrame
90
+
91
+ // ── Factory ──
92
+
93
+ let nextId = 0
94
+
95
+ type FrameOfKind<K extends Frame['kind']> = Extract<Frame, { kind: K }>
96
+ type FrameData<K extends Frame['kind']> = Omit<FrameOfKind<K>, 'id' | 'timestamp' | 'kind'>
97
+
98
+ export function createFrame<K extends Frame['kind']>(
99
+ kind: K,
100
+ ...[data]: FrameData<K> extends Record<string, never> ? [] : [FrameData<K>]
101
+ ): FrameOfKind<K> {
102
+ return {
103
+ kind,
104
+ id: nextId++,
105
+ timestamp: Date.now(),
106
+ ...(data ?? {}),
107
+ } as FrameOfKind<K>
108
+ }
109
+
110
+ /** Reset frame ID counter (for testing) */
111
+ export function resetFrameIds(): void {
112
+ nextId = 0
113
+ }
@@ -0,0 +1,36 @@
1
+ import type { Frame } from './frames'
2
+
3
+ export interface PipelineMetrics {
4
+ runId: number
5
+ startTime: number
6
+ endTime?: number
7
+
8
+ /** ms from run start to first AgentTextDeltaFrame */
9
+ agentFirstTokenMs?: number
10
+ /** ms from run start to AgentTextCompleteFrame */
11
+ agentCompleteMs?: number
12
+ /** total characters across all text deltas */
13
+ totalTextChars: number
14
+ toolCallCount: number
15
+ toolErrorCount: number
16
+
17
+ /** ms from run start to first TTSSpeakingStartFrame */
18
+ ttsSpeakingStartMs?: number
19
+ /** ms from run start to last TTSSpeakingEndFrame */
20
+ ttsSpeakingEndMs?: number
21
+ ttsErrorCount: number
22
+
23
+ /** frame counts by kind */
24
+ frameCounts: Record<string, number>
25
+ }
26
+
27
+ export interface PipelineObserver {
28
+ /** Called for every frame flowing through the pipeline */
29
+ onFrame(frame: Frame): void
30
+
31
+ /** Called when a run starts */
32
+ onRunStart?(runId: number): void
33
+
34
+ /** Called when a run completes (with full metrics) */
35
+ onRunEnd?(metrics: PipelineMetrics): void
36
+ }
@@ -0,0 +1,95 @@
1
+ import type { Frame } from '../frames'
2
+ import type { PipelineMetrics, PipelineObserver } from '../observer'
3
+
4
+ /**
5
+ * Tracks timing and counts for each pipeline run.
6
+ * Call getMetrics() after onRunEnd to retrieve the snapshot.
7
+ */
8
+ export function createMetricsObserver(): PipelineObserver & {
9
+ getMetrics(): PipelineMetrics | null
10
+ } {
11
+ let current: PipelineMetrics | null = null
12
+ let lastCompleted: PipelineMetrics | null = null
13
+
14
+ function ensureMetrics(runId: number): PipelineMetrics {
15
+ if (!current || current.runId !== runId) {
16
+ current = {
17
+ runId,
18
+ startTime: Date.now(),
19
+ totalTextChars: 0,
20
+ toolCallCount: 0,
21
+ toolErrorCount: 0,
22
+ ttsErrorCount: 0,
23
+ frameCounts: {},
24
+ }
25
+ }
26
+ return current
27
+ }
28
+
29
+ return {
30
+ onFrame(frame: Frame): void {
31
+ if (!current) return
32
+ const m = current
33
+ const elapsed = Date.now() - m.startTime
34
+
35
+ // Count every frame by kind
36
+ m.frameCounts[frame.kind] = (m.frameCounts[frame.kind] ?? 0) + 1
37
+
38
+ switch (frame.kind) {
39
+ case 'agent-text-delta':
40
+ if (m.agentFirstTokenMs === undefined) {
41
+ m.agentFirstTokenMs = elapsed
42
+ }
43
+ m.totalTextChars += frame.delta.length
44
+ break
45
+
46
+ case 'agent-text-complete':
47
+ m.agentCompleteMs = elapsed
48
+ break
49
+
50
+ case 'tool-call-start':
51
+ m.toolCallCount++
52
+ break
53
+
54
+ case 'tool-call-result':
55
+ if (frame.status === 'error') m.toolErrorCount++
56
+ break
57
+
58
+ case 'tts-speaking-start':
59
+ if (m.ttsSpeakingStartMs === undefined) {
60
+ m.ttsSpeakingStartMs = elapsed
61
+ }
62
+ break
63
+
64
+ case 'tts-speaking-end':
65
+ m.ttsSpeakingEndMs = elapsed
66
+ break
67
+
68
+ case 'tts-error':
69
+ m.ttsErrorCount++
70
+ break
71
+ }
72
+ },
73
+
74
+ onRunStart(runId: number): void {
75
+ ensureMetrics(runId)
76
+ },
77
+
78
+ onRunEnd(metrics: PipelineMetrics): void {
79
+ if (current) {
80
+ lastCompleted = {
81
+ ...current,
82
+ endTime: metrics.endTime ?? Date.now(),
83
+ }
84
+ current = null
85
+ return
86
+ }
87
+
88
+ lastCompleted = metrics
89
+ },
90
+
91
+ getMetrics(): PipelineMetrics | null {
92
+ return lastCompleted ?? current
93
+ },
94
+ }
95
+ }
@@ -0,0 +1,43 @@
1
+ import type { Frame } from './frames'
2
+ import type { Processor } from './processor'
3
+ import type { PipelineObserver } from './observer'
4
+
5
+ export interface PipelineConfig {
6
+ processors: Processor[]
7
+ observers?: PipelineObserver[]
8
+ }
9
+
10
+ /**
11
+ * Creates a pipeline that chains processors left-to-right.
12
+ * The pipeline itself is a Processor: AsyncIterable<Frame> → AsyncIterable<Frame>.
13
+ */
14
+ export function createPipeline(config: PipelineConfig): Processor {
15
+ return (source: AsyncIterable<Frame>) => {
16
+ let stream: AsyncIterable<Frame> = source
17
+
18
+ for (const processor of config.processors) {
19
+ stream = processor(stream)
20
+ }
21
+
22
+ if (config.observers && config.observers.length > 0) {
23
+ stream = tapObservers(stream, config.observers)
24
+ }
25
+
26
+ return stream
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Wraps a stream to notify observers of each frame without modifying the stream.
32
+ */
33
+ async function* tapObservers(
34
+ upstream: AsyncIterable<Frame>,
35
+ observers: PipelineObserver[],
36
+ ): AsyncGenerator<Frame> {
37
+ for await (const frame of upstream) {
38
+ for (const observer of observers) {
39
+ observer.onFrame(frame)
40
+ }
41
+ yield frame
42
+ }
43
+ }
@@ -0,0 +1,57 @@
1
+ import type { Frame } from './frames'
2
+
3
+ /**
4
+ * A Processor transforms an async stream of frames.
5
+ * Compose processors left-to-right: output of one feeds input of the next.
6
+ * Use `finally` blocks for cleanup on cancellation (generator `.return()`).
7
+ */
8
+ export type Processor = (upstream: AsyncIterable<Frame>) => AsyncIterable<Frame>
9
+
10
+ /**
11
+ * Per-frame handler: receives one frame, returns zero or more frames.
12
+ * Return `null` to filter a frame out; return the frame to pass it through.
13
+ */
14
+ type FrameHandler = (frame: Frame) => AsyncIterable<Frame> | Iterable<Frame> | Frame | null
15
+
16
+ interface ProcessorOptions {
17
+ onInit?: () => void | Promise<void>
18
+ onDestroy?: () => void | Promise<void>
19
+ }
20
+
21
+ /**
22
+ * Creates a Processor from a simpler per-frame handler.
23
+ * Handles the common case where each input frame produces 0..N output frames.
24
+ */
25
+ export function createProcessor(handler: FrameHandler, options?: ProcessorOptions): Processor {
26
+ return async function* (upstream: AsyncIterable<Frame>): AsyncGenerator<Frame> {
27
+ await options?.onInit?.()
28
+ try {
29
+ for await (const frame of upstream) {
30
+ const result = handler(frame)
31
+ if (result === null) continue
32
+ if (isAsyncIterable(result)) {
33
+ yield* result
34
+ } else if (isIterable(result)) {
35
+ yield* result
36
+ } else {
37
+ yield result
38
+ }
39
+ }
40
+ } finally {
41
+ await options?.onDestroy?.()
42
+ }
43
+ }
44
+ }
45
+
46
+ /** Helper: yield a single frame as an async iterable */
47
+ export async function* singleFrame(frame: Frame): AsyncGenerator<Frame> {
48
+ yield frame
49
+ }
50
+
51
+ function isAsyncIterable(value: unknown): value is AsyncIterable<Frame> {
52
+ return value !== null && typeof value === 'object' && Symbol.asyncIterator in (value as object)
53
+ }
54
+
55
+ function isIterable(value: unknown): value is Iterable<Frame> {
56
+ return value !== null && typeof value === 'object' && Symbol.iterator in (value as object)
57
+ }