import { activityOneLine, compareActivityRows } from "@oh-my-pi/pi-tui/overlays/agent-activity"; import type { Stats } from "node:fs"; import * as fs from "node:fs/promises"; import type { AgentActivityRow, AgentActivityQuery } from "@oh-my-pi/pi-tui/overlays/agent-activity"; export type { AgentActivityRow, AgentActivityQuery, AgentActivityKind, AgentActivityStatus, } from "@oh-my-pi/pi-tui/overlays/agent-activity"; export { activityRowsFromProgress } from "@oh-my-pi/pi-tui/overlays/agent-activity"; export interface AgentActivityTranscript { text: string; newSize: number; error?: string; } export interface AgentActivityRemote { readTranscript(agentId: string, fromByte: number): Promise; } interface TranscriptState { path?: string; offset: number; mtimeMs: number; pending: string; rows: AgentActivityRow[]; toolRows: Map; } interface ActivityMessage { role?: string; timestamp?: number; content?: unknown; toolCallId?: string; toolName?: string; isError?: boolean; customType?: string; details?: unknown; } const INITIAL_TAIL_BYTES = 256 * 1024; const MAX_ROWS_PER_AGENT = 256; const DEFAULT_QUERY_LIMIT = 200; const MAX_QUERY_LIMIT = 2_000; function recordOf(value: unknown): Record | undefined { return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record) : undefined; } function timestampOf(value: unknown, fallback: number): number { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value !== "string") return fallback; const parsed = Date.parse(value); return Number.isFinite(parsed) ? parsed : fallback; } function textContent(content: unknown): string { if (typeof content === "string") return activityOneLine(content); if (!Array.isArray(content)) return ""; const parts: string[] = []; for (const blockValue of content) { const block = recordOf(blockValue); if (block?.type === "text" && typeof block.text === "string") parts.push(block.text); } return activityOneLine(parts.join(" ")); } function sameRow(a: AgentActivityRow, b: AgentActivityRow): boolean { return ( a.id === b.id && a.agentId === b.agentId && a.timestamp === b.timestamp && a.kind === b.kind && a.title === b.title && a.summary === b.summary && a.status === b.status && a.entryId === b.entryId && a.toolCallId === b.toolCallId && a.toolName === b.toolName && a.from === b.from && a.to === b.to && a.replyTo === b.replyTo && a.source === b.source ); } function sameRows(current: readonly AgentActivityRow[] | undefined, next: readonly AgentActivityRow[]): boolean { if (!current) return next.length === 0; if (current.length !== next.length) return false; for (let index = 0; index < next.length; index++) { if (!sameRow(current[index]!, next[index]!)) return false; } return true; } function sameSet(a: ReadonlySet | undefined, b: ReadonlySet | undefined): boolean { if (a === b) return true; if (!a || !b || a.size !== b.size) return false; for (const value of a) if (!b.has(value)) return false; return true; } /** Lower-cased search haystack per row; rows' text fields never change after creation. */ const searchTextCache = new WeakMap(); function searchTextOf(row: AgentActivityRow): string { let text = searchTextCache.get(row); if (text === undefined) { text = `${row.agentId} ${row.title} ${row.summary} ${row.from ?? ""} ${row.to ?? ""}`.toLowerCase(); searchTextCache.set(row, text); } return text; } function firstString(value: Record, ...keys: string[]): string | undefined { for (const key of keys) { const candidate = value[key]; if (typeof candidate === "string" && candidate.trim()) return activityOneLine(candidate); } return undefined; } function argumentsSummary(toolName: string, value: unknown): string { const args = recordOf(value) ?? {}; const intent = firstString(args, "i", "intent"); if (intent) return intent; switch (toolName) { case "read": return firstString(args, "path") ?? "Read resource"; case "grep": { const pattern = firstString(args, "pattern"); const path = firstString(args, "path"); return [pattern, path].filter(Boolean).join(" ยท ") || "Search text"; } case "glob": return firstString(args, "path") ?? "Map files"; case "bash": return firstString(args, "command") ?? "Run command"; case "write": case "edit": return firstString(args, "path") ?? `${toolName === "write" ? "Write" : "Edit"} files`; case "wait": return "Waiting for background work or a peer"; default: { const common = firstString(args, "path", "query", "name", "task", "title"); if (common) return common; try { const encoded = activityOneLine(JSON.stringify(args)); return encoded === "{}" ? toolName : encoded; } catch { return toolName; } } } } function toolBlocks(content: unknown): Array<{ id: string; name: string; args: unknown }> { if (!Array.isArray(content)) return []; const calls: Array<{ id: string; name: string; args: unknown }> = []; for (const blockValue of content) { const block = recordOf(blockValue); if (block?.type !== "toolCall" || typeof block.id !== "string" || typeof block.name !== "string") continue; calls.push({ id: block.id, name: block.name, args: block.arguments }); } return calls; } /** * Insert-or-replace by id without sorting; `#consume` sorts and trims via * {@link trimRows} once per batch and whenever a backlog doubles the bound. */ function upsertRow(rows: AgentActivityRow[], index: Map, row: AgentActivityRow): void { const existing = index.get(row.id); if (existing !== undefined) rows[existing] = row; else { index.set(row.id, rows.length); rows.push(row); } } function trimRows(state: TranscriptState): void { state.rows.sort(compareActivityRows); if (state.rows.length <= MAX_ROWS_PER_AGENT) return; state.rows.splice(0, state.rows.length - MAX_ROWS_PER_AGENT); const retained = new Set(state.rows.map(row => row.id)); for (const [toolCallId, row] of state.toolRows) { if (!retained.has(row.id)) state.toolRows.delete(toolCallId); } } /** Row position by id, rebuilt after {@link trimRows} reorders the rows. */ function rowIndex(rows: readonly AgentActivityRow[]): Map { const index = new Map(); for (let position = 0; position < rows.length; position++) index.set(rows[position]!.id, position); return index; } interface QueryCacheEntry { revision: number; query: AgentActivityQuery; rows: AgentActivityRow[]; } export class AgentActivityIndex { #states = new Map(); #liveRows = new Map(); #listeners = new Set<() => void>(); #remote: AgentActivityRemote | undefined; /** Bumped on every change; keys the merged-row and query caches. */ #revision = 0; #mergedRevision = -1; #merged = new Map(); #queryCache: QueryCacheEntry | undefined; constructor(options?: { remote?: AgentActivityRemote }) { this.#remote = options?.remote; } onChange(listener: () => void): () => void { this.#listeners.add(listener); return () => this.#listeners.delete(listener); } setLive(agentId: string, rows: readonly AgentActivityRow[]): void { if (sameRows(this.#liveRows.get(agentId), rows)) return; const next = [...rows]; if (next.length > 0) this.#liveRows.set(agentId, next); else this.#liveRows.delete(agentId); this.#notify(); } async sync(agentId: string, sessionFile?: string | null): Promise { if (this.#remote) { await this.#syncRemote(agentId); return; } if (!sessionFile) return; await this.#syncLocal(agentId, sessionFile); } /** Test/diag helper: number of toolCallId mappings retained for an agent. */ retainedToolMappings(agentId: string): number { return this.#states.get(agentId)?.toolRows.size ?? 0; } recent(agentId: string, limit = 12): AgentActivityRow[] { return this.#mergedRows(agentId).slice(-Math.max(0, limit)); } /** Persisted rows merged with live rows, sorted; memoized per revision. Callers must not mutate. */ #mergedRows(agentId: string): AgentActivityRow[] { if (this.#mergedRevision !== this.#revision) { this.#merged.clear(); this.#mergedRevision = this.#revision; } let merged = this.#merged.get(agentId); if (merged) return merged; const persisted = this.#states.get(agentId)?.rows ?? []; const live = this.#liveRows.get(agentId) ?? []; if (live.length === 0) { merged = persisted.slice(); } else { const liveKeys = new Set(live.map(row => `${row.kind}:${row.toolName ?? row.title}:${row.summary}`)); merged = persisted.filter(row => !liveKeys.has(`${row.kind}:${row.toolName ?? row.title}:${row.summary}`)); merged.push(...live); merged.sort(compareActivityRows); } this.#merged.set(agentId, merged); return merged; } query(query: AgentActivityQuery = {}): AgentActivityRow[] { const cached = this.#queryCache; if ( cached && cached.revision === this.#revision && cached.query.search === query.search && cached.query.limit === query.limit && cached.query.before?.timestamp === query.before?.timestamp && cached.query.before?.id === query.before?.id && sameSet(cached.query.agentIds, query.agentIds) && sameSet(cached.query.kinds, query.kinds) ) { return cached.rows.slice(); } const search = query.search?.trim().toLowerCase(); const rows: AgentActivityRow[] = []; const ids = new Set([...this.#states.keys(), ...this.#liveRows.keys()]); for (const agentId of ids) { if (query.agentIds && !query.agentIds.has(agentId)) continue; for (const row of this.#mergedRows(agentId).slice(-MAX_ROWS_PER_AGENT)) { if (query.kinds && !query.kinds.has(row.kind)) continue; if (query.before) { const afterCursor = row.timestamp > query.before.timestamp || (row.timestamp === query.before.timestamp && row.id >= query.before.id); if (afterCursor) continue; } if (search && !searchTextOf(row).includes(search)) continue; rows.push(row); } } rows.sort(compareActivityRows); const limit = Math.max(0, Math.min(MAX_QUERY_LIMIT, query.limit ?? DEFAULT_QUERY_LIMIT)); const result = rows.slice(-limit); // Snapshot the filter sets: callers may reuse and mutate them. this.#queryCache = { revision: this.#revision, query: { ...query, agentIds: query.agentIds && new Set(query.agentIds), kinds: query.kinds && new Set(query.kinds), before: query.before && { ...query.before }, }, rows: result, }; return result.slice(); } clear(): void { this.#states.clear(); this.#liveRows.clear(); this.#notify(); } async #syncLocal(agentId: string, sessionFile: string): Promise { let stat: Stats; try { stat = await fs.stat(sessionFile); } catch { return; } let state = this.#states.get(agentId); if ( !state || state.path !== sessionFile || stat.size < state.offset || (stat.size === state.offset && stat.mtimeMs !== state.mtimeMs) ) { const hadRows = (state?.rows.length ?? 0) > 0; state = { path: sessionFile, offset: Math.max(0, stat.size - INITIAL_TAIL_BYTES), mtimeMs: 0, pending: "", rows: [], toolRows: new Map(), }; this.#states.set(agentId, state); // The rotated transcript's rows are gone even if the new one yields none. if (hadRows) this.#notify(); } if (stat.size === state.offset && stat.mtimeMs === state.mtimeMs) return; const start = state.offset; let text: string; try { text = await Bun.file(sessionFile).slice(start, stat.size).text(); } catch { return; } state.offset = stat.size; state.mtimeMs = stat.mtimeMs; this.#consume(agentId, state, text, start > 0 && state.rows.length === 0); } async #syncRemote(agentId: string): Promise { let state = this.#states.get(agentId); if (!state) { state = { offset: 0, mtimeMs: 0, pending: "", rows: [], toolRows: new Map() }; this.#states.set(agentId, state); // The host returns the actual EOF when asked beyond it. Probe once so // historical sessions start at the same bounded tail as local files. let probe: AgentActivityTranscript | null | undefined; try { probe = await this.#remote?.readTranscript(agentId, Number.MAX_SAFE_INTEGER); } catch { return; } if (!probe || probe.error) return; state.offset = Math.max(0, probe.newSize - INITIAL_TAIL_BYTES); } let result: AgentActivityTranscript | null | undefined; try { result = await this.#remote?.readTranscript(agentId, state.offset); } catch { return; } if (!result || result.error) return; if (result.newSize < state.offset) { state.offset = Math.max(0, result.newSize - INITIAL_TAIL_BYTES); state.pending = ""; const hadRows = state.rows.length > 0; state.rows = []; state.toolRows.clear(); // The truncated transcript's rows are gone even if the new tail yields none. if (hadRows) this.#notify(); try { result = await this.#remote?.readTranscript(agentId, state.offset); } catch { return; } if (!result || result.error) return; } if (result.newSize === state.offset && !result.text) return; this.#consume(agentId, state, result.text, state.offset > 0 && state.rows.length === 0); state.offset = result.newSize; } #consume(agentId: string, state: TranscriptState, chunk: string, dropLeadingPartial: boolean): void { let text = state.pending + chunk; if (dropLeadingPartial) { const newline = text.indexOf("\n"); text = newline >= 0 ? text.slice(newline + 1) : ""; } const complete = text.endsWith("\n"); const lines = text.split("\n"); state.pending = complete ? "" : (lines.pop() ?? ""); let changed = false; let index = rowIndex(state.rows); for (const line of lines) { if (!line.trim()) continue; let entry: Record | undefined; try { entry = recordOf(JSON.parse(line)); } catch { continue; } if (entry?.type !== "message" || typeof entry.id !== "string") continue; const message = recordOf(entry.message) as ActivityMessage | undefined; if (!message) continue; const timestamp = timestampOf(message.timestamp, timestampOf(entry.timestamp, Date.now())); if (message.role === "assistant") { const response = textContent(message.content); if (response) { upsertRow(state.rows, index, { id: `${agentId}:response:${entry.id}`, agentId, timestamp, kind: "response", title: "Response", summary: response, status: message.isError ? "error" : "success", entryId: entry.id, source: "transcript", }); changed = true; } for (const call of toolBlocks(message.content)) { const row: AgentActivityRow = { id: `${agentId}:tool:${call.id}`, agentId, timestamp, kind: "tool", title: call.name, summary: argumentsSummary(call.name, call.args), status: "pending", entryId: entry.id, toolCallId: call.id, toolName: call.name, source: "transcript", }; state.toolRows.set(call.id, row); upsertRow(state.rows, index, row); changed = true; } // A backlog may hold far more rows than are kept: trim as it grows, not once at the end. if (state.rows.length >= MAX_ROWS_PER_AGENT * 2) { trimRows(state); index = rowIndex(state.rows); } continue; } if (message.role === "toolResult" && typeof message.toolCallId === "string") { const row = state.toolRows.get(message.toolCallId); if (!row) continue; row.status = message.isError ? "error" : "success"; row.timestamp = Math.max(row.timestamp, timestamp); if (!index.has(row.id)) upsertRow(state.rows, index, row); state.toolRows.delete(message.toolCallId); changed = true; } } if (!changed) return; trimRows(state); this.#notify(); } #notify(): void { this.#revision++; for (const listener of this.#listeners) listener(); } }