diff --git a/agent/src/index.ts b/agent/src/index.ts index 6664230e..d16d1f28 100644 --- a/agent/src/index.ts +++ b/agent/src/index.ts @@ -54,6 +54,7 @@ const app = new Elysia() .listen({ port: config.agent_gateway.port ?? 8081, hostname: config.agent_gateway.host ?? "127.0.0.1", + idleTimeout: 255, // max allowed by Bun, to accommodate long-running tool calls }); console.log( diff --git a/agent/src/prompts/system.ts b/agent/src/prompts/system.ts index 05772a6e..c3caf6dc 100644 --- a/agent/src/prompts/system.ts +++ b/agent/src/prompts/system.ts @@ -43,6 +43,35 @@ ${Bun.YAML.stringify(headers)} --- You are an AI agent, and now you wake up. +${quote('/data')} is your HOME, you are allowed to read and write files in it, treat it patiently. + +## Basic Tools +- ${quote('read')}: read file content +- ${quote('write')}: write file content +- ${quote('list')}: list directory entries +- ${quote('edit')}: apply unified diff patch. Format: + +${block([ + '@@ -, +, @@', + '-old line', + '+new line', + '', + '@@ -3,1 +3,2 @@', + ' existing line 3', + '+added line after 3', + '', + '@@ -2,1 +2,0 @@', + '-deleted line', +].join('\n'))} + + Rules: + - Lines prefixed with ${quote(' ')} (space) are context (unchanged) lines + - Lines prefixed with ${quote('-')} are removed, ${quote('+')} are added + - ${quote('orig_count')} / ${quote('new_count')} must match the actual number of lines (context + removed / context + added) + - Multiple hunks allowed in one patch + +- ${quote('exec')}: execute command + ## Memory Your context is loaded from the recent of ${maxContextLoadTime} minutes (${(maxContextLoadTime / 60).toFixed(2)} hours). diff --git a/packages/cli/src/cli/bot.ts b/packages/cli/src/cli/bot.ts index bc735c7c..d42c5044 100644 --- a/packages/cli/src/cli/bot.ts +++ b/packages/cli/src/cli/bot.ts @@ -5,11 +5,10 @@ import ora from 'ora' import { table } from 'table' import readline from 'node:readline/promises' import { stdin as input, stdout as output } from 'node:process' -import { randomUUID } from 'node:crypto' - import { apiRequest } from '../core/api' -import { readConfig, getBaseURL, TokenInfo } from '../utils/store' +import { readConfig, TokenInfo } from '../utils/store' import { ensureAuth, getErrorMessage, resolveBotId, BotSummary } from './shared' +import { streamChat } from './stream' type Bot = BotSummary & { metadata?: Record @@ -60,91 +59,6 @@ const renderBotsTable = (items: BotSummary[]) => { return table(rows) } -const streamChat = async (query: string, botId: string, sessionId: string, token: TokenInfo) => { - const config = readConfig() - const baseURL = getBaseURL(config) - const resp = await fetch(`${baseURL}/bots/${botId}/chat/stream?session_id=${encodeURIComponent(sessionId)}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token.access_token}`, - }, - body: JSON.stringify({ query }), - }).catch(() => null) - if (!resp || !resp.ok || !resp.body) return false - - const stream = resp.body - const reader = stream.getReader() - const decoder = new TextDecoder() - let buffer = '' - let printed = false - while (true) { - const { value, done } = await reader.read() - if (done) break - buffer += decoder.decode(value, { stream: true }) - let idx - while ((idx = buffer.indexOf('\n')) >= 0) { - const line = buffer.slice(0, idx).trim() - buffer = buffer.slice(idx + 1) - if (!line.startsWith('data:')) continue - const payload = line.slice(5).trim() - if (!payload || payload === '[DONE]') continue - const text = extractTextFromEvent(payload) - if (text) { - process.stdout.write(text) - printed = true - } - } - } - if (printed) { - process.stdout.write('\n') - } - return true -} - -const extractTextFromMessage = (message: unknown) => { - if (typeof message === 'string') return message - if (message && typeof message === 'object') { - const value = message as { text?: unknown; parts?: unknown[] } - if (typeof value.text === 'string') return value.text - if (Array.isArray(value.parts)) { - const lines = value.parts - .map((part) => { - if (!part || typeof part !== 'object') return '' - const typed = part as { text?: unknown; url?: unknown; emoji?: unknown } - if (typeof typed.text === 'string' && typed.text.trim()) return typed.text - if (typeof typed.url === 'string' && typed.url.trim()) return typed.url - if (typeof typed.emoji === 'string' && typed.emoji.trim()) return typed.emoji - return '' - }) - .filter(Boolean) - if (lines.length) return lines.join('\n') - } - } - return null -} - -const extractTextFromEvent = (payload: string) => { - try { - const event = JSON.parse(payload) - if (typeof event === 'string') return event - if (typeof event?.text === 'string') return event.text - const messageText = extractTextFromMessage(event?.message) - if (messageText) return messageText - if (typeof event?.delta === 'string') return event.delta - if (typeof event?.delta?.content === 'string') return event.delta.content - if (typeof event?.content === 'string') return event.content - if (typeof event?.data === 'string') return event.data - if (typeof event?.data?.text === 'string') return event.data.text - if (typeof event?.data?.delta?.content === 'string') return event.data.delta.content - const nestedMessageText = extractTextFromMessage(event?.data?.message) - if (nestedMessageText) return nestedMessageText - return null - } catch { - return payload - } -} - export const registerBotCommands = (program: Command) => { const bot = program.command('bot').description('Bot management') @@ -297,7 +211,8 @@ export const registerBotCommands = (program: Command) => { await ensureModelsReady() const token = ensureAuth() const botId = await resolveBotId(token, id) - const sessionId = String(opts.session || `cli:${randomUUID()}`) + const config = readConfig() + const sessionId = String(opts.session || config.session_id) const rl = readline.createInterface({ input, output }) console.log(chalk.green(`Chatting with ${chalk.bold(botId)} (session ${sessionId}). Type \`exit\` to quit.`)) while (true) { diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts index 9912d51c..4429f55d 100755 --- a/packages/cli/src/cli/index.ts +++ b/packages/cli/src/cli/index.ts @@ -6,12 +6,12 @@ import ora from 'ora' import { table } from 'table' import readline from 'node:readline/promises' import { stdin as input, stdout as output } from 'node:process' -import { randomUUID } from 'node:crypto' import packageJson from '../../package.json' import { apiRequest } from '../core/api' import { registerBotCommands } from './bot' import { registerChannelCommands } from './channel' +import { streamChat } from './stream' import { readConfig, writeConfig, @@ -710,7 +710,8 @@ program await ensureModelsReady() const token = ensureAuth() const botId = await resolveBotId(token, program.opts().bot) - const sessionId = `cli:${randomUUID()}` + const config = readConfig() + const sessionId = config.session_id const rl = readline.createInterface({ input, output }) console.log(chalk.green(`Chatting with ${chalk.bold(botId)}. Type \`exit\` to quit.`)) @@ -756,93 +757,11 @@ program program.parseAsync(process.argv) -const streamChat = async (query: string, botId: string, sessionId: string, token: TokenInfo) => { - const config = readConfig() - const baseURL = getBaseURL(config) - const resp = await fetch(`${baseURL}/bots/${botId}/chat/stream?session_id=${sessionId}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token.access_token}`, - }, - body: JSON.stringify({ query }), - }).catch(() => null) - if (!resp || !resp.ok || !resp.body) return false - - const stream = resp.body - const reader = stream.getReader() - const decoder = new TextDecoder() - let buffer = '' - let printed = false - while (true) { - const { value, done } = await reader.read() - if (done) break - buffer += decoder.decode(value, { stream: true }) - let idx - while ((idx = buffer.indexOf('\n')) >= 0) { - const line = buffer.slice(0, idx).trim() - buffer = buffer.slice(idx + 1) - if (!line.startsWith('data:')) continue - const payload = line.slice(5).trim() - if (!payload || payload === '[DONE]') continue - const text = extractTextFromEvent(payload) - if (text) { - process.stdout.write(text) - printed = true - } - } - } - if (printed) { - process.stdout.write('\n') - } - return true -} - -const extractTextFromMessage = (message: unknown) => { - if (typeof message === 'string') return message - if (message && typeof message === 'object') { - const value = message as { text?: unknown; parts?: unknown[] } - if (typeof value.text === 'string') return value.text - if (Array.isArray(value.parts)) { - const lines = value.parts - .map((part) => { - if (!part || typeof part !== 'object') return '' - const typed = part as { text?: unknown; url?: unknown; emoji?: unknown } - if (typeof typed.text === 'string' && typed.text.trim()) return typed.text - if (typeof typed.url === 'string' && typed.url.trim()) return typed.url - if (typeof typed.emoji === 'string' && typed.emoji.trim()) return typed.emoji - return '' - }) - .filter(Boolean) - if (lines.length) return lines.join('\n') - } - } - return null -} - -const extractTextFromEvent = (payload: string) => { - try { - const event = JSON.parse(payload) - if (typeof event === 'string') return event - if (typeof event?.text === 'string') return event.text - const messageText = extractTextFromMessage(event?.message) - if (messageText) return messageText - if (typeof event?.delta === 'string') return event.delta - if (typeof event?.delta?.content === 'string') return event.delta.content - if (typeof event?.content === 'string') return event.content - if (typeof event?.data === 'string') return event.data - if (typeof event?.data?.text === 'string') return event.data.text - if (typeof event?.data?.delta?.content === 'string') return event.data.delta.content - const nestedMessageText = extractTextFromMessage(event?.data?.message) - if (nestedMessageText) return nestedMessageText - return null - } catch { - return payload - } -} +// streamChat is imported from ./stream const runTui = async (botId: string, token: TokenInfo) => { - const sessionId = `cli:${randomUUID()}` + const config = readConfig() + const sessionId = config.session_id const rl = readline.createInterface({ input, output }) console.log(chalk.green(`TUI session (line mode) with ${chalk.bold(botId)}. Type \`exit\` to quit.`)) diff --git a/packages/cli/src/cli/stream.ts b/packages/cli/src/cli/stream.ts new file mode 100644 index 00000000..25080312 --- /dev/null +++ b/packages/cli/src/cli/stream.ts @@ -0,0 +1,388 @@ +import chalk from 'chalk' +import { readConfig, getBaseURL, TokenInfo } from '../utils/store' + +// --------------------------------------------------------------------------- +// Tool display configuration +// --------------------------------------------------------------------------- + +type ToolDisplayMode = 'inline' | 'expanded' + +interface ToolDisplayConfig { + mode: ToolDisplayMode + /** For expanded mode: which parameter to show as detail content */ + expandParam?: string + /** Label shown in the display header */ + label?: string +} + +/** + * Tools listed here will be displayed in "expanded" mode with a box showing + * the specified parameter content. Everything else defaults to single-line. + * exec uses a custom single-line format instead of the box. + */ +const TOOL_DISPLAY: Record = { + exec: { mode: 'expanded', label: 'exec' }, + write: { mode: 'expanded', expandParam: 'content', label: 'write' }, + edit: { mode: 'expanded', expandParam: 'patch', label: 'edit' }, +} + +const getToolDisplay = (toolName: string): ToolDisplayConfig => { + return TOOL_DISPLAY[toolName] ?? { mode: 'inline' } +} + +// --------------------------------------------------------------------------- +// Tool call formatting helpers +// --------------------------------------------------------------------------- + +const BOX_WIDTH = 60 + +// --------------------------------------------------------------------------- +// exec-specific helpers +// --------------------------------------------------------------------------- + +/** Extract the actual shell command from exec input like { command: "bash", args: ["-lc", "echo hi"] } */ +const extractExecCommand = (toolInput: unknown): string => { + if (!toolInput || typeof toolInput !== 'object') return '' + const input = toolInput as Record + const command = typeof input.command === 'string' ? input.command : '' + const args = Array.isArray(input.args) ? input.args.map(String) : [] + // If shell + -c/-lc flag, extract the actual script + if (/^(bash|sh|zsh)$/.test(command) && args.length >= 2) { + const flag = args[0] + if (flag === '-c' || flag === '-lc') { + return args.slice(1).join(' ') + } + } + return [command, ...args].filter(Boolean).join(' ') +} + +const formatExecCall = (toolInput: unknown) => { + const cmd = extractExecCommand(toolInput) + return chalk.dim(' ▶ ') + chalk.white('$ ') + chalk.bold.white(cmd) +} + +/** Try to unwrap MCP content-block results into a plain object */ +const unwrapToolResult = (result: unknown): Record | null => { + if (!result) return null + + // Helper to extract from MCP content blocks array + const extractFromContentBlocks = (arr: unknown[]): Record | null => { + for (const block of arr) { + if (block && typeof block === 'object') { + const b = block as Record + if (b.type === 'text' && typeof b.text === 'string') { + try { return JSON.parse(b.text) } catch { /* ignore */ } + } + } + } + return null + } + + // MCP content array: [{ type: "text", text: "{...}" }] + if (Array.isArray(result)) { + return extractFromContentBlocks(result) + } + + // Object - might be MCP wrapper { content: [...] } or direct result + if (typeof result === 'object') { + const obj = result as Record + // MCP wrapper: { content: [{ type: "text", text: "{...}" }], isError: ... } + if (Array.isArray(obj.content)) { + const extracted = extractFromContentBlocks(obj.content) + if (extracted) return extracted + } + // Direct object with known result fields + return obj + } + + // JSON string + if (typeof result === 'string') { + try { return JSON.parse(result) } catch { /* ignore */ } + } + return null +} + +const formatExecResult = (result: unknown) => { + const r = unwrapToolResult(result) + if (!r) return chalk.dim(' ╰─ done') + + const exitCode = typeof r.exit_code === 'number' ? r.exit_code : (r.ok ? 0 : 1) + const ok = exitCode === 0 + const stdout = typeof r.stdout === 'string' ? r.stdout.trim() : '' + const stderr = typeof r.stderr === 'string' ? r.stderr.trim() : '' + + const lines: string[] = [] + lines.push(chalk.dim(' ╰─ ') + (ok ? chalk.green(`✓ exit ${exitCode}`) : chalk.red(`✗ exit ${exitCode}`))) + + const output = ok ? stdout : (stderr || stdout) + if (output) { + const outputLines = output.split('\n') + const maxLines = 8 + const shown = outputLines.slice(0, maxLines) + for (const ol of shown) { + const truncated = ol.length > 72 ? ol.slice(0, 69) + '...' : ol + lines.push(chalk.dim(' ') + (ok ? chalk.white(truncated) : chalk.yellow(truncated))) + } + if (outputLines.length > maxLines) { + lines.push(chalk.dim(` ... (${outputLines.length - maxLines} more lines)`)) + } + } + return lines.join('\n') +} + +// --------------------------------------------------------------------------- +// Generic tool formatting +// --------------------------------------------------------------------------- + +const formatToolCallInline = (toolName: string, toolInput: unknown) => { + let params = '' + if (toolInput && typeof toolInput === 'object') { + const entries = Object.entries(toolInput as Record) + params = entries + .map(([k, v]) => { + const s = typeof v === 'string' ? v : JSON.stringify(v) + const short = s.length > 40 ? s.slice(0, 37) + '...' : s + return `${k}=${short}` + }) + .join(', ') + } + return chalk.dim(` ◆ ${toolName}`) + (params ? chalk.dim(`(${params})`) : '') +} + +const formatToolCallExpanded = (config: ToolDisplayConfig, toolName: string, toolInput: unknown) => { + const label = config.label ?? toolName + const inputObj = (toolInput && typeof toolInput === 'object' ? toolInput : {}) as Record + + const summaryParts: string[] = [] + for (const [k, v] of Object.entries(inputObj)) { + if (k === config.expandParam) continue + const s = typeof v === 'string' ? v : JSON.stringify(v) + summaryParts.push(`${k}: ${s.length > 50 ? s.slice(0, 47) + '...' : s}`) + } + const summary = summaryParts.length ? ' ' + summaryParts.join(', ') : '' + + let detail = '' + if (config.expandParam && config.expandParam in inputObj) { + const raw = inputObj[config.expandParam] + if (typeof raw === 'string') { + detail = raw + } else if (Array.isArray(raw)) { + detail = raw.join(' ') + } else { + detail = JSON.stringify(raw, null, 2) + } + } + + const topBorder = '┌' + '─'.repeat(BOX_WIDTH - 2) + '┐' + const botBorder = '└' + '─'.repeat(BOX_WIDTH - 2) + '┘' + + const lines: string[] = [] + lines.push(chalk.cyan(topBorder)) + lines.push(chalk.cyan('│ ') + chalk.bold.white(label) + chalk.gray(summary)) + + if (detail) { + lines.push(chalk.cyan('│ ') + chalk.dim('─'.repeat(BOX_WIDTH - 4))) + const detailLines = detail.split('\n') + const maxLines = 20 + const shown = detailLines.slice(0, maxLines) + for (const dl of shown) { + const truncated = dl.length > BOX_WIDTH - 4 ? dl.slice(0, BOX_WIDTH - 7) + '...' : dl + lines.push(chalk.cyan('│ ') + chalk.white(truncated)) + } + if (detailLines.length > maxLines) { + lines.push(chalk.cyan('│ ') + chalk.dim(`... (${detailLines.length - maxLines} more lines)`)) + } + } + + lines.push(chalk.cyan(botBorder)) + return lines.join('\n') +} + +const formatToolResult = (toolName: string, result: unknown) => { + // exec has its own result formatter + if (toolName === 'exec') { + return formatExecResult(result) + } + const config = getToolDisplay(toolName) + if (config.mode === 'expanded') { + const r = unwrapToolResult(result) + if (r) { + if ('ok' in r) { + return chalk.dim(` ╰─ `) + (r.ok ? chalk.green('✓ ok') : chalk.red('✗ failed')) + } + } + return chalk.dim(` ╰─ done`) + } + return null +} + +// --------------------------------------------------------------------------- +// Text extraction helpers (fallback for unknown event formats) +// --------------------------------------------------------------------------- + +const extractTextFromMessage = (message: unknown) => { + if (typeof message === 'string') return message + if (message && typeof message === 'object') { + const value = message as { text?: unknown; parts?: unknown[] } + if (typeof value.text === 'string') return value.text + if (Array.isArray(value.parts)) { + const lines = value.parts + .map((part) => { + if (!part || typeof part !== 'object') return '' + const typed = part as { text?: unknown; url?: unknown; emoji?: unknown } + if (typeof typed.text === 'string' && typed.text.trim()) return typed.text + if (typeof typed.url === 'string' && typed.url.trim()) return typed.url + if (typeof typed.emoji === 'string' && typed.emoji.trim()) return typed.emoji + return '' + }) + .filter(Boolean) + if (lines.length) return lines.join('\n') + } + } + return null +} + +const extractTextFromEvent = (payload: string) => { + try { + const event = JSON.parse(payload) + if (typeof event === 'string') return event + if (typeof event?.text === 'string') return event.text + const messageText = extractTextFromMessage(event?.message) + if (messageText) return messageText + if (typeof event?.delta === 'string') return event.delta + if (typeof event?.delta?.content === 'string') return event.delta.content + if (typeof event?.content === 'string') return event.content + if (typeof event?.data === 'string') return event.data + if (typeof event?.data?.text === 'string') return event.data.text + if (typeof event?.data?.delta?.content === 'string') return event.data.delta.content + const nestedMessageText = extractTextFromMessage(event?.data?.message) + if (nestedMessageText) return nestedMessageText + return null + } catch { + return payload + } +} + +// --------------------------------------------------------------------------- +// Stream chat +// --------------------------------------------------------------------------- + +export const streamChat = async (query: string, botId: string, sessionId: string, token: TokenInfo) => { + const config = readConfig() + const baseURL = getBaseURL(config) + const resp = await fetch(`${baseURL}/bots/${botId}/chat/stream?session_id=${encodeURIComponent(sessionId)}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token.access_token}`, + }, + body: JSON.stringify({ query }), + }).catch(() => null) + if (!resp || !resp.ok || !resp.body) return false + + const stream = resp.body + const reader = stream.getReader() + const decoder = new TextDecoder() + let buffer = '' + let printedText = false + + while (true) { + const { value, done } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + let idx + while ((idx = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, idx).trim() + buffer = buffer.slice(idx + 1) + if (!line.startsWith('data:')) continue + const payload = line.slice(5).trim() + if (!payload || payload === '[DONE]') continue + + let event: Record + try { + const parsed = JSON.parse(payload) + if (typeof parsed === 'string') { + process.stdout.write(parsed) + printedText = true + continue + } + event = parsed + } catch { + process.stdout.write(payload) + printedText = true + continue + } + + const eventType = event.type as string | undefined + + switch (eventType) { + case 'text_start': + break + + case 'text_delta': + if (typeof event.delta === 'string') { + process.stdout.write(event.delta) + printedText = true + } + break + + case 'text_end': + if (printedText) { + process.stdout.write('\n') + printedText = false + } + break + + case 'tool_call_start': { + if (printedText) { + process.stdout.write('\n') + printedText = false + } + const toolName = event.toolName as string + const toolInput = event.input + if (toolName === 'exec') { + console.log(formatExecCall(toolInput)) + } else { + const displayConfig = getToolDisplay(toolName) + if (displayConfig.mode === 'expanded') { + console.log(formatToolCallExpanded(displayConfig, toolName, toolInput)) + } else { + console.log(formatToolCallInline(toolName, toolInput)) + } + } + break + } + + case 'tool_call_end': { + const toolName = event.toolName as string + const result = event.result + const resultLine = formatToolResult(toolName, result) + if (resultLine) { + console.log(resultLine) + } + break + } + + case 'reasoning_start': + case 'reasoning_delta': + case 'reasoning_end': + case 'agent_start': + case 'agent_end': + break + + default: { + const text = extractTextFromEvent(payload) + if (text) { + process.stdout.write(text) + printedText = true + } + break + } + } + } + } + if (printedText) { + process.stdout.write('\n') + } + return true +} diff --git a/packages/cli/src/utils/store.ts b/packages/cli/src/utils/store.ts index 7bf277b6..71200344 100644 --- a/packages/cli/src/utils/store.ts +++ b/packages/cli/src/utils/store.ts @@ -1,10 +1,12 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' +import { randomUUID } from 'node:crypto' export type CliConfig = { host: string port: number + session_id: string } export type TokenInfo = { @@ -18,6 +20,7 @@ export type TokenInfo = { const defaultConfig: CliConfig = { host: '127.0.0.1', port: 8080, + session_id: '', } const memohDir = () => join(homedir(), '.memoh') @@ -46,24 +49,33 @@ const parseTomlConfig = (raw: string): CliConfig => { } else if (key === 'port') { const parsed = Number.parseInt(value, 10) if (!Number.isNaN(parsed)) result.port = parsed + } else if (key === 'session_id') { + result.session_id = value } } return result } const serializeTomlConfig = (config: CliConfig) => { - return `host = "${config.host}"\nport = ${config.port}\n` + return `host = "${config.host}"\nport = ${config.port}\nsession_id = "${config.session_id}"\n` } export const readConfig = (): CliConfig => { ensureStore() const path = configPath() + let config: CliConfig if (!existsSync(path)) { - writeFileSync(path, serializeTomlConfig(defaultConfig), 'utf-8') - return { ...defaultConfig } + config = { ...defaultConfig } + } else { + const raw = readFileSync(path, 'utf-8') + config = parseTomlConfig(raw) } - const raw = readFileSync(path, 'utf-8') - return parseTomlConfig(raw) + // Auto-generate session_id on first run + if (!config.session_id) { + config.session_id = `cli:${randomUUID()}` + writeFileSync(path, serializeTomlConfig(config), 'utf-8') + } + return config } export const writeConfig = (config: CliConfig) => {