Files
lvmh/web/src/derive.ts
T

366 lines
9.7 KiB
TypeScript

import type { EventFrame } from "./protocol";
// ---------- event list merge ----------
export function mergeEvents(
existing: EventFrame[],
incoming: EventFrame[],
): EventFrame[] {
const bySeq = new Map<number, EventFrame>();
for (const e of existing) bySeq.set(e.seq, e);
for (const e of incoming) bySeq.set(e.seq, e);
return Array.from(bySeq.values()).sort((a, b) => a.seq - b.seq);
}
/** Highest persisted seq — deltas (`message_update`) are not persisted, so they
* must not advance the refetch cursor. */
export function lastPersistedSeq(events: EventFrame[]): number {
let max = 0;
for (const e of events)
if (e.type !== "message_update" && e.seq > max) max = e.seq;
return max;
}
// ---------- chat view derivation ----------
export interface ToolState {
id: string;
name: string;
args: string;
running: boolean;
isError: boolean;
preview: string;
}
export interface ChatMessage {
key: string;
role: "user" | "assistant" | "system" | "toolResult";
text: string;
thinking: string | null;
toolCalls: { id: string; name: string; argsJson: string }[];
toolCallId: string | null;
streaming: boolean;
/** envelope ts of the persisted message_end (0 while streaming) */
ts: number;
/** true for daemon/plugin error notices: rendered as a notice line, not a chat bubble */
notice?: boolean;
}
export interface ChatDerivation {
messages: ChatMessage[];
tools: Map<string, ToolState>;
busy: boolean;
usage: { inputTokens: number; outputTokens: number; totalCost: number };
}
// The plugin mirrors pi extension events verbatim: tool args arrive as a raw
// JSON object (or string in older frames). Normalizing at this boundary keeps
// the render layer string-only; regression: object args crashed React (#31).
export function argsText(args: unknown): string {
if (typeof args === "string") return args;
if (args === null || args === undefined) return "";
try {
return JSON.stringify(args, null, 2) ?? "";
} catch {
return String(args);
}
}
export function deriveChat(events: EventFrame[]): ChatDerivation {
const messages: ChatMessage[] = [];
const tools = new Map<string, ToolState>();
let stream: { id: string; text: string } | null = null;
let busy = false;
const usage = { inputTokens: 0, outputTokens: 0, totalCost: 0 };
for (const e of events) {
switch (e.type) {
case "tool_execution_start": {
if (e.toolCallId !== undefined) {
tools.set(e.toolCallId, {
id: e.toolCallId,
name: e.toolName ?? "tool",
args: argsText(e.args),
running: true,
isError: false,
preview: "",
});
}
break;
}
case "tool_execution_end": {
const t =
e.toolCallId !== undefined ? tools.get(e.toolCallId) : undefined;
if (t !== undefined) {
t.running = false;
t.isError = e.isError ?? false;
t.preview = e.resultPreview ?? "";
}
break;
}
case "agent_start":
busy = true;
break;
case "agent_end": {
const u = e.usage;
if (u !== undefined) {
usage.inputTokens += u.inputTokens ?? 0;
usage.outputTokens += u.outputTokens ?? 0;
usage.totalCost += u.totalCost ?? 0;
}
break;
}
case "agent_settled":
busy = false;
break;
case "message_start":
if (e.message?.role === "assistant")
stream = { id: e.message.id, text: "" };
break;
case "message_update":
if (stream !== null) stream.text += e.delta ?? "";
break;
case "message_end": {
const m = e.message;
if (m !== undefined) {
if (stream !== null && stream.id === m.id) stream = null;
messages.push({
key: `msg-${e.seq}`,
role: m.role,
text: m.text,
thinking: m.thinking,
toolCalls: m.toolCalls ?? [],
toolCallId: m.toolCallId,
streaming: false,
ts: e.ts ?? 0,
});
}
break;
}
case "error_notice":
messages.push({
key: `notice-${e.seq}`,
role: "system",
text: e.reason ?? "unknown error",
thinking: null,
toolCalls: [],
toolCallId: null,
streaming: false,
ts: e.ts ?? 0,
notice: true,
});
break;
default:
break;
}
}
if (stream !== null) {
messages.push({
key: `stream-${stream.id}`,
role: "assistant",
text: stream.text,
thinking: null,
toolCalls: [],
toolCallId: null,
streaming: true,
ts: 0,
});
}
return { messages, tools, busy: busy || stream !== null, usage };
}
// ---------- todo / subagent derivation (client-side, per PROTOCOL.md) ----------
export type TodoStatus = "pending" | "in-progress" | "completed";
export interface TodoItem {
content: string;
status: TodoStatus;
deleted: boolean;
}
export interface SubagentRun {
key: string;
name: string;
running: boolean;
isError: boolean;
}
export interface TaskDerivation {
todos: TodoItem[];
subagents: SubagentRun[];
workingTools: ToolState[];
}
const TODO_TOOL = "todo";
const SUBAGENT_TOOL = "subagent";
function parseJson(raw: unknown): unknown {
if (typeof raw === "object" && raw !== null) return raw; // already-decoded args
if (typeof raw !== "string" || raw.length === 0) return undefined;
try {
return JSON.parse(raw) as unknown;
} catch {
return undefined;
}
}
function normalizeStatus(raw: unknown): TodoStatus {
if (typeof raw !== "string") return "pending";
switch (raw.toLowerCase()) {
case "in_progress":
case "in-progress":
case "inprogress":
case "in progress":
case "doing":
case "started":
return "in-progress";
case "completed":
case "complete":
case "done":
return "completed";
default:
return "pending";
}
}
function extractSnapshot(
raw: unknown,
): { content: string; status: TodoStatus }[] | null {
let arr: unknown = raw;
if (Array.isArray(raw) === false && raw !== null && typeof raw === "object") {
const o = raw as Record<string, unknown>;
const nested = o.todos ?? o.items ?? o.tasks ?? o.list;
if (Array.isArray(nested)) arr = nested;
}
if (Array.isArray(arr) === false) return null;
const items: { content: string; status: TodoStatus }[] = [];
for (const entry of arr) {
if (typeof entry === "string") {
items.push({ content: entry, status: "pending" });
continue;
}
if (entry !== null && typeof entry === "object") {
const o = entry as Record<string, unknown>;
const content = o.content ?? o.title ?? o.text ?? o.subject ?? o.summary;
if (typeof content === "string" && content.length > 0) {
items.push({ content, status: normalizeStatus(o.status) });
}
}
}
return items.length > 0 ? items : null;
}
export function deriveTasks(events: EventFrame[]): TaskDerivation {
const toolNames = new Map<string, string>(); // toolCallId -> toolName
for (const e of events) {
if (e.type === "tool_execution_start" && e.toolCallId !== undefined) {
toolNames.set(e.toolCallId, e.toolName ?? "");
}
}
// todo snapshots, seq-ordered: args from execution start, result from execution end + toolResult message
const snapshots: {
seq: number;
items: { content: string; status: TodoStatus }[];
}[] = [];
const subagents: SubagentRun[] = [];
const runningTools: ToolState[] = [];
for (const e of events) {
if (e.type === "tool_execution_start" && e.toolCallId !== undefined) {
const name = e.toolName ?? "";
if (name === TODO_TOOL) {
const snap = extractSnapshot(parseJson(e.args));
if (snap !== null) snapshots.push({ seq: e.seq, items: snap });
} else if (name === SUBAGENT_TOOL) {
const parsed = parseJson(e.args);
const o =
parsed !== null && typeof parsed === "object"
? (parsed as Record<string, unknown>)
: {};
const nameField =
o.agentName ?? o.agent ?? o.name ?? o.agentType ?? o.role;
subagents.push({
key: e.toolCallId,
name:
typeof nameField === "string" && nameField.length > 0
? nameField
: "subagent",
running: true,
isError: false,
});
} else {
runningTools.push({
id: e.toolCallId,
name,
args: argsText(e.args),
running: true,
isError: false,
preview: "",
});
}
} else if (e.type === "tool_execution_end" && e.toolCallId !== undefined) {
const name = toolNames.get(e.toolCallId) ?? "";
if (name === SUBAGENT_TOOL) {
const run = subagents.find((s) => s.key === e.toolCallId);
if (run !== undefined) {
run.running = false;
run.isError = e.isError ?? false;
}
} else if (name !== TODO_TOOL) {
const t = runningTools.find((w) => w.id === e.toolCallId);
if (t !== undefined) {
t.running = false;
t.isError = e.isError ?? false;
t.preview = e.resultPreview ?? "";
}
}
if (name === TODO_TOOL) {
const snap = extractSnapshot(parseJson(e.resultPreview));
if (snap !== null) snapshots.push({ seq: e.seq, items: snap });
}
} else if (
e.type === "message_end" &&
e.message?.role === "toolResult" &&
e.message.toolCallId !== null
) {
const name = toolNames.get(e.message.toolCallId) ?? "";
if (name === TODO_TOOL) {
const snap = extractSnapshot(parseJson(e.message.text));
if (snap !== null) snapshots.push({ seq: e.seq, items: snap });
}
}
}
// todos: latest snapshot wins; earlier items missing from it are deleted
const todos: TodoItem[] = [];
if (snapshots.length > 0) {
snapshots.sort((a, b) => a.seq - b.seq);
const latest = snapshots[snapshots.length - 1]?.items ?? [];
const seen = new Map<string, TodoStatus>();
for (const snap of snapshots) {
for (const item of snap.items)
if (!seen.has(item.content)) seen.set(item.content, item.status);
}
for (const item of latest) {
todos.push({
content: item.content,
status: item.status,
deleted: false,
});
seen.delete(item.content);
}
for (const [content] of seen)
todos.push({ content, status: "pending", deleted: true });
}
return {
todos,
subagents,
workingTools: runningTools.filter((t) => t.running),
};
}