408 lines
13 KiB
TypeScript
408 lines
13 KiB
TypeScript
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
|
|
import { normalizeCursorStreamLine } from "../shared/stream.js";
|
|
|
|
function safeJsonParse(text: string): unknown {
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function asString(value: unknown, fallback = ""): string {
|
|
return typeof value === "string" ? value : fallback;
|
|
}
|
|
|
|
function asNumber(value: unknown, fallback = 0): number {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
}
|
|
|
|
function stringifyUnknown(value: unknown): string {
|
|
if (typeof value === "string") return value;
|
|
if (value === null || value === undefined) return "";
|
|
try {
|
|
return JSON.stringify(value, null, 2);
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
/** Max chars of stdout/stderr to show in run log for shell tool results. */
|
|
const SHELL_OUTPUT_TRUNCATE = 2000;
|
|
|
|
/**
|
|
* Format shell tool result for run log: exit code + stdout/stderr (truncated).
|
|
* If the result is not a shell-shaped object, returns full stringify.
|
|
*/
|
|
function formatShellToolResultForLog(result: unknown): string {
|
|
const obj = asRecord(result);
|
|
if (!obj) return stringifyUnknown(result);
|
|
const success = asRecord(obj.success);
|
|
if (!success) return stringifyUnknown(result);
|
|
const exitCode = asNumber(success.exitCode, NaN);
|
|
const stdout = asString(success.stdout).trim();
|
|
const stderr = asString(success.stderr).trim();
|
|
const hasShellShape = Number.isFinite(exitCode) || stdout.length > 0 || stderr.length > 0;
|
|
if (!hasShellShape) return stringifyUnknown(result);
|
|
|
|
const lines: string[] = [];
|
|
if (Number.isFinite(exitCode)) lines.push(`exit ${exitCode}`);
|
|
if (stdout) {
|
|
const out = stdout.length > SHELL_OUTPUT_TRUNCATE ? stdout.slice(0, SHELL_OUTPUT_TRUNCATE) + "\n... (truncated)" : stdout;
|
|
lines.push("<stdout>");
|
|
lines.push(out);
|
|
}
|
|
if (stderr) {
|
|
const err = stderr.length > SHELL_OUTPUT_TRUNCATE ? stderr.slice(0, SHELL_OUTPUT_TRUNCATE) + "\n... (truncated)" : stderr;
|
|
lines.push("<stderr>");
|
|
lines.push(err);
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
|
|
/** Return compact input for run log when tool is shell/shellToolCall (command only). */
|
|
function compactShellToolInput(rawInput: unknown, payload?: Record<string, unknown>): unknown {
|
|
const cmd = asString(payload?.command ?? asRecord(rawInput)?.command);
|
|
if (cmd) return { command: cmd };
|
|
return rawInput;
|
|
}
|
|
|
|
function parseUserMessage(messageRaw: unknown, ts: string): TranscriptEntry[] {
|
|
if (typeof messageRaw === "string") {
|
|
const text = messageRaw.trim();
|
|
return text ? [{ kind: "user", ts, text }] : [];
|
|
}
|
|
|
|
const message = asRecord(messageRaw);
|
|
if (!message) return [];
|
|
|
|
const entries: TranscriptEntry[] = [];
|
|
const directText = asString(message.text).trim();
|
|
if (directText) entries.push({ kind: "user", ts, text: directText });
|
|
|
|
const content = Array.isArray(message.content) ? message.content : [];
|
|
for (const partRaw of content) {
|
|
const part = asRecord(partRaw);
|
|
if (!part) continue;
|
|
const type = asString(part.type).trim();
|
|
if (type !== "output_text" && type !== "text") continue;
|
|
const text = asString(part.text).trim();
|
|
if (text) entries.push({ kind: "user", ts, text });
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
function parseAssistantMessage(messageRaw: unknown, ts: string): TranscriptEntry[] {
|
|
if (typeof messageRaw === "string") {
|
|
const text = messageRaw.trim();
|
|
return text ? [{ kind: "assistant", ts, text }] : [];
|
|
}
|
|
|
|
const message = asRecord(messageRaw);
|
|
if (!message) return [];
|
|
|
|
const entries: TranscriptEntry[] = [];
|
|
const directText = asString(message.text).trim();
|
|
if (directText) {
|
|
entries.push({ kind: "assistant", ts, text: directText });
|
|
}
|
|
|
|
const content = Array.isArray(message.content) ? message.content : [];
|
|
for (const partRaw of content) {
|
|
const part = asRecord(partRaw);
|
|
if (!part) continue;
|
|
const type = asString(part.type).trim();
|
|
|
|
if (type === "output_text" || type === "text") {
|
|
const text = asString(part.text).trim();
|
|
if (text) entries.push({ kind: "assistant", ts, text });
|
|
continue;
|
|
}
|
|
|
|
if (type === "thinking") {
|
|
const text = asString(part.text).trim();
|
|
if (text) entries.push({ kind: "thinking", ts, text });
|
|
continue;
|
|
}
|
|
|
|
if (type === "tool_call") {
|
|
const name = asString(part.name, asString(part.tool, "tool"));
|
|
const rawInput = part.input ?? part.arguments ?? part.args ?? {};
|
|
const input =
|
|
name === "shellToolCall" || name === "shell"
|
|
? compactShellToolInput(rawInput, asRecord(rawInput) ?? undefined)
|
|
: rawInput;
|
|
entries.push({
|
|
kind: "tool_call",
|
|
ts,
|
|
name,
|
|
toolUseId:
|
|
asString(part.tool_use_id) ||
|
|
asString(part.toolUseId) ||
|
|
asString(part.call_id) ||
|
|
asString(part.id) ||
|
|
undefined,
|
|
input,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (type === "tool_result") {
|
|
const toolUseId =
|
|
asString(part.tool_use_id) ||
|
|
asString(part.toolUseId) ||
|
|
asString(part.call_id) ||
|
|
asString(part.id) ||
|
|
"tool_result";
|
|
const rawOutput = part.output ?? part.result ?? part.text;
|
|
const contentText =
|
|
typeof rawOutput === "object" && rawOutput !== null
|
|
? formatShellToolResultForLog(rawOutput)
|
|
: asString(rawOutput) || stringifyUnknown(rawOutput);
|
|
const isError = part.is_error === true || asString(part.status).toLowerCase() === "error";
|
|
entries.push({
|
|
kind: "tool_result",
|
|
ts,
|
|
toolUseId,
|
|
content: contentText,
|
|
isError,
|
|
});
|
|
}
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
function parseCursorToolCallEvent(event: Record<string, unknown>, ts: string): TranscriptEntry[] {
|
|
const subtype = asString(event.subtype).trim().toLowerCase();
|
|
const callId =
|
|
asString(event.call_id) ||
|
|
asString(event.callId) ||
|
|
asString(event.id) ||
|
|
"tool_call";
|
|
const toolCall = asRecord(event.tool_call ?? event.toolCall);
|
|
if (!toolCall) {
|
|
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }];
|
|
}
|
|
|
|
const [toolName] = Object.keys(toolCall);
|
|
if (!toolName) {
|
|
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }];
|
|
}
|
|
const payload = asRecord(toolCall[toolName]) ?? {};
|
|
const rawInput = payload.args ?? asRecord(payload.function)?.arguments ?? payload;
|
|
const isShellTool = toolName === "shellToolCall" || toolName === "shell";
|
|
const input = isShellTool ? compactShellToolInput(rawInput, payload) : rawInput;
|
|
|
|
if (subtype === "started" || subtype === "start") {
|
|
return [{
|
|
kind: "tool_call",
|
|
ts,
|
|
name: toolName,
|
|
toolUseId: callId,
|
|
input,
|
|
}];
|
|
}
|
|
|
|
if (subtype === "completed" || subtype === "complete" || subtype === "finished") {
|
|
const result =
|
|
payload.result ??
|
|
payload.output ??
|
|
payload.error ??
|
|
asRecord(payload.function)?.result ??
|
|
asRecord(payload.function)?.output;
|
|
const isError =
|
|
event.is_error === true ||
|
|
payload.is_error === true ||
|
|
asString(payload.status).toLowerCase() === "error" ||
|
|
asString(payload.status).toLowerCase() === "failed" ||
|
|
asString(payload.status).toLowerCase() === "cancelled" ||
|
|
payload.error !== undefined;
|
|
const content =
|
|
result !== undefined
|
|
? isShellTool
|
|
? formatShellToolResultForLog(result)
|
|
: stringifyUnknown(result)
|
|
: `${toolName} completed`;
|
|
return [{
|
|
kind: "tool_result",
|
|
ts,
|
|
toolUseId: callId,
|
|
content,
|
|
isError,
|
|
}];
|
|
}
|
|
|
|
return [{
|
|
kind: "system",
|
|
ts,
|
|
text: `tool_call${subtype ? ` (${subtype})` : ""}: ${toolName}`,
|
|
}];
|
|
}
|
|
|
|
export function parseCursorStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
|
const normalized = normalizeCursorStreamLine(line);
|
|
if (!normalized.line) return [];
|
|
|
|
const parsed = asRecord(safeJsonParse(normalized.line));
|
|
if (!parsed) {
|
|
return [{ kind: "stdout", ts, text: normalized.line }];
|
|
}
|
|
|
|
const type = asString(parsed.type);
|
|
|
|
if (type === "system") {
|
|
const subtype = asString(parsed.subtype);
|
|
if (subtype === "init") {
|
|
const sessionId =
|
|
asString(parsed.session_id) ||
|
|
asString(parsed.sessionId) ||
|
|
asString(parsed.sessionID);
|
|
return [{ kind: "init", ts, model: asString(parsed.model, "cursor"), sessionId }];
|
|
}
|
|
return [{ kind: "system", ts, text: subtype ? `system: ${subtype}` : "system" }];
|
|
}
|
|
|
|
if (type === "assistant") {
|
|
const entries = parseAssistantMessage(parsed.message, ts);
|
|
return entries.length > 0 ? entries : [{ kind: "assistant", ts, text: asString(parsed.result) }];
|
|
}
|
|
|
|
if (type === "user") {
|
|
return parseUserMessage(parsed.message, ts);
|
|
}
|
|
|
|
if (type === "thinking") {
|
|
const textFromTopLevel = asString(parsed.text);
|
|
const textFromDelta = asString(asRecord(parsed.delta)?.text);
|
|
const text = textFromTopLevel.length > 0 ? textFromTopLevel : textFromDelta;
|
|
const subtype = asString(parsed.subtype).trim().toLowerCase();
|
|
const isDelta = subtype === "delta" || asRecord(parsed.delta) !== null;
|
|
if (!text.trim()) return [];
|
|
return [{ kind: "thinking", ts, text: isDelta ? text : text.trim(), ...(isDelta ? { delta: true } : {}) }];
|
|
}
|
|
|
|
if (type === "tool_call") {
|
|
return parseCursorToolCallEvent(parsed, ts);
|
|
}
|
|
|
|
if (type === "result") {
|
|
const usage = asRecord(parsed.usage);
|
|
const inputTokens = asNumber(usage?.input_tokens, asNumber(usage?.inputTokens));
|
|
const outputTokens = asNumber(usage?.output_tokens, asNumber(usage?.outputTokens));
|
|
const cachedTokens = asNumber(
|
|
usage?.cached_input_tokens,
|
|
asNumber(usage?.cachedInputTokens, asNumber(usage?.cache_read_input_tokens)),
|
|
);
|
|
const subtype = asString(parsed.subtype, "result");
|
|
const errors = Array.isArray(parsed.errors)
|
|
? parsed.errors.map((value) => stringifyUnknown(value)).filter(Boolean)
|
|
: [];
|
|
const errorText = asString(parsed.error).trim();
|
|
if (errorText) errors.push(errorText);
|
|
const isError = parsed.is_error === true || subtype === "error" || subtype === "failed";
|
|
|
|
return [{
|
|
kind: "result",
|
|
ts,
|
|
text: asString(parsed.result),
|
|
inputTokens,
|
|
outputTokens,
|
|
cachedTokens,
|
|
costUsd: asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost))),
|
|
subtype,
|
|
isError,
|
|
errors,
|
|
}];
|
|
}
|
|
|
|
if (type === "error") {
|
|
const message = asString(parsed.message) || stringifyUnknown(parsed.error ?? parsed.detail) || normalized.line;
|
|
return [{ kind: "stderr", ts, text: message }];
|
|
}
|
|
|
|
// Compatibility with older stream-json event shapes.
|
|
if (type === "step_start") {
|
|
const sessionId = asString(parsed.sessionID);
|
|
return [{ kind: "system", ts, text: `step started${sessionId ? ` (${sessionId})` : ""}` }];
|
|
}
|
|
|
|
if (type === "text") {
|
|
const part = asRecord(parsed.part);
|
|
const text = asString(part?.text).trim();
|
|
if (!text) return [];
|
|
return [{ kind: "assistant", ts, text }];
|
|
}
|
|
|
|
if (type === "tool_use") {
|
|
const part = asRecord(parsed.part);
|
|
const toolUseId = asString(part?.callID, asString(part?.id, "tool_use"));
|
|
const toolName = asString(part?.tool, "tool");
|
|
const state = asRecord(part?.state);
|
|
const input = state?.input ?? {};
|
|
const output = asString(state?.output).trim();
|
|
const status = asString(state?.status).trim();
|
|
const exitCode = asNumber(asRecord(state?.metadata)?.exit, NaN);
|
|
const isError =
|
|
status === "failed" ||
|
|
status === "error" ||
|
|
status === "cancelled" ||
|
|
(Number.isFinite(exitCode) && exitCode !== 0);
|
|
|
|
const entries: TranscriptEntry[] = [
|
|
{
|
|
kind: "tool_call",
|
|
ts,
|
|
name: toolName,
|
|
input,
|
|
},
|
|
];
|
|
|
|
if (status || output) {
|
|
const lines: string[] = [];
|
|
if (status) lines.push(`status: ${status}`);
|
|
if (Number.isFinite(exitCode)) lines.push(`exit: ${exitCode}`);
|
|
if (output) {
|
|
if (lines.length > 0) lines.push("");
|
|
lines.push(output);
|
|
}
|
|
entries.push({
|
|
kind: "tool_result",
|
|
ts,
|
|
toolUseId,
|
|
content: lines.join("\n").trim() || "tool completed",
|
|
isError,
|
|
});
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
if (type === "step_finish") {
|
|
const part = asRecord(parsed.part);
|
|
const tokens = asRecord(part?.tokens);
|
|
const cache = asRecord(tokens?.cache);
|
|
const reason = asString(part?.reason);
|
|
return [{
|
|
kind: "result",
|
|
ts,
|
|
text: reason,
|
|
inputTokens: asNumber(tokens?.input),
|
|
outputTokens: asNumber(tokens?.output),
|
|
cachedTokens: asNumber(cache?.read),
|
|
costUsd: asNumber(part?.cost),
|
|
subtype: reason || "step_finish",
|
|
isError: reason === "error" || reason === "failed",
|
|
errors: [],
|
|
}];
|
|
}
|
|
|
|
return [{ kind: "stdout", ts, text: normalized.line }];
|
|
}
|