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>
264 lines
8.5 KiB
TypeScript
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);
|
|
}
|