scripts/ask.js
#!/usr/bin/env node
// ask.js — the interviewer's mouth and ears. Sends one user turn to a model via
// the Cloudflare Workers AI REST API, appends the exchange to the session file,
// and prints the response plus usage/cost. The agent never touches credentials
// directly; auth is resolved here from env or opencode's auth.json.
import fs from "node:fs";
import path from "node:path";
import { getCloudflareAuth, parseArgs, nowIso, PROTOCOL_VERSION, modelSlug, REPO_ROOT } from "./lib.js";
import { findModel } from "./models.js";
const { args, positional } = parseArgs(process.argv.slice(2), {
model: { takes: "value", default: null },
session: { takes: "value", default: null },
domain: { takes: "value", default: "ad-hoc" },
lens: { takes: "value", default: "ad-hoc" },
message: { takes: "value", default: null },
system: { takes: "value", default: null },
system_file: { takes: "value", default: null },
temperature: { takes: "value", default: "0.7" },
max_tokens: { takes: "value", default: "2048" },
show: { takes: "value", default: null },
new: { takes: "none", default: false },
});
if (args.show) {
if (!args.session) { console.error("--show requires --session"); process.exit(2); }
if (!fs.existsSync(args.session)) { console.error("session file not found"); process.exit(1); }
const s = JSON.parse(fs.readFileSync(args.session, "utf8"));
const n = parseInt(args.show, 10);
for (const t of s.turns.slice(-n)) {
console.log(`--- turn ${t.turn} [${t.role}] ---\n${t.content}\n`);
}
process.exit(0);
}
if (!args.model) { console.error("--model is required"); process.exit(2); }
if (!args.session) { console.error("--session is required"); process.exit(2); }
let message = args.message;
if (message === "-" || (!message && !process.stdin.isTTY)) {
message = fs.readFileSync(0, "utf8");
}
if (!message) { console.error("--message (text, or - for stdin) is required"); process.exit(2); }
const auth = getCloudflareAuth();
if (!auth.token) {
console.error("No Cloudflare API token found. Set CLOUDFLARE_API_TOKEN or populate " +
"~/.local/share/opencode/auth.json with a cloudflare-workers-ai key.");
process.exit(1);
}
if (!auth.account) {
console.error("CLOUDFLARE_ACCOUNT_ID is not set.");
process.exit(1);
}
const temperature = parseFloat(args.temperature);
const maxTokens = parseInt(args.max_tokens, 10);
// Load or create session
let session;
const exists = fs.existsSync(args.session);
if (exists && !args.new) {
session = JSON.parse(fs.readFileSync(args.session, "utf8"));
if (session.model_id !== args.model) {
console.error(`Session belongs to ${session.model_id}, refusing to ask ${args.model}. Use --new.`);
process.exit(1);
}
} else {
fs.mkdirSync(path.dirname(args.session), { recursive: true });
const seq = exists ? 2 : 1;
session = {
session_id: `${modelSlug(args.model)}.${args.domain}.${args.lens}.${seq}`,
model_id: args.model,
domain: args.domain,
lens: args.lens,
protocol_version: PROTOCOL_VERSION,
temperature,
started: nowIso(),
ended: null,
turns: [],
totals: { tokens_in: 0, tokens_out: 0, cost_usd_estimate: 0 },
};
}
// System prompt: --system > --system-file > protocol default
let system = args.system;
if (!system && args.system_file) system = fs.readFileSync(args.system_file, "utf8");
if (!system) system = fs.readFileSync(path.join(REPO_ROOT, "protocol/prompts/system.md"), "utf8");
const messages = [
{ role: "system", content: system },
...session.turns.map((t) => ({ role: t.role, content: t.content })),
{ role: "user", content: message },
];
const url = `https://api.cloudflare.com/client/v4/accounts/${auth.account}/ai/run/${args.model}`;
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${auth.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ messages, temperature, max_tokens: maxTokens }),
});
if (!res.ok) {
console.error(`API error ${res.status}: ${(await res.text()).slice(0, 500)}`);
process.exit(1);
}
const data = await res.json();
if (!data.success) {
console.error(`API returned failure: ${JSON.stringify(data.errors || data).slice(0, 500)}`);
process.exit(1);
}
const text =
data.result?.choices?.[0]?.message?.content ??
data.result?.response ??
data.result?.message?.content ??
"";
const usage = data.result?.usage ?? {};
const tokensIn = usage.prompt_tokens ?? null;
const tokensOut = usage.completion_tokens ?? null;
const neurons = usage.neurons ?? null;
const model = findModel(args.model);
let callCost = 0;
if (model && tokensIn != null && tokensOut != null) {
callCost = (tokensIn / 1e6) * model.in + (tokensOut / 1e6) * model.out;
session.totals.tokens_in += tokensIn;
session.totals.tokens_out += tokensOut;
session.totals.cost_usd_estimate += callCost;
}
const userTurnNo = session.turns.length + 1;
session.turns.push({ turn: userTurnNo, role: "user", content: message, tokens_in: tokensIn, tokens_out: null, timestamp: nowIso() });
session.turns.push({ turn: userTurnNo + 1, role: "assistant", content: text, tokens_in: null, tokens_out: tokensOut, timestamp: nowIso() });
fs.writeFileSync(args.session, JSON.stringify(session, null, 2) + "\n", "utf8");
console.log(text);
if (tokensIn != null) {
console.error(`\n[usage] in=${tokensIn} out=${tokensOut} call≈$${callCost.toFixed(4)} session≈$${session.totals.cost_usd_estimate.toFixed(4)} turns=${session.turns.length}${neurons != null ? ` neurons=${neurons}` : ""}`);
} else {
console.error(`\n[usage] not reported by API; turns=${session.turns.length}`);
}