166 lines
4.8 KiB
TypeScript
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];
|
|
}
|