cheap-agent 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,270 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import { isAbsolute, join, relative } from "node:path";
4
+ import { loadConfig } from "./config.js";
5
+ import { consentGate } from "./consent.js";
6
+ import { redact } from "./credentials.js";
7
+ import { records } from "./jobs.js";
8
+ import { holdOpencode } from "./opencode.js";
9
+ import { projectScripts, researchRuleset } from "./permissions.js";
10
+ import { normalisePath } from "./platform.js";
11
+ import { failBlock, renderResearch } from "./report.js";
12
+ import { chain, modelName, recordSpend, runRouted } from "./routing.js";
13
+ import { openSession, releaseDirectory } from "./worker.js";
14
+ import { createWorktree, projectRoot, removeWorktree } from "./workspace.js";
15
+ /**
16
+ * Research: one question about the codebase, answered by a worker that reads, searches and runs test commands but
17
+ * never edits (agent `cheapag-research`, ruleset `researchRuleset`), in a throwaway worktree of the developer's current
18
+ * state (uncommitted work included) so a command can't touch their tree. It runs alongside jobs. The worker reports a
19
+ * scout brief (ticket 23.4) in a fenced `cheapag-research` block in its final text; a claimed reproduction is checked
20
+ * against the commands it actually ran.
21
+ */
22
+ export const RESEARCH_AGENT = "cheapag-research";
23
+ export const MAX_WORDS = 600;
24
+ const DEFAULT_WORDS = 200;
25
+ /** At most this many refs: the refs line must stay within the 200-char line rule. */
26
+ const MAX_REFS = 5;
27
+ export async function research(input, ctx) {
28
+ const root = projectRoot(process.cwd());
29
+ // first: an unapproved repo gets CONSENT before any record, OpenCode boot or provider call
30
+ const gate = consentGate(root, ctx.provider.provider);
31
+ if ("block" in gate)
32
+ return gate.block;
33
+ const scope = [];
34
+ for (const path of (input.paths ?? "").split(",").map((p) => p.trim()).filter(Boolean)) {
35
+ const rel = normalisePath(relative(root, join(root, path)));
36
+ if (isAbsolute(path) || rel === ".." || rel.startsWith("../"))
37
+ return failBlock(`path ${path} must be inside the repository, relative to its root`);
38
+ if (!existsSync(join(root, path)))
39
+ return failBlock(`path ${path} not found`);
40
+ if (rel)
41
+ scope.push(rel); // "." is the whole repo: no scope
42
+ }
43
+ let config;
44
+ try {
45
+ config = loadConfig(root);
46
+ }
47
+ catch (err) {
48
+ return failBlock(err.message);
49
+ }
50
+ const { limits } = config;
51
+ const maxWords = Math.min(input.max_words ?? DEFAULT_WORDS, MAX_WORDS);
52
+ const opencode = await ctx.opencode(gate.route);
53
+ const release = holdOpencode();
54
+ const { id, dir } = newResearch(root);
55
+ const write = (name, value) => writeFileSync(join(dir, name), redact(typeof value === "string" ? value : JSON.stringify(value, null, 2) + "\n"));
56
+ // the research model, then its fallbacks, each only if the repo's allowlist kept it (src/consent.ts)
57
+ const models = chain(gate.route.research ?? gate.route.implement, gate.route.researchFallbacks);
58
+ const startedAt = new Date();
59
+ write("request.json", { ...input, scope, maxWords, models: [models.first, ...models.rest].map(modelName), startedAt });
60
+ const prompt = [
61
+ `Question: ${input.question}`,
62
+ scope.length && `Scope: look only under ${scope.join(", ")}; cite only files there.`,
63
+ REPRO_RULE,
64
+ `Answer in at most ${maxWords} words.`,
65
+ // repeated here because real models skipped the agent prompt's format (seen live): without it a right answer is UNSURE
66
+ BRIEF_FORMAT,
67
+ ].filter(Boolean).join("\n");
68
+ const files = new Set();
69
+ const ran = []; // the commands the worker actually ran: a claimed reproduction must be one of them
70
+ const spent = { turns: 0, costUsd: 0 };
71
+ let text = "";
72
+ let error;
73
+ let budget;
74
+ // set from event callbacks: keep TS from narrowing it to undefined
75
+ let stopped = undefined;
76
+ let worktree;
77
+ try {
78
+ worktree = createWorktree(root, id, config.link_dirs);
79
+ const { scripts, exact } = projectScripts(worktree.path);
80
+ const permission = researchRuleset({ scripts, exact: [...exact, ...(config.test_cmd ? [config.test_cmd] : [])], allowCommands: config.allow_commands });
81
+ const halt = new AbortController();
82
+ const stop = (why) => {
83
+ stopped ??= why;
84
+ halt.abort();
85
+ };
86
+ const timer = setTimeout(() => stop({ status: "FAIL", reason: `minute cap (${limits.max_minutes} min) reached` }), limits.max_minutes * 60_000);
87
+ const session = await openSession({
88
+ opencode, directory: worktree.path, model: models.first, agent: RESEARCH_AGENT, permission, signal: halt.signal,
89
+ onEvent: (event, turns) => {
90
+ appendFileSync(join(dir, "events.jsonl"), redact(JSON.stringify(event)) + "\n");
91
+ if (event.type === "message.part.updated" && event.properties.part.type === "tool") {
92
+ const { tool, state } = event.properties.part;
93
+ if (state.status === "completed" && tool === "read")
94
+ files.add(String(state.input.filePath));
95
+ if (state.status === "completed" && tool === "bash")
96
+ ran.push(String(state.input.command ?? ""));
97
+ if (state.status === "completed" || state.status === "error") {
98
+ ctx.progress(turns, `turn ${turns}: ${tool}`);
99
+ if (turns >= limits.max_turns)
100
+ stop({ status: "FAIL", reason: `turn cap (${limits.max_turns}) reached` });
101
+ }
102
+ }
103
+ if (event.type === "message.updated" && session.totals().usage.costUsd >= limits.max_cost_usd) {
104
+ stop({ status: "BUDGET", reason: `job cap ($${limits.max_cost_usd.toFixed(2)}) reached` });
105
+ }
106
+ },
107
+ });
108
+ try {
109
+ // a provider limit continues the same session on the next fallback (src/routing.ts), never waiting it out
110
+ ({ text, error, budget } = await runRouted(session, prompt, models, ctx.provider.provider));
111
+ }
112
+ finally {
113
+ clearTimeout(timer);
114
+ const totals = session.totals();
115
+ spent.turns = totals.turns;
116
+ spent.costUsd = totals.usage.costUsd;
117
+ try {
118
+ recordSpend(totals.spend); // research draws on the same Go windows as jobs
119
+ }
120
+ catch {
121
+ // the ledger only steers later escalations: never fail an answer over it
122
+ }
123
+ }
124
+ }
125
+ catch (err) {
126
+ error = err.message;
127
+ }
128
+ finally {
129
+ release(); // the session is done: a config change may reboot OpenCode now
130
+ if (worktree) {
131
+ await releaseDirectory(worktree.path); // release before the worktree is removed
132
+ removeWorktree(root, worktree);
133
+ }
134
+ }
135
+ const answer = stopped ? { status: stopped.status, answer: "", refs: [] }
136
+ : budget ? { status: "BUDGET", answer: "", refs: [] }
137
+ : error ? { status: "FAIL", answer: "", refs: [] }
138
+ : verifyRepro(parseAnswer(text, root, scope, maxWords), ran) ?? { status: "FAIL", answer: "", refs: [] };
139
+ const reason = stopped?.reason
140
+ ?? budget
141
+ ?? (error ? `worker error: ${error}` : answer.status === "FAIL" ? "the research worker gave no answer" : undefined);
142
+ const final = models.current;
143
+ const record = {
144
+ format: "cheapag/1", job: id, kind: "research", provider: ctx.provider.provider, question: input.question, scope,
145
+ ...answer, reason, filesRead: files.size, unlanded: unlandedJobs(root),
146
+ models: { requested: modelName(models.first), final: modelName(final) },
147
+ usage: spent,
148
+ timing: { startedAt, endedAt: new Date(), ms: Date.now() - startedAt.getTime() },
149
+ };
150
+ const block = renderResearch(record);
151
+ // research.json, not result.json: `jobs list` reads only job records
152
+ write("research.json", record);
153
+ write("result.txt", block + "\n");
154
+ return block;
155
+ }
156
+ /** Research verifies before it claims (ticket 23.4: unverified handoffs inject false confidence). */
157
+ export const REPRO_RULE = "If the question is about a failure or wrong behaviour, reproduce it: run the relevant test or command and report exactly what it showed. Never claim a reproduction you did not run.";
158
+ export const BRIEF_FORMAT = "End your reply with exactly one fenced ```cheapag-research block: status (answer or unsure), confidence (high, medium or low), files (path:start-end, most relevant first, comma-separated), repro (`command` → what it showed, or not attempted), dead ends (what you ruled out, or none), then notes: and your prose.";
159
+ /**
160
+ * A claimed reproduction stands only if its `command` is one the worker ran; otherwise it is marked unverified and the
161
+ * confidence drops to low, so Claude doesn't build on a guess.
162
+ */
163
+ export function verifyRepro(answer, ran) {
164
+ const repro = answer?.repro?.trim();
165
+ if (!answer || !repro || /^not attempted|^none$/i.test(repro))
166
+ return answer && { ...answer, repro: repro || undefined };
167
+ const norm = (c) => c.replace(/\s+/g, " ").trim();
168
+ const claimed = norm(repro.match(/`([^`]+)`/)?.[1] ?? "");
169
+ const verified = !!claimed && ran.some((c) => norm(c).includes(claimed) || claimed.includes(norm(c)));
170
+ return verified ? { ...answer, repro } : { ...answer, repro: `unverified (not run): ${repro}`, confidence: "low" };
171
+ }
172
+ /**
173
+ * Jobs whose changes research cannot see (D7 note): not landed, and either still in flight (RUNNING or BLOCKED: their
174
+ * changes may land later) or holding changes in a worktree that still exists (PARTIAL, FAIL, CONFLICT, TIMEOUT,
175
+ * BUDGET, CANCELLED with edits; `land apply` could still land them). A discarded or cleaned-up job, or one that changed
176
+ * nothing, has nothing to miss. Oldest first.
177
+ */
178
+ export function unlandedJobs(root) {
179
+ return records(root)
180
+ .filter((r) => !r.landed && (r.status === "RUNNING" || r.status === "BLOCKED" || (!!r.worktree && !!r.changed?.length && existsSync(join(root, r.worktree)))))
181
+ .sort((a, b) => String(a.timing?.startedAt ?? "").localeCompare(String(b.timing?.startedAt ?? "")))
182
+ .map((r) => r.job);
183
+ }
184
+ /**
185
+ * The worker's last fenced `cheapag-research` block, parsed leniently. Without one, its final text is the answer,
186
+ * UNSURE with low confidence (the worker never rated it). Nothing at all → undefined.
187
+ */
188
+ export function parseAnswer(text, root, scope, maxWords) {
189
+ const block = [...text.matchAll(/```cheapag-research[^\n]*\n([\s\S]*?)```/g)].at(-1)?.[1];
190
+ if (!block)
191
+ return text.trim() ? { status: "UNSURE", answer: capWords(text.trim(), maxWords), refs: refs(text, root, scope), confidence: "low" } : undefined;
192
+ const fields = {};
193
+ let key;
194
+ for (const line of block.split("\n")) {
195
+ const kv = line.match(/^\s*(status|confidence|refs|files|repro|dead[ _]ends|answer|notes):\s*(.*)$/i);
196
+ // files/notes are the brief's names (ticket 23.4); refs/answer still read
197
+ if (kv)
198
+ fields[(key = { files: "refs", notes: "answer", "dead ends": "deadEnds", dead_ends: "deadEnds" }[kv[1].toLowerCase()] ?? kv[1].toLowerCase())] = kv[2];
199
+ else if (key === "answer")
200
+ fields.answer += `\n${line}`; // the answer is prose: it may span lines
201
+ }
202
+ const answer = (fields.answer ?? "").trim();
203
+ if (!answer)
204
+ return undefined;
205
+ const listed = refs(fields.refs ?? "", root, scope);
206
+ const confidence = fields.confidence?.trim().toLowerCase().match(/^(high|medium|low)/)?.[1];
207
+ return {
208
+ status: /^unsure/i.test(fields.status?.trim() ?? "") ? "UNSURE" : "ANSWER",
209
+ answer: capWords(answer, maxWords),
210
+ // refs from the refs line; failing that, any cited in the prose
211
+ refs: listed.length ? listed : refs(answer, root, scope),
212
+ confidence: confidence ?? "low",
213
+ repro: fields.repro?.trim() || undefined,
214
+ deadEnds: fields.deadEnds?.trim() && !/^none\.?$/i.test(fields.deadEnds.trim()) ? fields.deadEnds.trim() : undefined,
215
+ };
216
+ }
217
+ /** The raw `path:line(-line)` candidates in text, before validation. Accepts a Windows drive letter and backslashes. */
218
+ export function refTokens(text) {
219
+ return [...text.matchAll(/((?:[A-Za-z]:)?[\w./\\@-]+):(\d+)(?:-(\d+))?/g)].map(([, path, from, to]) => ({ path, from, to }));
220
+ }
221
+ /**
222
+ * Validated `path:line(-line)` refs from free text, at most 5: the file exists in the main checkout, inside the repo
223
+ * (and the scope, when there is one), and has those lines. Absolute paths inside the repo are made relative.
224
+ */
225
+ function refs(text, root, scope) {
226
+ const out = [];
227
+ for (const { path, from, to } of refTokens(text)) {
228
+ const abs = isAbsolute(path) ? path : join(root, path);
229
+ const rel = normalisePath(relative(root, abs));
230
+ const start = Number(from);
231
+ const end = Number(to ?? from);
232
+ if (!rel || rel === ".." || rel.startsWith("../") || isAbsolute(rel) || start < 1 || end < start)
233
+ continue;
234
+ if (scope.length && !scope.some((s) => rel === s || rel.startsWith(`${s}/`)))
235
+ continue;
236
+ if (out.some((r) => r.path === rel && r.start === start && r.end === end))
237
+ continue;
238
+ try {
239
+ if (!statSync(abs).isFile())
240
+ continue;
241
+ const content = readFileSync(abs, "utf8");
242
+ const lines = content.split("\n").length - (content.endsWith("\n") ? 1 : 0);
243
+ if (end > lines)
244
+ continue;
245
+ }
246
+ catch {
247
+ continue; // no such file
248
+ }
249
+ out.push({ path: rel, start, end });
250
+ if (out.length === MAX_REFS)
251
+ break;
252
+ }
253
+ return out;
254
+ }
255
+ /** The text cut after `max` words, line breaks kept. */
256
+ function capWords(text, max) {
257
+ const words = [...text.matchAll(/\S+/g)];
258
+ return words.length <= max ? text : `${text.slice(0, words[max].index).trimEnd()} …`;
259
+ }
260
+ /** A fresh `r` + 4 base36 id whose folder (beside the job folders, D7 `log:`) does not exist yet. */
261
+ function newResearch(root) {
262
+ for (;;) {
263
+ const id = `r${randomBytes(4).readUInt32BE().toString(36).slice(-4).padStart(4, "0")}`;
264
+ const dir = join(root, ".cheapag", "jobs", id);
265
+ if (existsSync(dir))
266
+ continue;
267
+ mkdirSync(dir, { recursive: true });
268
+ return { id, dir };
269
+ }
270
+ }
@@ -0,0 +1,212 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { providerLabel } from "./credentials.js";
5
+ // verified on OpenCode 1.19.0: options.provider becomes OpenRouter's request-body routing preference, so upstreams that
6
+ // collect data are never used. Every OpenRouter model carries it.
7
+ const OPENROUTER_PRIVATE = { provider: { data_collection: "deny" } };
8
+ /**
9
+ * Default models per provider and role (spec: Models and limits): each list is the role's model, then its fallbacks.
10
+ * Go and DeepSeek ids are in OpenCode 1.19.0's bundled model catalog; OpenRouter's are on openrouter.ai/api/v1/models
11
+ * (checked 2026-10-16). Direct DeepSeek has no other models to fall back to.
12
+ */
13
+ export const ROLES = {
14
+ "opencode-go": {
15
+ implement: ["deepseek-v4.1-flash", "qwen3.7-plus", "glm-5.3", "kimi-k3"],
16
+ research: ["qwen3.8-flash", "deepseek-v4.1-flash"],
17
+ escalation: "deepseek-v4-pro",
18
+ },
19
+ openrouter: {
20
+ implement: ["deepseek/deepseek-v4.1-flash", "qwen/qwen3.7-plus", "z-ai/glm-5.3", "moonshotai/kimi-k3"],
21
+ research: ["qwen/qwen3.8-flash", "deepseek/deepseek-v4.1-flash"],
22
+ // GLM-5.3-Flash: 37% on Terminal-Bench 4.0 vs V4 Pro 14% (Vals, 2026-10-16), and cheaper ($0.05 in / $0.20 out)
23
+ escalation: "z-ai/glm-5.3-flash",
24
+ },
25
+ deepseek: { implement: ["deepseek-flash"], research: ["deepseek-flash"], escalation: "deepseek-v4-pro" },
26
+ };
27
+ /** `cheapag login`'s worker-model presets (ticket 27): a named `models` per provider; roles a preset leaves out keep the defaults above. */
28
+ export const PRESET_NAMES = ["cheapest", "balanced", "strongest"];
29
+ // balanced is the default route, Flash for every tier: GLM-5.3 cost Claude 1.5x fewer tokens than Flash on the tasks Flash
30
+ // lost (ticket 25) but showed no gain on the hard tier (26), so it is opt-in as strongest. cheapest is balanced until a
31
+ // cheaper worker than Flash exists (a tie reports balanced). Kimi K3 was dropped: $0.36 worker per task, BUDGET on half.
32
+ export const PRESETS = {
33
+ "opencode-go": {
34
+ cheapest: { implement: "opencode-go/deepseek-v4.1-flash" },
35
+ balanced: { implement: "opencode-go/deepseek-v4.1-flash" },
36
+ strongest: { implement: "opencode-go/glm-5.3", medium: "opencode-go/glm-5.3" },
37
+ },
38
+ openrouter: {
39
+ cheapest: { implement: "openrouter/deepseek/deepseek-v4.1-flash" },
40
+ balanced: { implement: "openrouter/deepseek/deepseek-v4.1-flash" },
41
+ strongest: { implement: "openrouter/z-ai/glm-5.3", medium: "openrouter/z-ai/glm-5.3" },
42
+ },
43
+ // direct DeepSeek has no GLM: strongest implements on V4 Pro
44
+ deepseek: {
45
+ cheapest: { implement: "deepseek/deepseek-flash" },
46
+ balanced: { implement: "deepseek/deepseek-flash" },
47
+ strongest: { implement: "deepseek/deepseek-v4-pro" },
48
+ },
49
+ };
50
+ /** The preset whose route `models` gives on this provider, else custom (`preset=` in ping and status); a tie names balanced, the default. */
51
+ export function presetOf(provider, models = {}) {
52
+ const key = (m) => JSON.stringify(fullRoute(provider, m));
53
+ return ["balanced", "cheapest", "strongest"].find((p) => key(PRESETS[provider][p]) === key(models)) ?? "custom";
54
+ }
55
+ const model = (provider, id) => provider === "openrouter" ? { providerID: provider, id, options: OPENROUTER_PRIVATE } : { providerID: provider, id };
56
+ /** Claude's rating of a task from the issue (ticket 26): easy < 15 min, medium < 1 h, hard > 1 h of an engineer new to the code. */
57
+ export const DIFFICULTIES = ["easy", "medium", "hard"];
58
+ /** The model a job implements on: hard takes the hard model, else the medium one; medium the medium one; easy or no hint `implement`. */
59
+ export const tierModel = (r, d) => (d === "hard" ? r.hard ?? r.medium : d === "medium" ? r.medium : undefined) ?? r.implement;
60
+ /** Every model a provider's worker may run, defaults overridden by the config, before the repo's allowlist filters them. */
61
+ export function fullRoute(provider, models = {}) {
62
+ const roles = ROLES[provider];
63
+ const own = (name) => (name?.startsWith(`${provider}/`) ? name.slice(provider.length + 1) : undefined);
64
+ const ownFallbacks = models.fallback?.map(own).filter((id) => !!id);
65
+ const [research, ...researchFallbacks] = [own(models.research) ?? roles.research[0], ...roles.research.slice(1)];
66
+ const tier = (name) => (own(name) ? model(provider, own(name)) : undefined);
67
+ return {
68
+ implement: model(provider, own(models.implement) ?? roles.implement[0]),
69
+ medium: tier(models.medium),
70
+ hard: tier(models.hard),
71
+ fallbacks: (models.fallback ? ownFallbacks : roles.implement.slice(1)).map((id) => model(provider, id)),
72
+ escalation: model(provider, own(models.escalation) ?? roles.escalation),
73
+ research: model(provider, research),
74
+ researchFallbacks: researchFallbacks.map((id) => model(provider, id)),
75
+ };
76
+ }
77
+ /** Every distinct model a route may send code to, first to last (for the config, the CONSENT block and `cheapag allow`). */
78
+ export const routeModels = (r) => unique([r.implement, r.medium, r.hard, r.research, r.escalation, ...r.fallbacks, ...r.researchFallbacks]);
79
+ const unique = (ms) => ms.filter((m, i) => !!m && ms.findIndex((o) => o && modelName(o) === modelName(m)) === i);
80
+ export const modelName = (m) => `${m.providerID}/${m.id}`;
81
+ export const chain = (first, fallbacks) => ({ first, current: first, rest: [...fallbacks], limits: [], used: [] });
82
+ const CONTINUE = "The previous model hit a usage limit and was stopped. Continue the current step from where it left off.";
83
+ /**
84
+ * One prompt on the chain's current model. On a provider limit the worker has already aborted the session (never waiting
85
+ * out the retry); the same session continues on the next fallback. `budget` is set when the job must stop as BUDGET:
86
+ * every allowed model is limited, or (OpenRouter) the credit is gone, which no other model can help with.
87
+ * With `escalation`, the prompt runs on that model alone: a limit there is returned (in `limit`), never fallen back from.
88
+ */
89
+ export async function runRouted(session, prompt, c, provider, escalation, interrupt) {
90
+ // the first model is the session's own; once moved, every prompt names the fallback explicitly
91
+ let result = await session.run(prompt, escalation ?? (c.current === c.first ? undefined : c.current), interrupt);
92
+ if (escalation && result.limit)
93
+ c.limits.push(result.limit);
94
+ while (result.limit && !escalation) {
95
+ c.limits.push(result.limit);
96
+ const next = c.rest.shift();
97
+ if (!next)
98
+ return { ...result, budget: exhausted(provider, c.limits) };
99
+ c.current = next;
100
+ c.used.push(modelName(next));
101
+ result = await session.run(CONTINUE, next, interrupt);
102
+ }
103
+ if (provider === "openrouter" && result.httpStatus === 402)
104
+ return { ...result, budget: "OpenRouter credits exhausted" };
105
+ return result;
106
+ }
107
+ /** The BUDGET reason once every allowed model is limited, with the earliest reset the provider gave. */
108
+ function exhausted(provider, limits) {
109
+ const ids = [...new Set(limits.map((l) => l.model.slice(l.model.indexOf("/") + 1)))].join(", ");
110
+ // OpenCode's next retry honours the provider's retry-after, i.e. the reset; a next only seconds away is just its backoff
111
+ // (no retry-after was sent), so then quote the provider's own text instead
112
+ const next = Math.min(...limits.map((l) => l.next).filter((n) => Number.isFinite(n) && n > Date.now() + 60_000));
113
+ const when = Number.isFinite(next) ? `~${clock(next)}` : limits.at(-1).message;
114
+ const go = limits.find((l) => l.reason === "account_rate_limit" || l.reason === "free_tier_limit");
115
+ if (go) {
116
+ // OpenCode words it "<limitName> usage limit reached. It will reset in …"
117
+ const window = go.reason === "free_tier_limit" ? "free tier" : go.message.match(/^(.+?) usage limit reached/i)?.[1].toLowerCase() ?? "usage";
118
+ return `Go ${window} window exhausted for ${ids} (resets ${when})`;
119
+ }
120
+ return `${providerLabel(provider)} rate limit on every allowed model: ${ids} (retry ${when})`;
121
+ }
122
+ /** Local time as HH:MM, with the date in front when it is not today. */
123
+ function clock(ms) {
124
+ const d = new Date(ms);
125
+ const hhmm = d.toTimeString().slice(0, 5);
126
+ return d.toDateString() === new Date().toDateString() ? hhmm : `${d.toLocaleDateString("en-US", { month: "short", day: "numeric" })} ${hhmm}`;
127
+ }
128
+ // ---- spend ledger ----
129
+ /**
130
+ * Go's monthly caps per model in USD (spec C4, the Go plan; models without a known cap are not tracked against one).
131
+ * shortcut: Go Plus caps (4x) are not known to Cheap Agent; add a plan setting if Go Plus users report early skips.
132
+ */
133
+ const GO_CAPS = {
134
+ "opencode-go/deepseek-v4.1-flash": 80, "opencode-go/deepseek-v4-pro": 15, "opencode-go/qwen3.8-max": 15,
135
+ "opencode-go/qwen3.8-flash": 30, "opencode-go/qwen3.7-plus": 60,
136
+ };
137
+ /** Go enforces 20% of the monthly cap per 5 h, 50% per week, 100% per month (spec C4); tracked here as rolling windows. */
138
+ const WINDOWS = [
139
+ { name: "5h", ms: 5 * 3_600_000, share: 0.2 },
140
+ { name: "weekly", ms: 7 * 86_400_000, share: 0.5 },
141
+ { name: "monthly", ms: 30 * 86_400_000, share: 1 },
142
+ ];
143
+ /**
144
+ * The ledger is per user, not per repo: Go's windows are per account, so every repo's jobs draw on the same caps.
145
+ * One JSON line per model per job: `{at, model, costUsd}`.
146
+ * shortcut: never pruned (a line per job and model); trim entries older than the monthly window if it ever grows large.
147
+ */
148
+ export const ledgerFile = () => join(process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"), "cheapag", "spend.jsonl");
149
+ export function recordSpend(spend) {
150
+ const lines = Object.entries(spend).filter(([, cost]) => cost > 0).map(([m, costUsd]) => JSON.stringify({ at: new Date().toISOString(), model: m, costUsd }) + "\n");
151
+ if (!lines.length)
152
+ return;
153
+ mkdirSync(dirname(ledgerFile()), { recursive: true });
154
+ appendFileSync(ledgerFile(), lines.join(""));
155
+ }
156
+ /** A model's fullest tracked window: the share of its cap used (plus `extra` not yet recorded); undefined without a known cap. */
157
+ export function capUse(name, extra = 0) {
158
+ const monthly = GO_CAPS[name];
159
+ if (!monthly)
160
+ return undefined;
161
+ const now = Date.now();
162
+ const entries = readLedger().filter((e) => e.model === name);
163
+ return WINDOWS.map(({ name: window, ms, share }) => {
164
+ const spent = extra + entries.filter((e) => now - e.at < ms).reduce((sum, e) => sum + e.costUsd, 0);
165
+ return { used: spent / (monthly * share), window, capUsd: monthly * share };
166
+ }).reduce((a, b) => (b.used > a.used ? b : a));
167
+ }
168
+ /** Tracked spend per model and window, with the window's cap where Go has one (for `cheapag status`); models unspent this month are left out. */
169
+ export function spendWindows(now = Date.now()) {
170
+ const ledger = readLedger();
171
+ return [...new Set(ledger.map((e) => e.model))].sort().flatMap((model) => {
172
+ const windows = WINDOWS.map(({ name: window, ms, share }) => ({
173
+ window,
174
+ spent: ledger.filter((e) => e.model === model && now - e.at < ms).reduce((sum, e) => sum + e.costUsd, 0),
175
+ capUsd: GO_CAPS[model] ? GO_CAPS[model] * share : undefined,
176
+ }));
177
+ return windows.at(-1).spent > 0 ? [{ model, windows }] : [];
178
+ });
179
+ }
180
+ function readLedger() {
181
+ if (!existsSync(ledgerFile()))
182
+ return [];
183
+ return readFileSync(ledgerFile(), "utf8").split("\n").flatMap((line) => {
184
+ try {
185
+ const e = JSON.parse(line);
186
+ return typeof e.model === "string" && typeof e.costUsd === "number" ? [{ model: e.model, costUsd: e.costUsd, at: Date.parse(e.at) }] : [];
187
+ }
188
+ catch {
189
+ return []; // a torn or damaged line only loses that entry
190
+ }
191
+ });
192
+ }
193
+ /**
194
+ * Why the escalation attempt is skipped, as its `tried:` line, or undefined to run it: not allowed by consent, limited
195
+ * earlier in this job, or at `skipAt` (E4 `escalation_skip_at`) or more of a tracked cap (spec A7: V4 Pro's Go cap is small).
196
+ */
197
+ export function escalationSkip(escalation, c, skipAt, spent) {
198
+ if (!escalation)
199
+ return "esc: skipped, the escalation model is not allowed by this repo's consent";
200
+ const name = modelName(escalation);
201
+ if (c.limits.some((l) => l.model === name))
202
+ return `esc: skipped, ${escalation.id} hit its usage limit in this job`;
203
+ const use = capUse(name, spent[name] ?? 0);
204
+ if (use && use.used >= skipAt)
205
+ return `esc: skipped, ${escalation.id} at ${Math.round(use.used * 100)}% of its tracked ${use.window} cap ($${use.capUsd.toFixed(2)})`;
206
+ return undefined;
207
+ }
208
+ /**
209
+ * Whether the job ran on pay-as-you-go balance (`billing=zen`). OpenCode reports no balance signal, so it is inferred:
210
+ * a Go model whose tracked spend, this job included, is past a window's cap kept answering, which only balance allows.
211
+ */
212
+ export const usedBalance = (spent) => Object.keys(spent).some((m) => (capUse(m)?.used ?? 0) > 1);
package/dist/rules.js ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The verification budget (ticket 23.3): Claude ran tests ~3 times per task, and again after an OK job. Part of the
3
+ * server instructions, and appended verbatim to the benchmark's baseline arm so both arms get the same rule.
4
+ */
5
+ export const VERIFY_RULE = "Tests: run only the test file or id the task concerns, once before if it names a failure and once after; never the whole suite; chain commands with &&.";
6
+ /**
7
+ * Trusting results (ticket 24.1): reading the diff of every bigger OK cost +28% tokens and caught no wrong fix. Ticket 30:
8
+ * unverified OKs were as correct as verified ones (83% vs 80%) yet re-checking them cost 229k tokens vs 63k, so every OK is
9
+ * trusted and the hook enforces it. Part of the server instructions.
10
+ */
11
+ export const REVIEW_RULE = "An OK is done: don't re-run its tests or read its diff or its test.";
12
+ /**
13
+ * Who finishes a step the worker could not (ticket 30). With Opus doing it, a failure costs at least what stock does,
14
+ * capping the saving near 1.9x; a Sonnet subagent working from the worker's found: note does it at a fraction of the
15
+ * usage. The hook enforces either: sonnet leaves Opus only Agent and cheapag's tools, self caps its tool calls.
16
+ */
17
+ export const TAKE_OVER_RULE = {
18
+ sonnet: "After a FAIL or PARTIAL (land apply a PARTIAL first), don't delegate it again or open files: give the step to your Agent tool with model: sonnet, passing the task, the user's words and the block's found:, test=, error: and worktree; report its answer, and if it did not finish, say what is left.",
19
+ self: "After a FAIL or PARTIAL, do the task yourself instead of delegating it again: start from its found:, test= and worktree, open only the files they name, no background runs.",
20
+ };
21
+ /**
22
+ * Delegate before reading (ticket 23.4): Claude read ~2 files before each delegation, so the code entered its context
23
+ * anyway and the worker only typed. Part of the server instructions.
24
+ */
25
+ export const SCOUT_RULE = "Don't open source files before delegating; the worker explores. To learn the code first, call research and pass its id as delegate brief:, not Read/Bash.";