novacode 0.2.0

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.
@@ -0,0 +1,517 @@
1
+ import chalk from "chalk"
2
+ import { Box, render, Static, Text, useInput } from "ink"
3
+ import { useEffect, useRef, useState } from "react"
4
+ import type { Agent } from "../agent/agent.ts"
5
+ import { COMMANDS, dispatch } from "../commands/index.ts"
6
+ import type { SessionStore } from "../session/store.ts"
7
+ import type { Msg } from "../types.ts"
8
+ import { formatToolArgs, makeRelative } from "../util.ts"
9
+ import { formatMarkdown } from "./markdown.ts"
10
+ export async function interactive(
11
+ agent: Agent,
12
+ store: SessionStore,
13
+ sessionId: string,
14
+ ): Promise<void> {
15
+ // Hide system cursor during session
16
+ process.stdout.write("\x1B[?25l")
17
+ process.stdout.write(chalk.bold.cyan("⚡ novacode\n"))
18
+
19
+ try {
20
+ const { waitUntilExit } = render(<App agent={agent} store={store} sessionId={sessionId} />)
21
+ await waitUntilExit()
22
+ } finally {
23
+ // Restore system cursor on exit
24
+ process.stdout.write("\x1B[?25h")
25
+ }
26
+ }
27
+
28
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
29
+
30
+ function Spinner() {
31
+ const [frame, setFrame] = useState(0)
32
+
33
+ useEffect(() => {
34
+ const timer = setInterval(() => {
35
+ setFrame((f) => (f + 1) % SPINNER_FRAMES.length)
36
+ }, 80)
37
+ return () => clearInterval(timer)
38
+ }, [])
39
+
40
+ return <Text color="yellow">{SPINNER_FRAMES[frame]}</Text>
41
+ }
42
+
43
+ function Cursor() {
44
+ const [visible, setVisible] = useState(true)
45
+ useEffect(() => {
46
+ const timer = setInterval(() => setVisible((v) => !v), 530)
47
+ return () => clearInterval(timer)
48
+ }, [])
49
+ return <Text color="green">{visible ? "│" : " "}</Text>
50
+ }
51
+
52
+ function App({
53
+ agent: initialAgent,
54
+ store,
55
+ sessionId,
56
+ }: {
57
+ agent: Agent
58
+ store: SessionStore
59
+ sessionId: string
60
+ }) {
61
+ const [agent, _setAgent] = useState(initialAgent)
62
+ const [msgs, setMsgs] = useState<Msg[]>(initialAgent.messages)
63
+ const [stream, setStream] = useState("")
64
+ const [thinkStream, setThinkStream] = useState("")
65
+ const [busy, setBusy] = useState(false)
66
+ const [input, setInput] = useState("")
67
+ const [status, setStatus] = useState("")
68
+ const [usage, setUsage] = useState<{ in: number; out: number }>({ in: 0, out: 0 })
69
+ const [selCmdIdx, setSelCmdIdx] = useState(0)
70
+ const [cmdRunning, setCmdRunning] = useState(false)
71
+ const history = useRef<string[]>([])
72
+ const hIdx = useRef(-1)
73
+ const abortCtrl = useRef<AbortController | null>(null)
74
+
75
+ const isTypingCmd = input.startsWith("/") && !input.includes(" ")
76
+ const suggestions = isTypingCmd
77
+ ? COMMANDS.filter(
78
+ (c) =>
79
+ c.name.startsWith(input.slice(1).toLowerCase()) ||
80
+ c.aliases?.some((a) => a.startsWith(input.slice(1).toLowerCase())),
81
+ )
82
+ : []
83
+
84
+ // biome-ignore lint/correctness/useExhaustiveDependencies: reset selection on input change
85
+ useEffect(() => {
86
+ setSelCmdIdx(0)
87
+ }, [input])
88
+
89
+ useEffect(() => {
90
+ if (!cmdRunning) {
91
+ process.stdout.write("\x1B[?25l")
92
+ }
93
+ }, [cmdRunning])
94
+
95
+ useInput((ch, key) => {
96
+ if (cmdRunning) return
97
+
98
+ if (key.escape) {
99
+ if (abortCtrl.current) {
100
+ abortCtrl.current.abort()
101
+ abortCtrl.current = null
102
+ }
103
+ return
104
+ }
105
+ if (key.upArrow) {
106
+ if (isTypingCmd && suggestions.length > 0) {
107
+ setSelCmdIdx((prev) => (prev > 0 ? prev - 1 : suggestions.length - 1))
108
+ return
109
+ }
110
+ if (history.current.length > 0) {
111
+ hIdx.current = Math.min(hIdx.current + 1, history.current.length - 1)
112
+ setInput(history.current[hIdx.current] ?? "")
113
+ }
114
+ return
115
+ }
116
+ if (key.downArrow) {
117
+ if (isTypingCmd && suggestions.length > 0) {
118
+ setSelCmdIdx((prev) => (prev < suggestions.length - 1 ? prev + 1 : 0))
119
+ return
120
+ }
121
+ hIdx.current = Math.max(hIdx.current - 1, -1)
122
+ setInput(hIdx.current >= 0 ? (history.current[hIdx.current] ?? "") : "")
123
+ return
124
+ }
125
+ if (key.tab) {
126
+ if (isTypingCmd && suggestions.length > 0) {
127
+ const match = suggestions[selCmdIdx]
128
+ if (match) {
129
+ setInput(`/${match.name} `)
130
+ }
131
+ }
132
+ return
133
+ }
134
+ if (!key.return) {
135
+ setInput((prev) => {
136
+ if (key.backspace || key.delete) return prev.slice(0, -1)
137
+ return prev + ch
138
+ })
139
+ return
140
+ }
141
+
142
+ if (busy) return
143
+
144
+ let line = input.trim()
145
+ if (!line) return
146
+
147
+ if (isTypingCmd && suggestions.length > 0) {
148
+ const match = suggestions[selCmdIdx]
149
+ if (match) {
150
+ line = `/${match.name}`
151
+ }
152
+ }
153
+
154
+ setInput("")
155
+ history.current.unshift(line)
156
+ hIdx.current = -1
157
+
158
+ if (line.startsWith("/")) {
159
+ const cmdName = line.slice(1).split(" ")[0]?.toLowerCase() ?? ""
160
+ const isInteractive =
161
+ ["providers", "prov", "config", "cfg", "models", "model"].includes(cmdName) &&
162
+ !line.includes(" ")
163
+
164
+ if (isInteractive) {
165
+ setCmdRunning(true)
166
+ // Small delay to let Ink clear
167
+ setTimeout(() => {
168
+ dispatch(line, agent, store, sessionId).then((r) => {
169
+ process.stdin.setRawMode?.(true)
170
+ setCmdRunning(false)
171
+ if (r) {
172
+ setMsgs((prev) => {
173
+ const updated: Msg[] = [
174
+ ...prev,
175
+ {
176
+ role: "assistant",
177
+ content: [{ type: "text", text: r }],
178
+ model: "system",
179
+ provider: "system",
180
+ usage: { in: 0, out: 0 },
181
+ stop: "stop",
182
+ ts: Date.now(),
183
+ },
184
+ ]
185
+ agent.setMessages(updated)
186
+ return updated
187
+ })
188
+ }
189
+ })
190
+ }, 50)
191
+ return
192
+ }
193
+
194
+ // Slash commands
195
+ dispatch(line, agent, store, sessionId).then((r) => {
196
+ if (r) {
197
+ setMsgs((prev) => {
198
+ const updated: Msg[] = [
199
+ ...prev,
200
+ {
201
+ role: "assistant",
202
+ content: [{ type: "text", text: r }],
203
+ model: "system",
204
+ provider: "system",
205
+ usage: { in: 0, out: 0 },
206
+ stop: "stop",
207
+ ts: Date.now(),
208
+ },
209
+ ]
210
+
211
+ agent.setMessages(updated)
212
+ return updated
213
+ })
214
+ }
215
+ })
216
+ return
217
+ }
218
+
219
+ abortCtrl.current = new AbortController()
220
+ const stream = agent.prompt(line, abortCtrl.current.signal)
221
+
222
+ ;(async () => {
223
+ for await (const ev of stream) {
224
+ switch (ev.type) {
225
+ case "start":
226
+ setBusy(true)
227
+ setStream("")
228
+ setThinkStream("")
229
+ setStatus("")
230
+ break
231
+ case "text_delta":
232
+ if (ev.text) setStream((prev) => prev + ev.text)
233
+ break
234
+ case "thinking_delta":
235
+ if (ev.text) setThinkStream((prev) => prev + ev.text)
236
+ break
237
+ case "assistant_msg":
238
+ setStream("")
239
+ setThinkStream("")
240
+ setMsgs((prev) => {
241
+ const updated = [...prev, ev.msg]
242
+ agent.setMessages(updated)
243
+ return updated
244
+ })
245
+ store.append(sessionId, ev.msg)
246
+ break
247
+ case "tool_call":
248
+ setStatus(chalk.dim(`⏳ ${ev.call.name}…`))
249
+ break
250
+ case "tool_result":
251
+ setMsgs((prev) => {
252
+ const updated = [...prev, ev.result]
253
+ agent.setMessages(updated)
254
+ return updated
255
+ })
256
+ store.append(sessionId, ev.result)
257
+ {
258
+ const args = ev.args
259
+ ? `(${Object.values(ev.args)
260
+ .map((v) => {
261
+ const val = typeof v === "string" ? makeRelative(v) : JSON.stringify(v)
262
+ return val.length > 20 ? `${val.slice(0, 20)}…` : val
263
+ })
264
+ .join(", ")})`
265
+ : ""
266
+ setStatus(
267
+ ev.result.isError
268
+ ? chalk.red(`✗ ${ev.result.tool}${args}`)
269
+ : chalk.green(`✓ ${ev.result.tool}${args}`),
270
+ )
271
+ }
272
+ break
273
+ case "turn_end":
274
+ setStatus("")
275
+ break
276
+ case "usage":
277
+ if (ev.usage) setUsage(ev.usage)
278
+ }
279
+ }
280
+ abortCtrl.current = null
281
+ setBusy(false)
282
+ setStatus("")
283
+ setStream("")
284
+ setThinkStream("")
285
+ })().catch((err) => {
286
+ const errMsg: Msg = {
287
+ role: "assistant",
288
+ model: "system",
289
+ provider: "system",
290
+ content: [{ type: "text", text: chalk.red(`Error: ${err.message}`) }],
291
+ usage: { in: 0, out: 0 },
292
+ stop: "error",
293
+ ts: Date.now(),
294
+ }
295
+ setMsgs((prev) => [...prev, errMsg])
296
+ setBusy(false)
297
+ })
298
+
299
+ // Record user msg immediately
300
+ const userMsg: Msg = { role: "user", content: line, ts: Date.now() }
301
+ setMsgs((prev) => {
302
+ const updated = [...prev, userMsg]
303
+ agent.setMessages(updated)
304
+ return updated
305
+ })
306
+ store.append(sessionId, userMsg)
307
+ })
308
+
309
+ if (cmdRunning) return null
310
+
311
+ const visibleMsgs = msgs.filter(hasMeaningfulContent)
312
+ const isLiveActive = !!(stream || thinkStream || busy)
313
+
314
+ return (
315
+ <Box flexDirection="column" paddingX={1}>
316
+ {/* Messages - pushed to scrollback as they finish */}
317
+ <Static items={visibleMsgs}>
318
+ {(m, i) => <Message key={`${m.ts}-${i}`} msg={m} isFirst={i === 0} />}
319
+ </Static>
320
+
321
+ {/* Live Area (Streaming, active tool calls, working indicator) */}
322
+ {isLiveActive && (
323
+ <Box flexDirection="column" marginTop={visibleMsgs.length > 0 ? 1 : 0}>
324
+ {thinkStream && (
325
+ <Text dimColor italic>
326
+ {thinkStream}
327
+ </Text>
328
+ )}
329
+ {stream && (
330
+ <Box flexDirection="row">
331
+ <Box flexGrow={1} flexShrink={1}>
332
+ <Text>
333
+ {formatMarkdown(stream)}
334
+ {!input && <Cursor />}
335
+ </Text>
336
+ </Box>
337
+ </Box>
338
+ )}
339
+ {busy && !stream && (
340
+ <Box flexDirection="row">
341
+ <Box marginRight={1}>
342
+ <Spinner />
343
+ </Box>
344
+ <Text dimColor>{status ? status.replace("⏳ ", "") : chalk.yellow("working…")}</Text>
345
+ </Box>
346
+ )}
347
+ </Box>
348
+ )}
349
+
350
+ {/* Input & Footer (Live) */}
351
+ <Box flexDirection="column" marginTop={visibleMsgs.length > 0 || isLiveActive ? 1 : 0}>
352
+ <Box flexDirection="row">
353
+ <Box flexShrink={0} marginRight={1}>
354
+ <Text bold color="green">
355
+ {">"}
356
+ </Text>
357
+ </Box>
358
+ <Box flexGrow={1} flexShrink={1}>
359
+ <Text>
360
+ {input}
361
+ <Cursor />
362
+ </Text>
363
+ </Box>
364
+ </Box>
365
+
366
+ {/* Dynamic Status / Info Line */}
367
+ <Box justifyContent="space-between">
368
+ <Box>
369
+ {suggestions.length > 0 ? (
370
+ <Box flexDirection="column" marginLeft={2}>
371
+ {suggestions.map((s, i) => (
372
+ <Box key={s.name}>
373
+ <Text
374
+ color={i === selCmdIdx ? "black" : "yellow"}
375
+ backgroundColor={i === selCmdIdx ? "yellow" : undefined}
376
+ >
377
+ /{s.name.padEnd(10)}
378
+ </Text>
379
+ <Text dimColor> {s.desc}</Text>
380
+ </Box>
381
+ ))}
382
+ </Box>
383
+ ) : (
384
+ <Text dimColor>Enter to send · /help for commands</Text>
385
+ )}
386
+ </Box>
387
+
388
+ <Box>
389
+ <Text dimColor>{formatTokenUsage(usage.in, agent.model.contextWindow)}</Text>
390
+ <Text dimColor> │ </Text>
391
+ <Text dimColor>{agent.model.id}</Text>
392
+ {busy && <Text dimColor> │ Esc to stop</Text>}
393
+ </Box>
394
+ </Box>
395
+ </Box>
396
+ </Box>
397
+ )
398
+ }
399
+
400
+ function fmtK(n: number): string {
401
+ const k = n / 1000
402
+ return k >= 100 ? `${Math.round(k)}K` : `${k.toFixed(1)}K`
403
+ }
404
+
405
+ function formatTokenUsage(used: number, contextWindow: number): string {
406
+ if (used === 0) return `0/${fmtK(contextWindow)}`
407
+ const pct = Math.round((used / contextWindow) * 100)
408
+ return `${fmtK(used)}/${fmtK(contextWindow)} (${pct}%)`
409
+ }
410
+
411
+ const TOOL_STYLE: Record<string, string> = {
412
+ read: "blue",
413
+ write: "magenta",
414
+ edit: "yellow",
415
+ bash: "cyan",
416
+ glob: "green",
417
+ find: "green",
418
+ grep: "green",
419
+ }
420
+
421
+ function hasMeaningfulContent(msg: Msg): boolean {
422
+ if (msg.role === "user") return true
423
+ if (msg.role === "tool_result") return true
424
+ if (msg.role === "assistant") {
425
+ if (msg.model === "system") return true
426
+ return msg.content.some((c) => {
427
+ if (c.type === "thinking") return c.text.trim().length > 0
428
+ if (c.type === "text") return c.text.trim().length > 0
429
+ return false
430
+ })
431
+ }
432
+ return false
433
+ }
434
+
435
+ function Message({ msg, isFirst }: { msg: Msg; isFirst: boolean }) {
436
+ if (msg.role === "user") {
437
+ return (
438
+ <Box marginTop={isFirst ? 0 : 1} flexDirection="row">
439
+ <Box flexShrink={0} marginRight={1}>
440
+ <Text bold color="green">
441
+ {">"}
442
+ </Text>
443
+ </Box>
444
+ <Box flexGrow={1} flexShrink={1}>
445
+ <Text>
446
+ {typeof msg.content === "string"
447
+ ? msg.content
448
+ : msg.content.map((c) => (c.type === "text" ? c.text : "")).join("")}
449
+ </Text>
450
+ </Box>
451
+ </Box>
452
+ )
453
+ }
454
+
455
+ if (msg.role === "assistant") {
456
+ if (msg.model === "system") {
457
+ return (
458
+ <Box flexDirection="column" marginTop={0}>
459
+ {msg.content.map((c, i) =>
460
+ // biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
461
+ c.type === "text" ? <Text key={i}>{formatMarkdown(c.text)}</Text> : null,
462
+ )}
463
+ </Box>
464
+ )
465
+ }
466
+
467
+ // Don't render empty assistant messages (often just tool call containers)
468
+ const hasVisibleContent = msg.content.some((c) => c.type === "text" || c.type === "thinking")
469
+ if (!hasVisibleContent) return null
470
+
471
+ return (
472
+ <Box flexDirection="column" marginTop={0}>
473
+ {msg.content.map((c, i) => {
474
+ if (c.type === "thinking") {
475
+ return (
476
+ // biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
477
+ <Text key={i} dimColor italic>
478
+ {c.text}
479
+ </Text>
480
+ )
481
+ }
482
+ if (c.type === "text") {
483
+ // biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
484
+ return <Text key={i}>{formatMarkdown(c.text)}</Text>
485
+ }
486
+ return null
487
+ })}
488
+ </Box>
489
+ )
490
+ }
491
+
492
+ if (msg.role === "tool_result") {
493
+ const args = msg.args ? formatToolArgs(msg.args, true) : ""
494
+
495
+ const resText = msg.content
496
+ .map((c) => (c.type === "text" ? c.text : ""))
497
+ .join("")
498
+ .trim()
499
+
500
+ const isRead = msg.tool === "read"
501
+ const lineCount = isRead ? resText.split("\n").length : 0
502
+ const color = TOOL_STYLE[msg.tool] || "white"
503
+
504
+ return (
505
+ <Box flexDirection="row">
506
+ <Text color={msg.isError ? "red" : "green"}>{msg.isError ? "✗" : "✓"} </Text>
507
+ <Text color={color} bold>
508
+ {msg.tool}
509
+ </Text>
510
+ {args && <Text> {args}</Text>}
511
+ {isRead && !msg.isError && <Text dimColor> ({lineCount} lines)</Text>}
512
+ {msg.isError && resText && <Text color="red"> {resText.slice(0, 80)}</Text>}
513
+ </Box>
514
+ )
515
+ }
516
+ return null
517
+ }
@@ -0,0 +1,62 @@
1
+ import chalk from "chalk"
2
+
3
+ export class MarkdownRenderer {
4
+ #inCodeBlock = false
5
+ #codeBlockLang = ""
6
+
7
+ renderLine(line: string): string {
8
+ if (line.startsWith("```")) {
9
+ if (this.#inCodeBlock) {
10
+ this.#inCodeBlock = false
11
+ return chalk.dim(`└${"─".repeat(50)}`)
12
+ }
13
+ this.#inCodeBlock = true
14
+ this.#codeBlockLang = line.slice(3).trim()
15
+ return chalk.dim(
16
+ "┌" +
17
+ "─".repeat(10) +
18
+ ` [Code: ${this.#codeBlockLang || "text"}] ` +
19
+ "─".repeat(40 - (this.#codeBlockLang?.length || 4)),
20
+ )
21
+ }
22
+
23
+ if (this.#inCodeBlock) {
24
+ return chalk.cyan(`│ ${line}`)
25
+ }
26
+
27
+ if (line.startsWith("#")) {
28
+ const match = line.match(/^(#{1,6})\s+(.*)$/)
29
+ if (match?.[1] && match[2]) {
30
+ const level = match[1].length
31
+ const content = match[2]
32
+ if (level === 1) return chalk.bold.magenta.underline(content)
33
+ if (level === 2) return chalk.bold.blue(content)
34
+ return chalk.bold.cyan(content)
35
+ }
36
+ }
37
+
38
+ let formatted = line
39
+ if (formatted.startsWith("- ") || formatted.startsWith("* ")) {
40
+ formatted = ` ${chalk.yellow("•")} ${formatted.slice(2)}`
41
+ }
42
+
43
+ formatted = formatted.replace(/`([^`]+)`/g, (_, code) => chalk.yellow(code))
44
+ formatted = formatted.replace(/\*\*([^*]+)\*\*/g, (_, bold) => chalk.bold(bold))
45
+ formatted = formatted.replace(/__([^_]+)__/g, (_, bold) => chalk.bold(bold))
46
+ formatted = formatted.replace(/\*([^*]+)\*/g, (_, italic) => chalk.italic(italic))
47
+ formatted = formatted.replace(/_([^_]+)_/g, (_, italic) => chalk.italic(italic))
48
+ formatted = formatted.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) => {
49
+ return `${chalk.blue(text)} ${chalk.dim(`(${url})`)}`
50
+ })
51
+
52
+ return formatted
53
+ }
54
+ }
55
+
56
+ export function formatMarkdown(text: string): string {
57
+ const renderer = new MarkdownRenderer()
58
+ return text
59
+ .split("\n")
60
+ .map((line) => renderer.renderLine(line))
61
+ .join("\n")
62
+ }
@@ -0,0 +1,75 @@
1
+ import chalk from "chalk"
2
+ import type { Agent } from "../agent/agent.ts"
3
+ import type { Msg } from "../types.ts"
4
+ import { formatToolArgs } from "../util.ts"
5
+ import { MarkdownRenderer } from "./markdown.ts"
6
+
7
+ const TOOL_STYLE: Record<string, (s: string) => string> = {
8
+ read: (s) => chalk.blue.bold(s),
9
+ write: (s) => chalk.magenta.bold(s),
10
+ edit: (s) => chalk.yellow.bold(s),
11
+ bash: (s) => chalk.cyan.bold(s),
12
+ glob: (s) => chalk.green.bold(s),
13
+ find: (s) => chalk.green.bold(s),
14
+ grep: (s) => chalk.green.bold(s),
15
+ }
16
+
17
+ function stylizeTool(name: string): string {
18
+ const stylize = TOOL_STYLE[name] || ((s) => chalk.white.bold(s))
19
+ return stylize(name)
20
+ }
21
+
22
+ export async function runPrintMode(
23
+ agent: Agent,
24
+ prompt: string,
25
+ signal?: AbortSignal,
26
+ ): Promise<Msg[] | undefined> {
27
+ const stream = agent.prompt(prompt, signal)
28
+ let output = ""
29
+ let lastEventWasTool = false
30
+
31
+ const renderer = new MarkdownRenderer()
32
+ let lineBuffer = ""
33
+
34
+ for await (const event of stream) {
35
+ if (signal?.aborted) break
36
+ if (event.type === "text_delta") {
37
+ output += event.text
38
+ lineBuffer += event.text
39
+
40
+ if (lineBuffer.includes("\n")) {
41
+ const lines = lineBuffer.split("\n")
42
+ lineBuffer = lines.pop() ?? ""
43
+ for (const line of lines) {
44
+ process.stdout.write(`${renderer.renderLine(line)}\n`)
45
+ }
46
+ }
47
+ lastEventWasTool = false
48
+ }
49
+ if (event.type === "tool_call") {
50
+ const argsObj = event.call.args
51
+ const argsStr = argsObj ? ` ${formatToolArgs(argsObj, true)}` : ""
52
+ if (!lastEventWasTool) {
53
+ process.stderr.write("\n")
54
+ }
55
+ process.stderr.write(`⏳ ${stylizeTool(event.call.name)}${argsStr}… `)
56
+ lastEventWasTool = true
57
+ }
58
+ if (event.type === "tool_result") {
59
+ const status = event.result.isError ? chalk.red("✗") : chalk.green("✓")
60
+ const argsObj = event.result.args
61
+ const argsStr = argsObj ? ` ${formatToolArgs(argsObj, true)}` : ""
62
+ process.stderr.write(`\r${status} ${stylizeTool(event.result.tool)}${argsStr}\x1B[K\n`)
63
+ }
64
+ }
65
+
66
+ if (lineBuffer) {
67
+ process.stdout.write(renderer.renderLine(lineBuffer))
68
+ }
69
+
70
+ if (output && !output.endsWith("\n")) {
71
+ process.stdout.write("\n")
72
+ }
73
+
74
+ return stream.result
75
+ }