pi-mono/apps/p2/src/web/transcript.ts

604 lines
24 KiB
TypeScript

import type { JsonValue } from "@earendil-works/chord/json";
import type { AssistantMessage, ImageContent, ToolResultMessage } from "@earendil-works/pi-ai";
import type {
AgentState,
CompactionFailure,
EntryRecord,
InboxState,
LiveState,
NestedToolSlot,
RunFailure,
ToolSlot,
TranscriptItem,
TranscriptView,
} from "@earendil-works/pi-durable";
type ArtifactState = { artifacts: ArtifactInfo[] };
import { AGENT_MESSAGE_ENTRY, type AgentMessage, AgentMessageSchema, peerMessage } from "../runtime/entries.ts";
import type { ArtifactInfo } from "../runtime/schemas.ts";
type ConversationPeer = { id: string; title: string };
import { compile } from "@earendil-works/pi-schema";
import { type AssistantCost, cacheHitRate } from "../runtime/conversation-stats.ts";
import { type MessageData, messageData } from "../runtime/input.ts";
import type { Author } from "../runtime/sender.ts";
import { SHELL_ENTRY, shellData } from "../runtime/shell.ts";
// Pure display model of a Durable view: tool calls are joined with their live
// slot and result entry, so one card follows a call from request to result.
export type ToolStatus = "pending" | "running" | "done" | "error" | "interrupted";
export type ToolView = {
id: string;
name: string;
args: Record<string, unknown>;
status: ToolStatus;
output: string;
details?: JsonValue;
images: ImageContent[];
/** Calls this call made to other tools (code mode's `tools.<name>()`), in creation order; see `nestedCalls`. */
calls?: readonly NestedCall[];
/** The result's recorded execution time. */
durationMs?: number;
};
/** One call a running tool made to another tool, as a status row. */
export type NestedCall = {
id: string;
name: string;
/** The arguments, or their JSON text when a settled list cut it too short to parse. */
args: Record<string, unknown> | string;
status: ToolStatus;
/** 0 for calls of the model's call, 1 for their own nested calls, and so on. */
depth: number;
/** Running output, while running. */
output?: string;
durationMs?: number;
error?: string;
};
export type Item =
| {
type: "user";
key: string;
text: string;
author?: Author;
peer?: Pick<ConversationPeer, "id" | "title">;
/** A subagent's message (its report, a question or a note), from the entry's data. */
agent?: Pick<AgentMessage, "from" | "intent">;
images: ImageContent[];
files: string[];
/** The entry this item shows; absent for queued items. */
entryId?: number;
/** A display-only entry shown as a person's message (it never reached the model). */
chat?: boolean;
/** The message this one answers: shown as a quoted line that leads to it. */
replyTo?: ReplyQuote;
/** A plugin's short note about the message, e.g. how much chat a mention passed on. */
note?: string;
}
| {
type: "assistant";
key: string;
/** The committed entry; absent while streaming. */
entryId?: number;
message: AssistantMessage;
tools: ToolView[];
streaming: boolean;
}
| { type: "failure"; key: string; text: string }
| { type: "marker"; key: string; label: string; detail?: string }
| { type: "tool"; key: string; tool: ToolView }
/** Entries between two loaded ones are missing (`TranscriptStore.fillGap`); `loading` while they load. */
| { type: "gap"; key: string; gap: Gap; loading: boolean };
export { type AssistantCost, cacheHitRate };
type Gap = Extract<TranscriptItem, { kind: "gap" }>;
export type Transcript = {
items: Item[];
/** More entries exist before the first item (`TranscriptStore.loadEarlier`). */
earlier: boolean;
artifacts: ArtifactInfo[];
busy: boolean;
working: boolean;
retry?: { at: number; error: string };
compactions: {
id: number;
/** What started the compaction (Durable's `CompactionStatus.trigger`). */
trigger: "manual" | "threshold" | "overflow";
blocking: boolean;
attempt: number;
retry?: { at: number; error: string };
}[];
queued: { id: number; mode: string; text: string }[];
agent: AgentState;
};
function text(content: string | readonly { type: string; text?: string }[]): string {
return typeof content === "string"
? content
: content
.filter((part) => part.type === "text")
.map((part) => part.text ?? "")
.join("\n");
}
function resultTool(result: ToolResultMessage, name = result.toolName): ToolView {
return {
id: result.toolCallId,
name,
args: {},
status: result.isError ? "error" : "done",
output: text(result.content),
details: result.details as JsonValue | undefined,
images: result.content.filter((part) => part.type === "image"),
...(typeof result.durationMs === "number" ? { durationMs: result.durationMs } : {}),
};
}
function artifactInfo(value: unknown): ArtifactInfo | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return;
const details = value as Record<string, unknown>;
if (
typeof details.name !== "string" ||
!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(details.name) ||
typeof details.title !== "string" ||
!["html", "svg", "markdown", "text"].includes(String(details.type)) ||
typeof details.version !== "number" ||
!Number.isSafeInteger(details.version) ||
details.version <= 0
)
return;
return details as ArtifactInfo;
}
const isAgentMessage = compile(AgentMessageSchema).check;
/**
* Display items of each entry, with what they were built from. Chord keeps unchanged entries (and documents' unchanged
* parts) as the same objects, so while an entry, its tools' results and live slots and the busy flags stay the same,
* its items are the same objects too, and the memoized components showing them skip rendering. A streaming delta then
* re-renders the live message, not the whole transcript.
*/
const itemCache = new WeakMap<EntryRecord, { inputs: readonly unknown[]; items: Item[] }>();
const liveItems = new WeakMap<AssistantMessage, Item>();
/** The last nested calls this browser saw for each model-issued call, by call ID. */
const seenCalls = new Map<string, readonly NestedCall[]>();
const settledStatus = { ok: "done", error: "error", cancelled: "interrupted", running: "interrupted" } as const;
/** Durable's error for a call a restart cut short (an unsafe tool is not run again): interrupted, not failed. */
const INTERRUPTED_ERROR = /^Tool \S+ was interrupted and may have partially run/;
const interrupted = (error: unknown): boolean => typeof error === "string" && INTERRUPTED_ERROR.test(error);
/**
* The settled list of a result's details, code mode's `CodemodeToolDetails`:
* `{ calls: [{ id, name, args (cut JSON), status: "ok" | "error" | "cancelled", durationMs?, error? }] }`.
* Only the script's own calls are listed, so every row is at depth 0.
*/
function detailCalls(callId: string, details: unknown): readonly NestedCall[] | undefined {
if (!details || typeof details !== "object" || !("calls" in details) || !Array.isArray(details.calls)) return;
const calls: NestedCall[] = [];
for (const call of details.calls as unknown[]) {
if (!call || typeof call !== "object") return;
const row = call as Record<string, unknown>;
if (typeof row.id !== "number" || typeof row.name !== "string" || typeof row.args !== "string") return;
if (!Object.hasOwn(settledStatus, String(row.status))) return;
let args: Record<string, unknown> | string = row.args;
try {
const parsed: unknown = JSON.parse(row.args);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) args = parsed as Record<string, unknown>;
} catch {}
calls.push({
id: `${callId}/c${row.id}`,
name: row.name,
args,
status:
row.status === "error" && interrupted(row.error)
? "interrupted"
: settledStatus[row.status as keyof typeof settledStatus],
depth: 0,
...(typeof row.durationMs === "number" ? { durationMs: row.durationMs } : {}),
...(typeof row.error === "string" ? { error: row.error } : {}),
});
}
return calls;
}
/**
* The nested calls of model-issued call `callId`, the one source of every nested-call row. While the call runs that is
* `pi.live.nestedTools` (`slots`: this call's slots, parents before children); the commit appending its result removes
* them. A settled call shows the final list of its result `details` (code mode's `calls`), else the last live list this
* browser saw (lost on reload).
*/
function nestedCalls(
callId: string,
slots: readonly NestedToolSlot[] | undefined,
settled: boolean,
details?: unknown,
): readonly NestedCall[] | undefined {
const final = detailCalls(callId, details);
if (final) {
seenCalls.delete(callId);
return final;
}
if (slots?.length) {
const depths = new Map<string, number>();
const calls = slots.map((slot): NestedCall => {
const depth = (depths.get(slot.parentCallId) ?? -1) + 1;
depths.set(slot.callId, depth);
const summary = slot.summary;
return {
id: slot.callId,
// Code mode's internal tools (`pi.codemode.models.classify`) by their script name, as the settled list has them.
name: slot.name.replace(/^pi\.codemode\./, ""),
args: slot.arguments,
// Done without a summary: the scheduler faulted or orphaned the call.
status:
slot.status === "done"
? summary && !summary.isError
? "done"
: interrupted(summary?.error)
? "interrupted"
: "error"
: slot.status,
depth,
...(slot.status === "running" && slot.output ? { output: slot.output } : {}),
...(summary?.durationMs === undefined ? {} : { durationMs: summary.durationMs }),
...(summary?.error
? { error: summary.error }
: slot.status === "done" && !summary
? { error: "No result" }
: {}),
};
});
seenCalls.set(callId, calls);
return calls;
}
const seen = seenCalls.get(callId);
// A call that never finished before its caller settled was aborted with it.
return settled
? seen?.map((call) =>
call.status === "running" || call.status === "pending" ? { ...call, status: "interrupted" } : call,
)
: seen;
}
const compactionFailureItems = new WeakMap<CompactionFailure, Item>();
/** Null for a failure that shows nothing (`runFailureText`). */
const runFailureItems = new WeakMap<RunFailure, Item | null>();
const gapItems = new WeakMap<Gap, { loading: boolean; item: Item }>();
/** A response's identity from its partial to its committed entry: the time the provider started it. */
function responseKey(message: AssistantMessage): string {
return `response-${message.timestamp}`;
}
function cachedItems(entry: EntryRecord, inputs: readonly unknown[], build: () => Item[]): Item[] {
const cached = itemCache.get(entry);
if (
cached &&
cached.inputs.length === inputs.length &&
cached.inputs.every((input, index) => input === inputs[index])
)
return cached.items;
const items = build();
itemCache.set(entry, { inputs, items });
return items;
}
/** A reply's quoted line: whose message it answers and the start of that message. */
export type ReplyQuote = { entryId: number; name: string; excerpt: string };
/** What a plugin adds to its entry kind's messages (see `TranscriptEntries` in plugins/chat/contract.ts). */
export type EntryDisplayOf = (kind: string, data: unknown) => { replyTo?: ReplyQuote; note?: string } | undefined;
/** A `p2.shell` entry as a marker: `$ command`, and the output with how it ended. */
function shellItems(key: string, raw: unknown): Item[] {
const data = shellData(raw);
if (!data) return [];
const fence = "`".repeat(Math.max(3, ...Array.from(data.output.matchAll(/`+/g), (match) => match[0].length + 1)));
const status = data.cancelled
? "Cancelled"
: data.error
? data.error
: data.exitCode !== undefined && data.exitCode !== 0
? `Exited with code ${data.exitCode}`
: "";
const detail = [
data.output ? `${fence}\n${data.output}\n${fence}` : "(no output)",
...(data.truncated ? ["Earlier output was dropped."] : []),
...(status ? [status] : []),
`Run by ${data.author.kind === "owner" ? "the owner" : `${data.author.name} (guest)`}${data.includeInContext ? "" : ", not shared with Pi"}.`,
].join("\n\n");
return [{ type: "marker", key, label: `$ ${data.command}`, detail }];
}
/** What a run that ended without an answer shows (`pi.live.runFailure`); nothing for a Stop or a new context. */
function runFailureText(failure: RunFailure): string | undefined {
const detail = failure.detail?.trim();
switch (failure.reason) {
// The detail names the error, or the missing model ("Model faux/gone is not available").
case "model_error":
case "no_model":
return `The model could not answer this input.${detail ? ` ${detail}` : ""}`;
case "faulted":
return `The run failed${detail ? `: ${detail}` : "."}`;
case "orphaned":
return `The run could not continue${detail ? `: ${detail}` : "."}`;
case "interrupted":
return "The answer was interrupted: the server stopped while the model was answering.";
// The detail says what was cut off how often ("The generation was interrupted 5 times in its request phase").
case "restart_limit":
return `The run was stopped after repeated restarts.${detail ? ` ${detail}.` : ""}`;
case "aborted":
case "reset":
return undefined;
}
}
/**
* The display items of a conversation's transcript (`TranscriptStore`): its entries in ID order with each gap, and the
* live state of the view's documents. People's messages come from entry data (src/runtime/input.ts); `display` lets a
* plugin add to its kinds' messages. `filling` names the gaps being loaded (`gapKey`).
*/
export function buildTranscript(
view: TranscriptView,
display?: EntryDisplayOf,
filling: ReadonlySet<string> = new Set(),
): Transcript {
const live = view.docs["pi.live"] as unknown as LiveState | undefined;
const compactionFailure = live?.compactionFailure;
const compactionError = compactionFailure ? `Compaction failed: ${compactionFailure.error}` : undefined;
const inbox = view.docs["pi.inbox"] as unknown as InboxState | undefined;
const artifactState = view.docs["p2.artifacts"] as unknown as ArtifactState | undefined;
const busy = Boolean(live?.run);
const results = new Map<string, ToolResultMessage>();
let latest: EntryRecord | undefined;
for (const item of view.items) {
if (item.kind !== "entry") continue;
const message = item.entry.model?.[0];
if (message?.role === "toolResult") results.set(message.toolCallId, message);
else if (message?.role === "assistant") latest = item.entry;
}
const slots = new Map<string, ToolSlot>((live?.tools ?? []).map((slot) => [slot.callId, slot]));
// Nested slots by the model-issued call they descend from; the list holds parents before their children.
const nested = new Map<string, NestedToolSlot[]>();
const rootOf = new Map<string, string>();
for (const slot of live?.nestedTools ?? []) {
const root = rootOf.get(slot.parentCallId) ?? slot.parentCallId;
rootOf.set(slot.callId, root);
const group = nested.get(root);
if (group) group.push(slot);
else nested.set(root, [slot]);
}
const called = new Set<string>();
/** Keys of responses already placed: a second response started in the same millisecond keys by its entry. */
const responseKeys = new Set<string>();
const items: Item[] = [];
/** A person's message: who wrote it and what, from its data; a plugin may add a reply quote or a note. */
const personItem = (entry: EntryRecord, person: MessageData, images: ImageContent[], chat: boolean): Item => ({
type: "user",
key: String(entry.id),
entryId: entry.id,
images,
author: person.author,
text: person.text,
files: person.attachments.map((file) => file.name),
...(chat ? { chat } : {}),
...display?.(entry.kind, entry.data),
});
for (const transcriptItem of view.items) {
if (transcriptItem.kind === "earlier") continue;
if (transcriptItem.kind === "gap") {
const loading = filling.has(`gap-${transcriptItem.after}`);
let cached = gapItems.get(transcriptItem);
if (cached?.loading !== loading) {
cached = {
loading,
item: { type: "gap", key: `gap-${transcriptItem.after}`, gap: transcriptItem, loading },
};
gapItems.set(transcriptItem, cached);
}
items.push(cached.item);
continue;
}
const entry = transcriptItem.entry;
const message = entry.model?.[0];
const key = String(entry.id);
if (entry.kind === "app.model-error") {
// Notices earlier p2 versions wrote for inputs the model could not answer; `runFailure` replaced them.
const data = entry.data;
const detail = data && typeof data === "object" && !Array.isArray(data) ? data.detail : undefined;
items.push(
...cachedItems(entry, [], () => [
{
type: "failure",
key,
text: `The model could not answer this input.${typeof detail === "string" && detail.trim() ? ` ${detail}` : ""}`,
},
]),
);
} else if (entry.kind === "pi.compaction") {
items.push(
...cachedItems(entry, [], () => [
{ type: "marker", key, label: "Context compacted", detail: message ? text(message.content) : "" },
]),
);
} else if (entry.kind === "pi.reset") {
items.push(
...cachedItems(entry, [], () => [
{
type: "marker",
key,
label: "New context",
...(message?.role === "user" ? { detail: text(message.content) } : {}),
},
]),
);
} else if (entry.kind === SHELL_ENTRY) {
// A user shell command (`!cmd`/`!!cmd`): read-only, its output in a fenced block.
items.push(...cachedItems(entry, [], () => shellItems(key, entry.data)));
} else if (message?.role === "user") {
items.push(
...cachedItems(entry, [display], () => {
const images =
typeof message.content === "string" ? [] : message.content.filter((part) => part.type === "image");
// A person's input from its data.
const person = messageData(entry.data);
if (person) return [personItem(entry, person, images, false)];
if (entry.kind === AGENT_MESSAGE_ENTRY && isAgentMessage(entry.data)) {
const { from, intent, text } = entry.data;
return [{ type: "user", key, entryId: entry.id, images, agent: { from, intent }, text, files: [] }];
}
// A peer session's message from its data, or a tool's plain input.
const peer = peerMessage(entry.kind, entry.data);
if (peer)
return [
{ type: "user", key, entryId: entry.id, images, peer: peer.from, text: peer.text, files: [] },
];
return [{ type: "user", key, entryId: entry.id, images, text: text(message.content), files: [] }];
}),
);
} else if (message?.role === "assistant") {
// Keep empty abnormal responses visible; context filtering is not a display filter. Calls of the newest
// response without a result or slot are pending while busy; older ones were interrupted.
const current = entry === latest;
const calls = message.content.flatMap((part) => (part.type === "toolCall" ? [part] : []));
for (const call of calls) called.add(call.id);
// What this entry's card shows besides the entry: each call's result and live slot, and whether a call with
// neither is pending or interrupted. Ending a run thus rebuilds only cards with unfinished calls.
const inputs = [
current,
...calls.flatMap((call) => {
const result = results.get(call.id);
const slot = slots.get(call.id);
return [result, slot, result || slot ? undefined : busy && current, ...(nested.get(call.id) ?? [])];
}),
];
// Keyed by the response's start time, which the streaming partial carries too: when the answer commits,
// React keeps the component, so expanded thinking and tool cards stay open and the reading position holds.
const [item] = cachedItems(entry, inputs, () => [
{ ...assistantItem(responseKey(message), message, calls, current), entryId: entry.id },
]);
if (item) items.push(responseKeys.has(item.key) ? { ...item, key } : item);
if (item) responseKeys.add(item.key);
} else if (message?.role === "toolResult" && !called.has(message.toolCallId)) {
items.push(...cachedItems(entry, [], () => [{ type: "tool", key, tool: resultTool(message) }]));
} else if (!message) {
items.push(
...cachedItems(entry, [display], () => {
const person = messageData(entry.data);
return person ? [personItem(entry, person, [], true)] : [];
}),
);
}
}
function assistantItem(
key: string,
message: AssistantMessage,
calls: readonly { id: string; name: string; arguments: Record<string, unknown> }[],
current: boolean,
): Item {
const tools = calls.map((part): ToolView => {
const result = results.get(part.id);
// Live slots belong to the current tool round; an unfinished older call is interrupted.
const slot = slots.get(part.id);
const calls = nestedCalls(part.id, nested.get(part.id), Boolean(result) || !current || !busy, result?.details);
if (result)
return {
...resultTool(result, part.name),
args: part.arguments,
...(slot?.output && !text(result.content) ? { output: slot.output } : {}),
...(calls ? { calls } : {}),
};
const status: ToolStatus = slot
? slot.status === "done"
? "error"
: slot.status
: busy && current
? "pending"
: "interrupted";
return {
id: part.id,
name: part.name,
args: part.arguments,
status,
output: slot?.output ?? "",
details: slot?.details,
images: [],
...(calls ? { calls } : {}),
};
});
return { type: "assistant", key, message, tools, streaming: false };
}
const partial = live?.generation?.message as unknown as AssistantMessage | undefined;
if (partial) {
let item = liveItems.get(partial);
if (!item) {
item = { type: "assistant", key: responseKey(partial), message: partial, tools: [], streaming: true };
liveItems.set(partial, item);
}
items.push(responseKeys.has(item.key) ? { ...item, key: "live" } : item);
}
if (compactionFailure) {
let item = compactionFailureItems.get(compactionFailure);
if (!item) {
item = {
type: "failure",
key: `compaction-failure-${compactionFailure.taskId}`,
text: `Compaction failed: ${compactionFailure.error}`,
};
compactionFailureItems.set(compactionFailure, item);
}
// State-derived, not a retained entry: it persists while idle and disappears when Durable clears it.
// Both the browser and p2 TUI render failure items in their existing error-notice style.
items.push(item);
}
// Why the last run ended without an answer, until the next run starts; a blocking compaction failure is shown once.
const runFailure = live?.runFailure;
if (runFailure && !(compactionError !== undefined && runFailure.detail === compactionError)) {
let item = runFailureItems.get(runFailure);
if (item === undefined) {
const text = runFailureText(runFailure);
item = text === undefined ? null : { type: "failure", key: `run-failure-${runFailure.taskId}`, text };
runFailureItems.set(runFailure, item);
}
if (item) items.push(item);
}
return {
items,
earlier: view.items[0]?.kind === "earlier",
artifacts: Array.isArray(artifactState?.artifacts)
? artifactState.artifacts.flatMap((artifact) => artifactInfo(artifact) ?? []).toReversed()
: [],
busy,
// Keep one stable activity signal through thinking, text, tools and compaction.
// It disappears only when the viewed conversation is actually idle.
working: busy || Boolean(live?.compactions?.length),
...(live?.generation?.retry ? { retry: live.generation.retry } : {}),
compactions: (live?.compactions ?? []).map((status) => ({
id: status.taskId,
trigger: status.trigger,
blocking: status.blocking,
attempt: status.attempt,
...(status.retry ? { retry: status.retry } : {}),
})),
queued: (inbox?.items ?? []).flatMap((item) =>
item.mode === "write"
? []
: [
{
id: item.id,
mode: item.mode,
text:
messageData(item.entry?.data)?.text ??
(item.entry && peerMessage(item.entry.kind, item.entry.data)?.text) ??
text(item.content),
},
],
),
agent: (view.docs["pi.agent"] ?? {}) as AgentState,
};
}