Something went wrong. Try again.
fork of prime agent with changes i want but are not upstreamable probqbly (and some fixes)
Something went wrong. Try again.
9.5 kB · 242 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243import type { TextContent } from "@earendil-works/pi-ai";import type { SessionEntry, SessionTreeFlatNode } from "../../core/session-manager.js";import type { AgentConnectionSessionTreeEntry, AgentConnectionSessionTreeFlatNode, AgentConnectionSessionTreeNode, AgentConnectionSessionTreeToolCall,} from "./types.js";
/** * The session tree renders one line per entry, so the wire carries the line, * not the entry. A message's text is clipped to the budget the tree has always * used for both its line and its search text (`extractContent`'s 200 * characters). Text the old client searched in full — custom messages, branch * summaries, bash commands — stays whole; those bodies are small next to the * images, tool results, and thinking blocks the tree never showed. */export const TREE_MESSAGE_TEXT_LIMIT = 200;
/** Text blocks of a message content payload, clipped to `limit` (unclipped when omitted). */export function extractTreeText(content: unknown, limit?: number): string { if (typeof content === "string") return limit === undefined ? content : content.slice(0, limit); if (!Array.isArray(content)) return ""; let result = ""; for (const block of content) { if (typeof block !== "object" || block === null || !("type" in block)) continue; const typed = block as Partial<TextContent>; if (typed.type !== "text" || typeof typed.text !== "string") continue; result += typed.text; if (limit !== undefined && result.length >= limit) return result.slice(0, limit); } return result;}
/** Whether any text block carries visible text (the assistant "no content" filter). */export function treeContentHasText(content: unknown): boolean { if (typeof content === "string") return content.trim().length > 0; if (!Array.isArray(content)) return false; for (const block of content) { if (typeof block !== "object" || block === null || !("type" in block)) continue; const typed = block as Partial<TextContent>; if (typed.type === "text" && typeof typed.text === "string" && typed.text.trim().length > 0) return true; } return false;}
const TOOL_ARGUMENT_PREVIEW_LIMIT = 40;const TOOL_COMMAND_PREVIEW_LIMIT = 50;
function shortenHomePath(path: string, home: string | undefined): string { if (home && path.startsWith(home)) return `~${path.slice(home.length)}`; return path;}
/** * One-line tool call description for the tree, next to the toolResult that * answers it. Kept pure so the daemon can compute it once per call instead of * shipping tool arguments with every tree entry. */export function formatTreeToolCall( name: string, args: Record<string, unknown>, home: string | undefined = process.env.HOME ?? process.env.USERPROFILE,): string { switch (name) { case "edit": return `[edit: ${shortenHomePath(String(args.path || args.file_path || ""), home)}]`; case "bash": { const raw = String(args.command || ""); return `[bash: ${raw .replace(/[\n\t]/g, " ") .trim() .slice(0, TOOL_COMMAND_PREVIEW_LIMIT)}${raw.length > TOOL_COMMAND_PREVIEW_LIMIT ? "..." : ""}]`; } case "ipython": { const raw = String(args.code || ""); return `[ipython: ${raw .replace(/[\n\t]/g, " ") .trim() .slice(0, TOOL_COMMAND_PREVIEW_LIMIT)}${raw.length > TOOL_COMMAND_PREVIEW_LIMIT ? "..." : ""}]`; } default: { const serialized = JSON.stringify(args) ?? "{}"; return `[${name}: ${serialized.slice(0, TOOL_ARGUMENT_PREVIEW_LIMIT)}${serialized.length > TOOL_ARGUMENT_PREVIEW_LIMIT ? "..." : ""}]`; } }}
function toolCallsOf(content: unknown): AgentConnectionSessionTreeToolCall[] | undefined { if (!Array.isArray(content)) return undefined; let calls: AgentConnectionSessionTreeToolCall[] | undefined; for (const block of content) { if (typeof block !== "object" || block === null || !("type" in block)) continue; const typed = block as { type?: string; id?: string; name?: string; arguments?: Record<string, unknown> }; if (typed.type !== "toolCall" || typeof typed.id !== "string" || typeof typed.name !== "string") continue; calls ??= []; calls.push({ id: typed.id, display: formatTreeToolCall(typed.name, typed.arguments ?? {}) }); } return calls;}
/** * Reduce one session entry to everything the tree view needs: structure, the * display line, and the fields the filters and search read. */export function projectSessionTreeEntry(entry: SessionEntry): AgentConnectionSessionTreeEntry { const base = { id: entry.id, parentId: entry.parentId, timestamp: entry.timestamp }; switch (entry.type) { case "message": { const message = entry.message as { role?: string; content?: unknown; stopReason?: string; errorMessage?: string; toolCallId?: string; toolName?: string; command?: string; }; const role = message.role ?? "unknown"; if (role === "bashExecution") { return { ...base, type: "message", role, text: String(message.command ?? ""), hasText: false, }; } if (role === "toolResult") { // Not rendered — the tree shows the tool call, not its output — but the // search box has always matched against the result text too. return { ...base, type: "message", role, text: extractTreeText(message.content, TREE_MESSAGE_TEXT_LIMIT), hasText: false, ...(message.toolCallId === undefined ? {} : { toolCallId: message.toolCallId }), ...(message.toolName === undefined ? {} : { toolName: message.toolName }), }; } const toolCalls = role === "assistant" ? toolCallsOf(message.content) : undefined; return { ...base, type: "message", role, text: extractTreeText(message.content, TREE_MESSAGE_TEXT_LIMIT), hasText: treeContentHasText(message.content), ...(message.stopReason === undefined ? {} : { stopReason: message.stopReason }), ...(message.errorMessage === undefined ? {} : { errorMessage: message.errorMessage }), ...(toolCalls === undefined ? {} : { toolCalls }), }; } case "custom_message": return { ...base, type: "custom_message", customType: entry.customType, text: extractTreeText(entry.content), }; case "compaction": return { ...base, type: "compaction", tokensBefore: entry.tokensBefore }; case "branch_summary": return { ...base, type: "branch_summary", text: entry.summary }; case "model_change": return { ...base, type: "model_change", modelId: entry.modelId }; case "thinking_level_change": return { ...base, type: "thinking_level_change", thinkingLevel: entry.thinkingLevel }; case "service_tier_change": return { ...base, type: "service_tier_change", serviceTier: entry.serviceTier ?? "default" }; case "custom": return { ...base, type: "custom", customType: entry.customType }; case "child_usage_attributed": return { ...base, type: "child_usage_attributed", targetId: entry.targetId, childUsage: { input: entry.childUsage.input, output: entry.childUsage.output, cacheRead: entry.childUsage.cacheRead, cacheWrite: entry.childUsage.cacheWrite, }, }; case "label": return { ...base, type: "label", ...(entry.label === undefined ? {} : { label: entry.label }) }; case "session_info": return { ...base, type: "session_info", ...(entry.name === undefined ? {} : { name: entry.name }) }; case "session_state": return { ...base, type: "session_state" }; case "agent_status": return { ...base, type: "agent_status" }; case "git_state": return { ...base, type: "git_state" }; default: { const exhaustive: never = entry; throw new Error(`Unprojected session entry type: ${JSON.stringify(exhaustive)}`); } }}
/** Project a whole session tree for transport. Structure and labels are preserved. */export function projectSessionTreeFlatNodes( flatNodes: readonly SessionTreeFlatNode[],): AgentConnectionSessionTreeFlatNode[] { return flatNodes.map((flatNode) => ({ entry: projectSessionTreeEntry(flatNode.entry), ...(flatNode.label === undefined ? {} : { label: flatNode.label }), ...(flatNode.labelTimestamp === undefined ? {} : { labelTimestamp: flatNode.labelTimestamp }), }));}
/** Nest projected flat nodes by parent id, matching SessionManager.getTree() sibling order. */export function buildSessionTreeFromFlatNodes( flatNodes: readonly AgentConnectionSessionTreeFlatNode[],): AgentConnectionSessionTreeNode[] { const byId = new Map<string, AgentConnectionSessionTreeNode>(); const roots: AgentConnectionSessionTreeNode[] = []; for (const flatNode of flatNodes) { byId.set(flatNode.entry.id, { ...flatNode, children: [] }); } for (const flatNode of flatNodes) { const entry = flatNode.entry; const node = byId.get(entry.id)!; const parent = entry.parentId === null || entry.parentId === entry.id ? undefined : byId.get(entry.parentId); if (parent) parent.children.push(node); else roots.push(node); } // Every node is already indexed, so sort each sibling array directly instead // of walking deep chains. Timestamps are parsed once per node, not per // comparison: a long session sorts thousands of siblings on every open. const sortKeys = new Map<AgentConnectionSessionTreeNode, number>(); for (const node of byId.values()) { sortKeys.set(node, Date.parse(node.entry.timestamp)); } const compare = (left: AgentConnectionSessionTreeNode, right: AgentConnectionSessionTreeNode): number => (sortKeys.get(left) ?? 0) - (sortKeys.get(right) ?? 0); for (const node of byId.values()) { node.children.sort(compare); } return roots;}