diff --git a/packages/kiana-v6/AGENTS.md b/packages/kiana-v6/AGENTS.md new file mode 100644 index 00000000000..509feb53a1f --- /dev/null +++ b/packages/kiana-v6/AGENTS.md @@ -0,0 +1,4 @@ +## Kiana Project + + +- Use `bun` as a package manager diff --git a/packages/kiana-v6/CLAUDE.md b/packages/kiana-v6/CLAUDE.md new file mode 120000 index 00000000000..47dc3e3d863 --- /dev/null +++ b/packages/kiana-v6/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/packages/kiana-v6/kiana.jsonc b/packages/kiana-v6/kiana.jsonc.template similarity index 100% rename from packages/kiana-v6/kiana.jsonc rename to packages/kiana-v6/kiana.jsonc.template diff --git a/packages/kiana-v6/src/agent.ts b/packages/kiana-v6/src/agent.ts index aa9efec5905..bef732ea53a 100644 --- a/packages/kiana-v6/src/agent.ts +++ b/packages/kiana-v6/src/agent.ts @@ -130,6 +130,9 @@ export class CodingAgent { private abortController: AbortController private streamCallbacks: Set = new Set() private mcpInitialized = false + private isAborted = false + private currentAssistantMessageID: string | null = null + private currentAssistantParts: Part[] | null = null constructor(config: CodingAgentConfig) { this.config = config @@ -244,6 +247,9 @@ export class CodingAgent { * Generate a response (non-streaming) - still emits stream parts for compatibility */ async generate(params: GenerateParams): Promise<{ text: string; usage?: any }> { + // Reset abort flag for new generation + this.isAborted = false + // Initialize MCP servers if configured await this.initializeMCP() @@ -270,6 +276,10 @@ export class CodingAgent { data: { sessionID: this.id }, }) + // Clear current message tracking + this.currentAssistantMessageID = null + this.currentAssistantParts = null + this.saveSession() return { @@ -285,6 +295,9 @@ export class CodingAgent { textStream: AsyncIterable text: Promise }> { + // Reset abort flag for new stream + this.isAborted = false + // Initialize MCP servers if configured await this.initializeMCP() @@ -387,6 +400,10 @@ export class CodingAgent { data: { sessionID: self.id }, }) + // Clear current message tracking + self.currentAssistantMessageID = null + self.currentAssistantParts = null + self.saveSession() return text }) @@ -401,9 +418,30 @@ export class CodingAgent { * Abort the current generation */ abort(): void { + this.isAborted = true this.abortController.abort() this.abortController = new AbortController() + // Add interruption marker to current message if there is one + if (this.currentAssistantParts && this.currentAssistantMessageID) { + const interruptionPart: TextPart = { + id: generateId("part"), + sessionID: this.id, + messageID: this.currentAssistantMessageID, + type: "text", + text: "\n\n[Interrupted by user]", + synthetic: true, + } + this.currentAssistantParts.push(interruptionPart) + + // Save the session with the interruption marker + this.saveSession() + + // Clear current message tracking + this.currentAssistantMessageID = null + this.currentAssistantParts = null + } + this.emit({ type: "data-session-idle", data: { sessionID: this.id }, @@ -500,6 +538,10 @@ export class CodingAgent { const assistantParts: Part[] = [] this.messages.push({ info: assistantMessage, parts: assistantParts }) + // Track current assistant message for interruption handling + this.currentAssistantMessageID = assistantMessageID + this.currentAssistantParts = assistantParts + // Build v6 tools const aiTools = this.buildAITools(assistantMessageID, assistantParts) @@ -532,7 +574,11 @@ export class CodingAgent { model: this.config.model, instructions, tools: aiTools, - stopWhen: stepCountIs(this.config.maxSteps ?? 50), + stopWhen: [ + stepCountIs(this.config.maxSteps ?? 50), + // Stop when abort is triggered (after current step completes) + () => self.isAborted, + ], maxRetries: this.config.maxRetries ?? 3, providerOptions, onStepFinish: (step) => { diff --git a/packages/kiana-v6/src/cli.ts b/packages/kiana-v6/src/cli.ts index 1f31dfc85a6..eac776d6159 100644 --- a/packages/kiana-v6/src/cli.ts +++ b/packages/kiana-v6/src/cli.ts @@ -17,7 +17,7 @@ import * as fs from "node:fs" import * as readline from "node:readline" -import { loadConfig, writeConfigTemplate, normalizeConfig, getAvailableModels, type NormalizedConfig } from "./config.js" +import { loadConfig, loadGlobalConfig, writeConfigTemplate, saveGlobalConfig, GLOBAL_CONFIG_PATH, normalizeConfig, getAvailableModels, type Config, type NormalizedConfig } from "./config.js" import { createLanguageModel } from "./provider.js" import { CodingAgent, formatSSE, type StreamPart } from "./agent.js" import { InteractiveInput } from "./interactive.js" @@ -65,7 +65,9 @@ interface Args { session?: string log?: string createConfig?: boolean + createGlobalConfig?: boolean listModels?: boolean + listGlobalModels?: boolean interactive: boolean humanReadable: boolean verbose: boolean @@ -109,6 +111,15 @@ function parseArgs(argv: string[]): Args { case "--create-config": args.createConfig = true break + case "--create-global-config": + args.createGlobalConfig = true + break + case "--list-models": + args.listModels = true + break + case "--list-global-models": + args.listGlobalModels = true + break case "-i": case "--interactive": args.interactive = true @@ -137,18 +148,20 @@ Kiana v6 - Minimal Headless Coding Agent (AI SDK UI Stream Protocol) Usage: kiana-v6 [options] -Options: - --config, -c Path to config file (default: ./kiana.jsonc) - --model, -m Select model from config (e.g., sonnet, grok) - --list-models List available models in config - --prompt, -p Send a single prompt and exit - --interactive, -i Interactive REPL mode (Enter sends, Ctrl+J newline) - --session, -s Session directory for persistence - --log, -l Log all events to file (JSONL format) - --create-config Generate a template config file - -H Human-readable output (instead of SSE) - -v, --verbose Show verbose output (tool inputs/outputs) - --help, -h Show help + Options: + --config, -c Path to config file (default: ./kiana.jsonc) + --model, -m Select model from config (e.g., sonnet, grok) + --list-models List available models in config + --list-global-models List available models in global config + --prompt, -p Send a single prompt and exit + --interactive, -i Interactive REPL mode (Enter sends, Ctrl+J newline) + --session, -s Session directory for persistence + --log, -l Log all events to file (JSONL format) + --create-config Generate a template config file + --create-global-config Generate global config template + -H Human-readable output (instead of SSE) + -v, --verbose Show verbose output (tool inputs/outputs) + --help, -h Show help Examples: # Interactive mode with default model @@ -171,6 +184,16 @@ Interactive mode keybindings: Ctrl+J Insert newline ESC ESC Cancel current operation (double-tap within 2s) Ctrl+C Exit interactive mode + + Emacs-like editing: + Ctrl+A Move to beginning of line + Ctrl+E Move to end of line + Ctrl+B Move backward one character + Ctrl+F Move forward one character + Ctrl+U Delete from cursor to beginning of line + Ctrl+K Delete from cursor to end of line + Ctrl+W Delete word backwards + Arrow keys Left/Right cursor movement `) } @@ -489,6 +512,37 @@ async function main(): Promise { process.exit(0) } + if (args.createGlobalConfig) { + const template = writeConfigTemplate() + // Write to a temporary file and load it using loadConfig + const tempPath = `/tmp/kiana-global-config-template-${Date.now()}.jsonc` + fs.writeFileSync(tempPath, template, 'utf-8') + const config = loadConfig(tempPath) + fs.unlinkSync(tempPath) + saveGlobalConfig(config) + console.log(`Global config created at ${GLOBAL_CONFIG_PATH}`) + process.exit(0) + } + + if (args.listGlobalModels) { + const globalConfig = loadGlobalConfig() + if (!globalConfig) { + console.log("No global config found") + process.exit(0) + } + const availableModels = getAvailableModels(globalConfig) + if (availableModels.length === 0) { + console.log("No models configured in global config") + } else { + console.log("Global config models:") + for (const modelName of availableModels) { + const defaultMarker = ("defaultModel" in globalConfig && globalConfig.defaultModel === modelName) ? " (default)" : "" + console.log(` - ${modelName}${defaultMarker}`) + } + } + process.exit(0) + } + if (args.createConfig) { console.log(writeConfigTemplate()) process.exit(0) @@ -496,21 +550,31 @@ async function main(): Promise { // Default to ./kiana.jsonc if no config specified const configPath = args.config || "./kiana.jsonc" - - if (!fs.existsSync(configPath)) { - if (args.config) { - console.error(`Error: Config file not found: ${configPath}`) - } else { - console.error("Error: No config file found. Either:") - console.error(" - Create ./kiana.jsonc in the current directory") - console.error(" - Specify a config file with --config ") - console.error(" - Generate a template with --create-config > kiana.jsonc") + const hasLocalConfig = fs.existsSync(configPath) + + // Check if we can proceed: + // 1. Local config exists, OR + // 2. No explicit --config flag AND global config exists + if (!hasLocalConfig) { + const globalConfig = loadGlobalConfig() + if (args.config || !globalConfig) { + // User explicitly specified a config that doesn't exist, OR + // No local config and no global config + if (args.config) { + console.error(`Error: Config file not found: ${configPath}`) + } else { + console.error("Error: No config file found. Either:") + console.error(" - Create ./kiana.jsonc in the current directory") + console.error(" - Create a global config with --create-global-config") + console.error(" - Specify a config file with --config ") + console.error(" - Generate a template with --create-config > kiana.jsonc") + } + process.exit(1) } - process.exit(1) } - // Load config - const rawConfig = loadConfig(configPath) + // Load config (will use global config as fallback if local doesn't exist) + const rawConfig = hasLocalConfig ? loadConfig(configPath) : loadGlobalConfig()! // Handle --list-models if (args.listModels) { diff --git a/packages/kiana-v6/src/config.ts b/packages/kiana-v6/src/config.ts index b971612c079..e4c1548229c 100644 --- a/packages/kiana-v6/src/config.ts +++ b/packages/kiana-v6/src/config.ts @@ -1,8 +1,9 @@ import { z } from "zod" -import { readFileSync, writeFileSync, existsSync } from "node:fs" +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs" import { parse as parseJsonc } from "jsonc-parser" import { config as dotenvConfig } from "dotenv" import { resolve, dirname } from "node:path" +import { homedir } from "node:os" // Load .env file (searches current dir and parent dirs) let envLoaded = false @@ -27,6 +28,13 @@ function loadEnvFile(configPath?: string): void { return } + // Try loading from home directory + const homeEnvPath = resolve(homedir(), ".env") + if (existsSync(homeEnvPath)) { + dotenvConfig({ path: homeEnvPath }) + return + } + // Fallback to default dotenv behavior (searches up from cwd) dotenvConfig() } @@ -70,6 +78,57 @@ function resolveEnvVarsInObject(obj: unknown): unknown { return obj } +const GLOBAL_CONFIG_DIR = `${homedir()}/.kiana` +export const GLOBAL_CONFIG_PATH = `${GLOBAL_CONFIG_DIR}/config.json` + +/** + * Deep merge two objects, with source taking precedence. + * Arrays are replaced, not merged. + */ +function deepMerge(target: any, source: any): any { + if (source == null || typeof source !== 'object') return source + if (target == null || typeof target !== 'object') return source + if (Array.isArray(source)) return source + const result = { ...target } + for (const key in source) { + if (source.hasOwnProperty(key)) { + result[key] = deepMerge(target[key], source[key]) + } + } + return result +} + +/** + * Load global config from ~/.kiana/config.json if it exists. + */ +export function loadGlobalConfig(): Config | null { + if (!existsSync(GLOBAL_CONFIG_PATH)) return null + try { + return loadConfigFromFile(GLOBAL_CONFIG_PATH) + } catch (e) { + // Global config is optional, ignore errors + return null + } +} + +/** + * Save global config to ~/.kiana/config.json. + */ +export function saveGlobalConfig(config: Config): void { + // Ensure directory exists + mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true }) + // Write as formatted JSON + const json = JSON.stringify(config, null, 2) + writeFileSync(GLOBAL_CONFIG_PATH, json, "utf-8") +} + +/** + * Merge global config as defaults with session config as overrides. + */ +function mergeConfigs(defaults: Config, overrides: Config): Config { + return deepMerge(defaults, overrides) as Config +} + export const ThinkingConfigSchema = z.object({ // Enable extended thinking (default: false) enabled: z.boolean().optional().default(false), @@ -228,7 +287,7 @@ function normalizeProviderBaseUrl(obj: Record): void { * Environment variables in format ${VAR} or ${VAR:-default} are resolved. * Loads .env file from config directory or current directory. */ -export function loadConfig(path: string): Config { +function loadConfigFromFile(path: string): Config { // Load .env file before resolving environment variables loadEnvFile(path) @@ -270,6 +329,16 @@ export function loadConfig(path: string): Config { return result.data } +/** + * Load config from file, merging with global config if available. + * Global config provides defaults, session config overrides. + */ +export function loadConfig(path: string): Config { + const globalConfig = loadGlobalConfig() + const sessionConfig = loadConfigFromFile(path) + return globalConfig ? mergeConfigs(globalConfig, sessionConfig) : sessionConfig +} + /** * Default system prompt for Kiana headless mode. */ diff --git a/packages/kiana-v6/src/index.ts b/packages/kiana-v6/src/index.ts index cbfdc17afaa..fc344a0a9e9 100644 --- a/packages/kiana-v6/src/index.ts +++ b/packages/kiana-v6/src/index.ts @@ -42,7 +42,7 @@ export type { } from "./stream.js" // Config -export { loadConfig, writeConfigTemplate, DEFAULT_SYSTEM_PROMPT, type Config, type MCPServerConfig } from "./config.js" +export { loadConfig, loadGlobalConfig, saveGlobalConfig, writeConfigTemplate, DEFAULT_SYSTEM_PROMPT, GLOBAL_CONFIG_PATH, type Config, type MCPServerConfig } from "./config.js" // Provider export { createLanguageModel } from "./provider.js" diff --git a/packages/kiana-v6/src/interactive.ts b/packages/kiana-v6/src/interactive.ts index fa00d311d6f..2c3bb58ba70 100644 --- a/packages/kiana-v6/src/interactive.ts +++ b/packages/kiana-v6/src/interactive.ts @@ -4,6 +4,7 @@ * Features: * - Multi-line input with Enter to send, Ctrl+J for newline * - ESC double-tap to abort (with 2s timeout) + * - Emacs-like keybindings (Ctrl+A/E/U/K/W/B/F) * - Simple prompt: `>` first line, `ยป` continuation */ @@ -40,6 +41,10 @@ export class InteractiveInput extends EventEmitter { private cursorCol: number = 0 private escTimeout?: NodeJS.Timeout private rl?: readline.Interface + // Track actual physical lines displayed on terminal (for proper redraw) + private displayedPhysicalLines: number = 1 + // Track which physical row the terminal cursor is currently on (0-indexed from start of input) + private terminalCursorRow: number = 0 private readonly ESC_TIMEOUT_MS = 2000 private readonly PROMPT_FIRST = "> " @@ -104,6 +109,8 @@ export class InteractiveInput extends EventEmitter { this.inputLines = [""] this.cursorLine = 0 this.cursorCol = 0 + this.displayedPhysicalLines = 1 + this.terminalCursorRow = 0 // Show prompt this.showPrompt() } @@ -191,7 +198,76 @@ export class InteractiveInput extends EventEmitter { this.inputLines.push("") this.cursorLine++ this.cursorCol = 0 - process.stdout.write(`\n${this.PROMPT_CONT}`) + // Use \r\n in raw mode to move to column 1 of next line + process.stdout.write(`\r\n${this.PROMPT_CONT}`) + this.displayedPhysicalLines++ + this.terminalCursorRow++ + return + } + + // Ctrl+A - move to beginning of line + if (key === "\x01") { + this.cursorCol = 0 + this.repositionCursor() + return + } + + // Ctrl+E - move to end of line + if (key === "\x05") { + this.cursorCol = this.inputLines[this.cursorLine].length + this.repositionCursor() + return + } + + // Ctrl+U - kill line from cursor to beginning + if (key === "\x15") { + const line = this.inputLines[this.cursorLine] + this.inputLines[this.cursorLine] = line.slice(this.cursorCol) + this.cursorCol = 0 + this.redrawCurrentLine() + return + } + + // Ctrl+K - kill line from cursor to end + if (key === "\x0b") { + const line = this.inputLines[this.cursorLine] + this.inputLines[this.cursorLine] = line.slice(0, this.cursorCol) + this.redrawCurrentLine() + return + } + + // Ctrl+W - delete word backwards + if (key === "\x17") { + const line = this.inputLines[this.cursorLine] + const before = line.slice(0, this.cursorCol) + const after = line.slice(this.cursorCol) + + // Find start of word (skip trailing spaces, then find word boundary) + let pos = before.length + while (pos > 0 && before[pos - 1] === " ") pos-- + while (pos > 0 && before[pos - 1] !== " ") pos-- + + this.inputLines[this.cursorLine] = before.slice(0, pos) + after + this.cursorCol = pos + this.redrawCurrentLine() + return + } + + // Ctrl+B - move cursor backward one character + if (key === "\x02") { + if (this.cursorCol > 0) { + this.cursorCol-- + this.repositionCursor() + } + return + } + + // Ctrl+F - move cursor forward one character + if (key === "\x06") { + if (this.cursorCol < this.inputLines[this.cursorLine].length) { + this.cursorCol++ + this.repositionCursor() + } return } @@ -239,7 +315,23 @@ export class InteractiveInput extends EventEmitter { // Arrow keys and other escape sequences if (key.startsWith("\x1b[")) { - // For now, ignore arrow keys (could implement cursor movement later) + // Left arrow + if (key === "\x1b[D") { + if (this.cursorCol > 0) { + this.cursorCol-- + this.repositionCursor() + } + return + } + // Right arrow + if (key === "\x1b[C") { + if (this.cursorCol < this.inputLines[this.cursorLine].length) { + this.cursorCol++ + this.repositionCursor() + } + return + } + // Up/down arrows - ignore for now (could implement line navigation) return } @@ -292,31 +384,81 @@ export class InteractiveInput extends EventEmitter { * Redraw all input lines. */ private redrawAllLines(): void { - // Move cursor to first line - for (let i = 0; i < this.cursorLine; i++) { + const termWidth = process.stdout.columns || 80 + + // Calculate NEW total physical lines needed (accounting for wrapping) + let newTotalPhysicalLines = 0 + for (let i = 0; i < this.inputLines.length; i++) { + const prompt = i === 0 ? this.PROMPT_FIRST : this.PROMPT_CONT + const lineLength = prompt.length + this.inputLines[i].length + newTotalPhysicalLines += Math.max(1, Math.ceil(lineLength / termWidth)) + } + + // Move cursor up from current terminal position to row 0 (top of input) + for (let i = 0; i < this.terminalCursorRow; i++) { + process.stdout.write(ANSI.cursorUp) + } + + // Clear all DISPLAYED physical lines (moving down from top) + for (let i = 0; i < this.displayedPhysicalLines; i++) { + process.stdout.write(`${ANSI.clearLine}${ANSI.cursorLeft}`) + if (i < this.displayedPhysicalLines - 1) { + process.stdout.write(ANSI.cursorDown) + } + } + + // Move back to the top + for (let i = 0; i < this.displayedPhysicalLines - 1; i++) { process.stdout.write(ANSI.cursorUp) } - // Redraw each line + // Now we're at row 0, column 1. Redraw each logical line. for (let i = 0; i < this.inputLines.length; i++) { const prompt = i === 0 ? this.PROMPT_FIRST : this.PROMPT_CONT - process.stdout.write(`${ANSI.clearLine}${ANSI.cursorLeft}${prompt}${this.inputLines[i]}`) + process.stdout.write(`${prompt}${this.inputLines[i]}`) if (i < this.inputLines.length - 1) { - process.stdout.write("\n") + // Use \r\n in raw mode: \n moves down, \r returns to column 1 + process.stdout.write("\r\n") } } - // Position cursor correctly - // First, go to the correct line - const linesFromEnd = this.inputLines.length - 1 - this.cursorLine - for (let i = 0; i < linesFromEnd; i++) { + // Update displayed physical lines count + this.displayedPhysicalLines = newTotalPhysicalLines + + // Calculate where terminal cursor is now (end of last line) + const lastPrompt = this.inputLines.length === 1 ? this.PROMPT_FIRST : this.PROMPT_CONT + const lastLineLen = lastPrompt.length + this.inputLines[this.inputLines.length - 1].length + let currentRow = 0 + for (let i = 0; i < this.inputLines.length - 1; i++) { + const p = i === 0 ? this.PROMPT_FIRST : this.PROMPT_CONT + const len = p.length + this.inputLines[i].length + currentRow += Math.max(1, Math.ceil(len / termWidth)) + } + currentRow += Math.floor(lastLineLen / termWidth) + + // Calculate target row and column for logical cursor + const targetPrompt = this.cursorLine === 0 ? this.PROMPT_FIRST : this.PROMPT_CONT + let targetRow = 0 + for (let i = 0; i < this.cursorLine; i++) { + const p = i === 0 ? this.PROMPT_FIRST : this.PROMPT_CONT + const len = p.length + this.inputLines[i].length + targetRow += Math.max(1, Math.ceil(len / termWidth)) + } + const cursorPosInLine = targetPrompt.length + this.cursorCol + targetRow += Math.floor(cursorPosInLine / termWidth) + const targetCol = cursorPosInLine % termWidth + + // Move from current row to target row + const rowDiff = currentRow - targetRow + for (let i = 0; i < rowDiff; i++) { process.stdout.write(ANSI.cursorUp) } - // Then position cursor column - const prompt = this.cursorLine === 0 ? this.PROMPT_FIRST : this.PROMPT_CONT - process.stdout.write(`${ANSI.cursorLeft}`) - process.stdout.write(`\x1b[${prompt.length + this.cursorCol + 1}G`) // Move to exact column + // Move to the correct column (1-indexed) + process.stdout.write(`\x1b[${targetCol + 1}G`) + + // Update terminal cursor row tracking + this.terminalCursorRow = targetRow } private handleStreamingKeypress(key: string): void { @@ -367,8 +509,46 @@ export class InteractiveInput extends EventEmitter { } private redrawCurrentLine(): void { - const prompt = this.cursorLine === 0 ? this.PROMPT_FIRST : this.PROMPT_CONT - const line = this.inputLines[this.cursorLine] - process.stdout.write(`${ANSI.clearLine}${ANSI.cursorLeft}${prompt}${line}`) + // For single-line editing, just use redrawAllLines for simplicity and correctness + // This handles all the edge cases with wrapping properly + this.redrawAllLines() + } + + /** + * Reposition cursor to the current cursorCol position. + * This updates both the terminal cursor and the terminalCursorRow tracking. + */ + private repositionCursor(): void { + const termWidth = process.stdout.columns || 80 + + // Calculate target row (from start of input) and column + const targetPrompt = this.cursorLine === 0 ? this.PROMPT_FIRST : this.PROMPT_CONT + let targetRow = 0 + for (let i = 0; i < this.cursorLine; i++) { + const p = i === 0 ? this.PROMPT_FIRST : this.PROMPT_CONT + const len = p.length + this.inputLines[i].length + targetRow += Math.max(1, Math.ceil(len / termWidth)) + } + const cursorPosInLine = targetPrompt.length + this.cursorCol + targetRow += Math.floor(cursorPosInLine / termWidth) + const targetCol = cursorPosInLine % termWidth + + // Move from current terminal row to target row + const rowDiff = this.terminalCursorRow - targetRow + if (rowDiff > 0) { + for (let i = 0; i < rowDiff; i++) { + process.stdout.write(ANSI.cursorUp) + } + } else if (rowDiff < 0) { + for (let i = 0; i < -rowDiff; i++) { + process.stdout.write(ANSI.cursorDown) + } + } + + // Move to the correct column (1-indexed) + process.stdout.write(`\x1b[${targetCol + 1}G`) + + // Update terminal cursor row tracking + this.terminalCursorRow = targetRow } } diff --git a/packages/kiana/src/session.ts b/packages/kiana/src/session.ts index e94e39c916b..8814e18d120 100644 --- a/packages/kiana/src/session.ts +++ b/packages/kiana/src/session.ts @@ -912,10 +912,10 @@ function buildAITools( const result = await toolDef.execute(args, ctx) return result }, - toModelOutput(result: ToolResult) { + toModelOutput({ output }: { toolCallId: string; input: unknown; output: ToolResult }) { return { - type: "text", - value: result.output, + type: "text" as const, + value: output.output, } }, })