- Move core agent engine to packages/core/ - Add packages/types/ for shared TypeScript types - Add packages/utils/ for utility functions - Add apps/cli/ for command-line interface - Add apps/gateway/ for NestJS WebSocket gateway - Add apps/server/ for REST API server - Restructure desktop app (electron/ → src/main/, src/preload/) - Update pnpm workspace configuration - Remove legacy src/ directory Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
318 lines
10 KiB
TypeScript
318 lines
10 KiB
TypeScript
import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
|
|
import { colors, createSpinner, dim } from "./colors.js";
|
|
import { extractText, extractThinking } from "@multica/core";
|
|
import type { ReasoningMode } from "@multica/core";
|
|
|
|
export type AgentOutputState = {
|
|
lastAssistantText: string;
|
|
lastAssistantThinking: string;
|
|
printedLen: number;
|
|
printedThinkingLen: number;
|
|
streaming: boolean;
|
|
};
|
|
|
|
export type AgentOutput = {
|
|
state: AgentOutputState;
|
|
handleEvent: (event: AgentEvent) => void;
|
|
};
|
|
|
|
function truncate(s: string, max: number): string {
|
|
return s.length > max ? s.slice(0, max) + "…" : s;
|
|
}
|
|
|
|
// Exported for testing
|
|
export function toolDisplayName(name: string): string {
|
|
const map: Record<string, string> = {
|
|
read: "ReadFile",
|
|
write: "WriteFile",
|
|
edit: "EditFile",
|
|
exec: "Exec",
|
|
process: "Process",
|
|
grep: "Grep",
|
|
find: "FindFiles",
|
|
ls: "ListDir",
|
|
glob: "Glob",
|
|
web_search: "WebSearch",
|
|
web_fetch: "WebFetch",
|
|
};
|
|
return map[name] || name;
|
|
}
|
|
|
|
// Exported for testing
|
|
export function formatToolArgs(name: string, args: unknown): string {
|
|
if (!args || typeof args !== "object") return "";
|
|
const record = args as Record<string, unknown>;
|
|
const get = (key: string) => (record[key] !== undefined ? String(record[key]) : "");
|
|
switch (name) {
|
|
case "read":
|
|
return get("path") || get("file");
|
|
case "write":
|
|
return get("path") || get("file");
|
|
case "edit":
|
|
return get("path") || get("file");
|
|
case "grep":
|
|
return [get("pattern"), get("path") || get("directory")].filter(Boolean).join(" ");
|
|
case "find":
|
|
return [get("glob") || get("pattern"), get("path") || get("directory")].filter(Boolean).join(" ");
|
|
case "ls":
|
|
return get("path") || get("directory");
|
|
case "exec":
|
|
return get("command");
|
|
case "process":
|
|
return [get("action"), get("id")].filter(Boolean).join(" ");
|
|
case "glob":
|
|
return [get("pattern"), get("cwd")].filter(Boolean).join(" in ");
|
|
case "web_search":
|
|
return truncate(get("query"), 50);
|
|
case "web_fetch": {
|
|
const url = get("url");
|
|
try {
|
|
const parsed = new URL(url);
|
|
return parsed.hostname + (parsed.pathname !== "/" ? truncate(parsed.pathname, 30) : "");
|
|
} catch {
|
|
return truncate(url, 50);
|
|
}
|
|
}
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function formatToolLine(name: string, args: unknown, result?: unknown): string {
|
|
const title = colors.toolName(toolDisplayName(name));
|
|
const argText = formatToolArgs(name, args);
|
|
const resultSummary = formatResultSummary(name, result);
|
|
const bullet = colors.toolBullet("•");
|
|
|
|
let line = `${bullet} ${title}`;
|
|
if (argText) {
|
|
line += ` ${colors.toolArgs(`(${argText})`)}`;
|
|
}
|
|
if (resultSummary) {
|
|
line += ` ${colors.toolArrow("→")} ${colors.toolArgs(resultSummary)}`;
|
|
}
|
|
return line;
|
|
}
|
|
|
|
// Exported for testing
|
|
export function extractResultDetails(result: unknown): Record<string, unknown> | null {
|
|
if (!result || typeof result !== "object") return null;
|
|
|
|
// Try to extract from AgentMessage content array (JSON result)
|
|
const msg = result as { content?: Array<{ type: string; text?: string }> };
|
|
if (Array.isArray(msg.content)) {
|
|
for (const c of msg.content) {
|
|
if (c.type === "text" && c.text) {
|
|
try {
|
|
return JSON.parse(c.text) as Record<string, unknown>;
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const withDetails = result as { details?: unknown };
|
|
if (withDetails.details && typeof withDetails.details === "object") {
|
|
return withDetails.details as Record<string, unknown>;
|
|
}
|
|
|
|
// Try direct object access
|
|
return result as Record<string, unknown>;
|
|
}
|
|
|
|
// Exported for testing
|
|
export function formatResultSummary(name: string, result: unknown): string {
|
|
const details = extractResultDetails(result);
|
|
if (!details) return "";
|
|
|
|
switch (name) {
|
|
case "glob": {
|
|
const count = details.count ?? (Array.isArray(details.files) ? details.files.length : 0);
|
|
const truncated = details.truncated ? "+" : "";
|
|
return `${count}${truncated} files`;
|
|
}
|
|
case "web_search": {
|
|
if (details.error) return `error: ${details.message || details.error}`;
|
|
if (details.content) {
|
|
// Perplexity result
|
|
const citations = Array.isArray(details.citations) ? details.citations.length : 0;
|
|
return `${citations} citations`;
|
|
}
|
|
// Brave result
|
|
const count = details.count ?? (Array.isArray(details.results) ? details.results.length : 0);
|
|
return `${count} results`;
|
|
}
|
|
case "web_fetch": {
|
|
if (details.error) return `error: ${details.message || details.error}`;
|
|
const parts: string[] = [];
|
|
if (details.title) {
|
|
parts.push(`"${truncate(String(details.title), 30)}"`);
|
|
}
|
|
if (typeof details.length === "number") {
|
|
const kb = (details.length / 1024).toFixed(1);
|
|
parts.push(`${kb}KB`);
|
|
}
|
|
if (details.cached) {
|
|
parts.push("cached");
|
|
}
|
|
return parts.join(", ");
|
|
}
|
|
case "grep": {
|
|
// Try to count matches from result text
|
|
const text = extractText(result as AgentMessage | undefined);
|
|
if (text.includes("No matches found")) return "no matches";
|
|
const lines = text.split("\n").filter((l) => l.trim()).length;
|
|
if (lines > 0) return `${lines} matches`;
|
|
return "";
|
|
}
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
export function createAgentOutput(params: {
|
|
stdout: NodeJS.WritableStream;
|
|
stderr: NodeJS.WritableStream;
|
|
reasoningMode?: ReasoningMode;
|
|
}): AgentOutput {
|
|
const reasoningMode = params.reasoningMode ?? "stream";
|
|
const state: AgentOutputState = {
|
|
lastAssistantText: "",
|
|
lastAssistantThinking: "",
|
|
printedLen: 0,
|
|
printedThinkingLen: 0,
|
|
streaming: false,
|
|
};
|
|
|
|
// Create spinner for thinking indicator
|
|
const spinner = createSpinner({ stream: params.stderr });
|
|
let pendingToolName = "";
|
|
let pendingToolArgs: unknown = null;
|
|
|
|
const handleEvent = (event: AgentEvent) => {
|
|
switch (event.type) {
|
|
case "message_start": {
|
|
const msg = event.message;
|
|
if (msg.role === "assistant") {
|
|
// Stop any running spinner when assistant starts responding
|
|
if (spinner.isSpinning()) {
|
|
spinner.stop();
|
|
}
|
|
state.streaming = true;
|
|
state.printedLen = 0;
|
|
state.printedThinkingLen = 0;
|
|
const text = extractText(msg);
|
|
if (text.length > 0) {
|
|
params.stdout.write(text);
|
|
state.printedLen = text.length;
|
|
}
|
|
// Stream thinking content in real-time
|
|
if (reasoningMode === "stream") {
|
|
const thinking = extractThinking(msg);
|
|
if (thinking.length > 0) {
|
|
params.stderr.write(dim(thinking));
|
|
state.printedThinkingLen = thinking.length;
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case "message_update": {
|
|
const msg = event.message;
|
|
if (msg.role === "assistant") {
|
|
const text = extractText(msg);
|
|
if (text.length > state.printedLen) {
|
|
params.stdout.write(text.slice(state.printedLen));
|
|
state.printedLen = text.length;
|
|
}
|
|
// Stream thinking content in real-time
|
|
if (reasoningMode === "stream") {
|
|
const thinking = extractThinking(msg);
|
|
if (thinking.length > state.printedThinkingLen) {
|
|
params.stderr.write(dim(thinking.slice(state.printedThinkingLen)));
|
|
state.printedThinkingLen = thinking.length;
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case "message_end": {
|
|
const msg = event.message;
|
|
if (msg.role === "assistant") {
|
|
const text = extractText(msg);
|
|
if (text.length > state.printedLen) {
|
|
params.stdout.write(text.slice(state.printedLen));
|
|
state.printedLen = text.length;
|
|
}
|
|
if (state.streaming) params.stdout.write("\n");
|
|
state.streaming = false;
|
|
state.lastAssistantText = text;
|
|
|
|
// Extract and store thinking content (skip when off)
|
|
const thinking = reasoningMode !== "off" ? extractThinking(msg) : "";
|
|
state.lastAssistantThinking = thinking;
|
|
|
|
// Show thinking at end for "on" mode
|
|
if (reasoningMode === "on" && thinking) {
|
|
params.stderr.write(`\n${dim("--- Thinking ---")}\n`);
|
|
params.stderr.write(dim(thinking));
|
|
params.stderr.write(`\n${dim("--- End Thinking ---")}\n`);
|
|
}
|
|
// Finish streaming thinking with a newline
|
|
if (reasoningMode === "stream" && state.printedThinkingLen > 0) {
|
|
params.stderr.write("\n");
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case "tool_execution_start": {
|
|
pendingToolName = event.toolName;
|
|
pendingToolArgs = event.args;
|
|
const title = colors.toolName(toolDisplayName(event.toolName));
|
|
const argText = formatToolArgs(event.toolName, event.args);
|
|
const displayText = argText ? `${title} ${colors.toolArgs(`(${argText})`)}` : title;
|
|
spinner.start(displayText);
|
|
break;
|
|
}
|
|
case "tool_execution_update": {
|
|
// Show real-time output updates (e.g., from exec tool)
|
|
const updateText = extractText(event.partialResult);
|
|
if (updateText && pendingToolName) {
|
|
const title = colors.toolName(toolDisplayName(pendingToolName));
|
|
const preview = colors.toolArgs(updateText.slice(-50).replace(/\n/g, " "));
|
|
spinner.update(`${title} ${colors.toolArrow("→")} ${preview}`);
|
|
}
|
|
break;
|
|
}
|
|
case "tool_execution_end": {
|
|
// Stop spinner and show final result with summary
|
|
const details = extractResultDetails(event.result);
|
|
const errorField = details?.error;
|
|
const hasError =
|
|
event.isError ||
|
|
Boolean(errorField) ||
|
|
details?.success === false;
|
|
if (hasError) {
|
|
const errorText =
|
|
(typeof details?.message === "string" && details.message) ||
|
|
(typeof errorField === "string" && errorField) ||
|
|
extractText(event.result) ||
|
|
"Tool failed";
|
|
const bullet = colors.toolError("✗");
|
|
const title = colors.toolName(toolDisplayName(event.toolName));
|
|
spinner.stop(`${bullet} ${title}: ${colors.toolError(errorText)}`);
|
|
} else {
|
|
spinner.stop(formatToolLine(event.toolName, pendingToolArgs, event.result));
|
|
}
|
|
pendingToolName = "";
|
|
pendingToolArgs = null;
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
};
|
|
|
|
return { state, handleEvent };
|
|
}
|