Files
clawdbot/src/agents/pi-embedded-helpers/bootstrap.ts
Peter Steinberger c379191f80 chore: migrate to oxlint and oxfmt
Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
2026-01-14 15:02:19 +00:00

166 lines
4.8 KiB
TypeScript

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<T>(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];
}