Files
paperclip/packages/adapters/gemini-local/src/server/parse.ts
Aaron 6bfe0b8422 Default Gemini adapter to yolo mode and add API access prompt note
Gemini CLI only registers run_shell_command in --approval-mode yolo.
Non-yolo modes don't expose it at all, making Paperclip API calls
impossible. Always pass --approval-mode yolo and remove the now-unused
policy engine code, approval mode config, and UI toggles.

Add a "Paperclip API access note" to the prompt with curl examples
via run_shell_command, since the universal SKILL.md is tool-agnostic.

Also extract structured question events from Gemini assistant messages
to support interactive approval flows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 01:45:08 +00:00

264 lines
8.5 KiB
TypeScript

import { asNumber, asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils";
function collectMessageText(message: unknown): string[] {
if (typeof message === "string") {
const trimmed = message.trim();
return trimmed ? [trimmed] : [];
}
const record = parseObject(message);
const direct = asString(record.text, "").trim();
const lines: string[] = direct ? [direct] : [];
const content = Array.isArray(record.content) ? record.content : [];
for (const partRaw of content) {
const part = parseObject(partRaw);
const type = asString(part.type, "").trim();
if (type === "output_text" || type === "text" || type === "content") {
const text = asString(part.text, "").trim() || asString(part.content, "").trim();
if (text) lines.push(text);
}
}
return lines;
}
function readSessionId(event: Record<string, unknown>): string | null {
return (
asString(event.session_id, "").trim() ||
asString(event.sessionId, "").trim() ||
asString(event.sessionID, "").trim() ||
asString(event.checkpoint_id, "").trim() ||
asString(event.thread_id, "").trim() ||
null
);
}
function asErrorText(value: unknown): string {
if (typeof value === "string") return value;
const rec = parseObject(value);
const message =
asString(rec.message, "") ||
asString(rec.error, "") ||
asString(rec.code, "") ||
asString(rec.detail, "");
if (message) return message;
try {
return JSON.stringify(rec);
} catch {
return "";
}
}
function accumulateUsage(
target: { inputTokens: number; cachedInputTokens: number; outputTokens: number },
usageRaw: unknown,
) {
const usage = parseObject(usageRaw);
const usageMetadata = parseObject(usage.usageMetadata);
const source = Object.keys(usageMetadata).length > 0 ? usageMetadata : usage;
target.inputTokens += asNumber(
source.input_tokens,
asNumber(source.inputTokens, asNumber(source.promptTokenCount, 0)),
);
target.cachedInputTokens += asNumber(
source.cached_input_tokens,
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount, 0)),
);
target.outputTokens += asNumber(
source.output_tokens,
asNumber(source.outputTokens, asNumber(source.candidatesTokenCount, 0)),
);
}
export function parseGeminiJsonl(stdout: string) {
let sessionId: string | null = null;
const messages: string[] = [];
let errorMessage: string | null = null;
let costUsd: number | null = null;
let resultEvent: Record<string, unknown> | null = null;
let question: { prompt: string; choices: Array<{ key: string; label: string; description?: string }> } | null = null;
const usage = {
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
};
for (const rawLine of stdout.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const event = parseJson(line);
if (!event) continue;
const foundSessionId = readSessionId(event);
if (foundSessionId) sessionId = foundSessionId;
const type = asString(event.type, "").trim();
if (type === "assistant") {
messages.push(...collectMessageText(event.message));
const messageObj = parseObject(event.message);
const content = Array.isArray(messageObj.content) ? messageObj.content : [];
for (const partRaw of content) {
const part = parseObject(partRaw);
if (asString(part.type, "").trim() === "question") {
question = {
prompt: asString(part.prompt, "").trim(),
choices: (Array.isArray(part.choices) ? part.choices : []).map((choiceRaw) => {
const choice = parseObject(choiceRaw);
return {
key: asString(choice.key, "").trim(),
label: asString(choice.label, "").trim(),
description: asString(choice.description, "").trim() || undefined,
};
}),
};
break; // only one question per message
}
}
continue;
}
if (type === "result") {
resultEvent = event;
accumulateUsage(usage, event.usage ?? event.usageMetadata);
const resultText =
asString(event.result, "").trim() ||
asString(event.text, "").trim() ||
asString(event.response, "").trim();
if (resultText && messages.length === 0) messages.push(resultText);
costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd;
const isError = event.is_error === true || asString(event.subtype, "").toLowerCase() === "error";
if (isError) {
const text = asErrorText(event.error ?? event.message ?? event.result).trim();
if (text) errorMessage = text;
}
continue;
}
if (type === "error") {
const text = asErrorText(event.error ?? event.message ?? event.detail).trim();
if (text) errorMessage = text;
continue;
}
if (type === "system") {
const subtype = asString(event.subtype, "").trim().toLowerCase();
if (subtype === "error") {
const text = asErrorText(event.error ?? event.message ?? event.detail).trim();
if (text) errorMessage = text;
}
continue;
}
if (type === "text") {
const part = parseObject(event.part);
const text = asString(part.text, "").trim();
if (text) messages.push(text);
continue;
}
if (type === "step_finish" || event.usage || event.usageMetadata) {
accumulateUsage(usage, event.usage ?? event.usageMetadata);
costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd;
continue;
}
}
return {
sessionId,
summary: messages.join("\n\n").trim(),
usage,
costUsd,
errorMessage,
resultEvent,
question,
};
}
export function isGeminiUnknownSessionError(stdout: string, stderr: string): boolean {
const haystack = `${stdout}\n${stderr}`
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.join("\n");
return /unknown\s+session|session\s+.*\s+not\s+found|resume\s+.*\s+not\s+found|checkpoint\s+.*\s+not\s+found|cannot\s+resume|failed\s+to\s+resume/i.test(
haystack,
);
}
function extractGeminiErrorMessages(parsed: Record<string, unknown>): string[] {
const messages: string[] = [];
const errorMsg = asString(parsed.error, "").trim();
if (errorMsg) messages.push(errorMsg);
const raw = Array.isArray(parsed.errors) ? parsed.errors : [];
for (const entry of raw) {
if (typeof entry === "string") {
const msg = entry.trim();
if (msg) messages.push(msg);
continue;
}
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
const obj = entry as Record<string, unknown>;
const msg = asString(obj.message, "") || asString(obj.error, "") || asString(obj.code, "");
if (msg) {
messages.push(msg);
continue;
}
try {
messages.push(JSON.stringify(obj));
} catch {
// skip non-serializable entry
}
}
return messages;
}
export function describeGeminiFailure(parsed: Record<string, unknown>): string | null {
const status = asString(parsed.status, "");
const errors = extractGeminiErrorMessages(parsed);
const detail = errors[0] ?? "";
const parts = ["Gemini run failed"];
if (status) parts.push(`status=${status}`);
if (detail) parts.push(detail);
return parts.length > 1 ? parts.join(": ") : null;
}
const GEMINI_AUTH_REQUIRED_RE = /(?:not\s+authenticated|please\s+authenticate|api[_ ]?key\s+(?:required|missing|invalid)|authentication\s+required|unauthorized|invalid\s+credentials|not\s+logged\s+in|login\s+required|run\s+`?gemini\s+auth(?:\s+login)?`?\s+first)/i;
export function detectGeminiAuthRequired(input: {
parsed: Record<string, unknown> | null;
stdout: string;
stderr: string;
}): { requiresAuth: boolean } {
const errors = extractGeminiErrorMessages(input.parsed ?? {});
const messages = [...errors, input.stdout, input.stderr]
.join("\n")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const requiresAuth = messages.some((line) => GEMINI_AUTH_REQUIRED_RE.test(line));
return { requiresAuth };
}
export function isGeminiTurnLimitResult(
parsed: Record<string, unknown> | null | undefined,
exitCode?: number | null,
): boolean {
if (exitCode === 53) return true;
if (!parsed) return false;
const status = asString(parsed.status, "").trim().toLowerCase();
if (status === "turn_limit" || status === "max_turns") return true;
const error = asString(parsed.error, "").trim();
return /turn\s*limit|max(?:imum)?\s+turns?/i.test(error);
}