import fs from "node:fs/promises"; import path from "node:path"; import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { ClawdbotConfig } from "../../config/config.js"; import type { WorkspaceBootstrapFile } from "../workspace.js"; import type { EmbeddedContextFile } from "./types.js"; type ContentBlockWithSignature = { thought_signature?: unknown; [key: string]: unknown; }; /** * Strips Claude-style thought_signature fields from content blocks. * * Gemini expects thought signatures as base64-encoded bytes, but Claude stores message ids * like "msg_abc123...". We only strip "msg_*" to preserve any provider-valid signatures. */ export function stripThoughtSignatures(content: T): T { if (!Array.isArray(content)) return content; return content.map((block) => { if (!block || typeof block !== "object") return block; const rec = block as ContentBlockWithSignature; const signature = rec.thought_signature; if (typeof signature !== "string" || !signature.startsWith("msg_")) { return block; } const { thought_signature: _signature, ...rest } = rec; return rest; }) as T; } export const DEFAULT_BOOTSTRAP_MAX_CHARS = 20_000; const BOOTSTRAP_HEAD_RATIO = 0.7; const BOOTSTRAP_TAIL_RATIO = 0.2; type TrimBootstrapResult = { content: string; truncated: boolean; maxChars: number; originalLength: number; }; export function resolveBootstrapMaxChars(cfg?: ClawdbotConfig): number { const raw = cfg?.agents?.defaults?.bootstrapMaxChars; if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) { return Math.floor(raw); } return DEFAULT_BOOTSTRAP_MAX_CHARS; } function trimBootstrapContent( content: string, fileName: string, maxChars: number, ): TrimBootstrapResult { const trimmed = content.trimEnd(); if (trimmed.length <= maxChars) { return { content: trimmed, truncated: false, maxChars, originalLength: trimmed.length, }; } const headChars = Math.floor(maxChars * BOOTSTRAP_HEAD_RATIO); const tailChars = Math.floor(maxChars * BOOTSTRAP_TAIL_RATIO); const head = trimmed.slice(0, headChars); const tail = trimmed.slice(-tailChars); const marker = [ "", `[...truncated, read ${fileName} for full content...]`, `…(truncated ${fileName}: kept ${headChars}+${tailChars} chars of ${trimmed.length})…`, "", ].join("\n"); const contentWithMarker = [head, marker, tail].join("\n"); return { content: contentWithMarker, truncated: true, maxChars, originalLength: trimmed.length, }; } export async function ensureSessionHeader(params: { sessionFile: string; sessionId: string; cwd: string; }) { const file = params.sessionFile; try { await fs.stat(file); return; } catch { // create } await fs.mkdir(path.dirname(file), { recursive: true }); const sessionVersion = 2; const entry = { type: "session", version: sessionVersion, id: params.sessionId, timestamp: new Date().toISOString(), cwd: params.cwd, }; await fs.writeFile(file, `${JSON.stringify(entry)}\n`, "utf-8"); } export function buildBootstrapContextFiles( files: WorkspaceBootstrapFile[], opts?: { warn?: (message: string) => void; maxChars?: number }, ): EmbeddedContextFile[] { const maxChars = opts?.maxChars ?? DEFAULT_BOOTSTRAP_MAX_CHARS; const result: EmbeddedContextFile[] = []; for (const file of files) { if (file.missing) { result.push({ path: file.name, content: `[MISSING] Expected at: ${file.path}`, }); continue; } const trimmed = trimBootstrapContent(file.content ?? "", file.name, maxChars); if (!trimmed.content) continue; if (trimmed.truncated) { opts?.warn?.( `workspace bootstrap file ${file.name} is ${trimmed.originalLength} chars (limit ${trimmed.maxChars}); truncating in injected context`, ); } result.push({ path: file.name, content: trimmed.content, }); } return result; } export function sanitizeGoogleTurnOrdering(messages: AgentMessage[]): AgentMessage[] { const GOOGLE_TURN_ORDER_BOOTSTRAP_TEXT = "(session bootstrap)"; const first = messages[0] as { role?: unknown; content?: unknown } | undefined; const role = first?.role; const content = first?.content; if ( role === "user" && typeof content === "string" && content.trim() === GOOGLE_TURN_ORDER_BOOTSTRAP_TEXT ) { return messages; } if (role !== "assistant") return messages; // Cloud Code Assist rejects histories that begin with a model turn (tool call or text). // Prepend a tiny synthetic user turn so the rest of the transcript can be used. const bootstrap: AgentMessage = { role: "user", content: GOOGLE_TURN_ORDER_BOOTSTRAP_TEXT, timestamp: Date.now(), } as AgentMessage; return [bootstrap, ...messages]; }