pgroast 0.0.0-stage → 0.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.
- package/LICENSE +202 -0
- package/README.md +173 -2
- package/bin/pgroast.mjs +19 -0
- package/data/precedents.sql.gz +0 -0
- package/docs/corpus.md +352 -0
- package/docs/design.md +540 -0
- package/docs/eval.md +245 -0
- package/docs/haas-paranoia-notes.md +223 -0
- package/invariants/README.md +15 -0
- package/invariants/nbtree.md +14 -0
- package/package.json +51 -4
- package/prompts/brief.md +32 -0
- package/prompts/committer-paranoia.md +149 -0
- package/prompts/critic.md +56 -0
- package/prompts/enrich.md +35 -0
- package/prompts/pushback-map.md +58 -0
- package/prompts/pushback-reduce.md +72 -0
- package/prompts/reflect.md +20 -0
- package/prompts/relevance.md +20 -0
- package/prompts/roaster-already-exists.md +23 -0
- package/prompts/roaster-common.md +88 -0
- package/prompts/roaster-demand.md +28 -0
- package/prompts/roaster-maintenance-fit.md +22 -0
- package/prompts/roaster-prior-attempts.md +29 -0
- package/prompts/roaster-replication-ondisk.md +24 -0
- package/prompts/roaster-security.md +21 -0
- package/prompts/roaster-standards-compat.md +21 -0
- package/prompts/supervisor.md +31 -0
- package/prompts/synth-self.md +19 -0
- package/prompts/synth.md +140 -0
- package/prompts/tone.md +21 -0
- package/prompts/writing.md +98 -0
- package/sql/schema.sql +45 -0
- package/src/cli.ts +391 -0
- package/src/corpus/cf.ts +220 -0
- package/src/corpus/cli.ts +189 -0
- package/src/corpus/db.ts +119 -0
- package/src/corpus/fetch.ts +104 -0
- package/src/corpus/reverts.ts +133 -0
- package/src/corpus/search.ts +201 -0
- package/src/corpus/util.ts +133 -0
- package/src/corpus/wiki.ts +80 -0
- package/src/eval/cases.ts +118 -0
- package/src/eval/cli.ts +105 -0
- package/src/eval/judge.ts +136 -0
- package/src/eval/report.ts +268 -0
- package/src/eval/run.ts +153 -0
- package/src/eval/score.ts +275 -0
- package/src/roast/doctor.ts +109 -0
- package/src/roast/enrich.ts +335 -0
- package/src/roast/invariants.ts +65 -0
- package/src/roast/llm.ts +601 -0
- package/src/roast/material.ts +133 -0
- package/src/roast/page.ts +266 -0
- package/src/roast/pipeline.ts +981 -0
- package/src/roast/precedents.ts +308 -0
- package/src/roast/pushback.ts +664 -0
- package/src/roast/render.ts +386 -0
- package/src/roast/sanitize.ts +140 -0
- package/src/roast/schema.ts +714 -0
- package/src/roast/tone.ts +216 -0
- package/src/thread/fetch.ts +172 -0
- package/src/thread/load.ts +227 -0
- package/src/thread/local.ts +73 -0
- package/src/thread/parse.ts +243 -0
- package/src/thread/patchprose.ts +143 -0
- package/src/thread/types.ts +120 -0
package/src/roast/llm.ts
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
// LLM backend. One entry point: backend.complete({system, prompt, model, schema, label}) -> parsed JSON.
|
|
2
|
+
//
|
|
3
|
+
// The real backend shells out to an installed CLI in headless mode: Claude Code (`claude -p`) or
|
|
4
|
+
// Codex (`codex exec`). Both run on the user's subscription login; API key variables are removed
|
|
5
|
+
// from the child's environment and are never read here.
|
|
6
|
+
//
|
|
7
|
+
// The model gets text in and returns text out, with every tool switched off; all retrieval is
|
|
8
|
+
// done by our code. That matters twice: links cannot come from a model-side search, and during
|
|
9
|
+
// evaluation the model cannot look up how a thread ended.
|
|
10
|
+
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { validate, type JsonSchema } from "./schema.ts";
|
|
16
|
+
|
|
17
|
+
export interface CompleteArgs {
|
|
18
|
+
system: string;
|
|
19
|
+
prompt: string;
|
|
20
|
+
model: string;
|
|
21
|
+
schema: JsonSchema;
|
|
22
|
+
/** Short name for the transcript files, e.g. "roaster-security". */
|
|
23
|
+
label: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface LlmBackend {
|
|
27
|
+
readonly dryRun: boolean;
|
|
28
|
+
complete<T>(args: CompleteArgs): Promise<T>;
|
|
29
|
+
stats(): { calls: number; costUsd: number | null };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type BackendName = "claude" | "codex";
|
|
33
|
+
export const BACKENDS: readonly BackendName[] = ["claude", "codex"];
|
|
34
|
+
|
|
35
|
+
export const DEFAULT_ROASTER_MODEL = "opus";
|
|
36
|
+
export const DEFAULT_CRITIC_MODEL = "claude-mythos-5-2";
|
|
37
|
+
/** Shown where a model name is needed and the Codex CLI's configured default is used. */
|
|
38
|
+
export const CODEX_DEFAULT = "codex-default";
|
|
39
|
+
|
|
40
|
+
/** Only Opus, Mythos and Fable (the same underlying model as Mythos) are allowed on the claude backend. */
|
|
41
|
+
export function assertAllowedModel(model: string): string {
|
|
42
|
+
if (!/(opus|mythos|fable)/i.test(model) || /(sonnet|haiku)/i.test(model)) {
|
|
43
|
+
throw new Error(`model "${model}" is not allowed: on the claude backend PG Roast runs on Opus, Mythos or Fable only`);
|
|
44
|
+
}
|
|
45
|
+
return model;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Models {
|
|
49
|
+
roaster: string;
|
|
50
|
+
critic: string;
|
|
51
|
+
synth: string;
|
|
52
|
+
brief: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type Env = Record<string, string | undefined>;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* claude: PGROAST_ROASTER_MODEL / PGROAST_CRITIC_MODEL, Opus, Mythos or Fable only.
|
|
59
|
+
* codex: PGROAST_CODEX_MODEL for every stage, or the CLI's configured default. No names are guessed.
|
|
60
|
+
*/
|
|
61
|
+
export function modelsFromEnv(env: Env = process.env, backend: BackendName = "claude"): Models {
|
|
62
|
+
if (backend === "codex") {
|
|
63
|
+
const m = env.PGROAST_CODEX_MODEL?.trim() || CODEX_DEFAULT;
|
|
64
|
+
return { roaster: m, critic: m, synth: m, brief: m };
|
|
65
|
+
}
|
|
66
|
+
const roaster = assertAllowedModel(env.PGROAST_ROASTER_MODEL || DEFAULT_ROASTER_MODEL);
|
|
67
|
+
const critic = assertAllowedModel(env.PGROAST_CRITIC_MODEL || DEFAULT_CRITIC_MODEL);
|
|
68
|
+
return { roaster, critic, synth: critic, brief: roaster };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Variables that would make a CLI bill an API key. The CLIs must use their subscription login. */
|
|
72
|
+
export const API_KEY_VARS = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY", "CODEX_API_KEY"] as const;
|
|
73
|
+
|
|
74
|
+
/** The environment for a spawned CLI: the caller's, without any API key variable. */
|
|
75
|
+
export function childEnv(env: Env = process.env): Record<string, string> {
|
|
76
|
+
const out: Record<string, string> = {};
|
|
77
|
+
for (const [k, v] of Object.entries(env)) {
|
|
78
|
+
if (v != null && !(API_KEY_VARS as readonly string[]).includes(k)) out[k] = v;
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function onPath(bin: string): string | null {
|
|
84
|
+
return Bun.which(bin);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** --backend / PGROAST_BACKEND, else claude when installed, else codex. */
|
|
88
|
+
export function pickBackend(flag: string | undefined, env: Env = process.env, which: (b: string) => string | null = onPath): BackendName {
|
|
89
|
+
const want = (flag ?? env.PGROAST_BACKEND ?? "").trim().toLowerCase();
|
|
90
|
+
if (want) {
|
|
91
|
+
if (!(BACKENDS as readonly string[]).includes(want)) throw new Error(`unknown backend "${want}": use claude or codex`);
|
|
92
|
+
return want as BackendName;
|
|
93
|
+
}
|
|
94
|
+
if (which("claude")) return "claude";
|
|
95
|
+
if (which("codex")) return "codex";
|
|
96
|
+
throw new Error("neither the claude nor the codex CLI is on PATH: install Claude Code or Codex CLI and sign in (run `pgroast doctor`)");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------------------------
|
|
100
|
+
// JSON extraction
|
|
101
|
+
|
|
102
|
+
/** Pull the first complete top-level JSON object out of a model reply. */
|
|
103
|
+
export function extractJson(text: string): unknown {
|
|
104
|
+
const t = text.trim();
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(t);
|
|
107
|
+
} catch {
|
|
108
|
+
// fall through
|
|
109
|
+
}
|
|
110
|
+
for (let start = t.indexOf("{"); start >= 0; start = t.indexOf("{", start + 1)) {
|
|
111
|
+
let depth = 0;
|
|
112
|
+
let inStr = false;
|
|
113
|
+
let esc = false;
|
|
114
|
+
for (let i = start; i < t.length; i++) {
|
|
115
|
+
const c = t[i]!;
|
|
116
|
+
if (inStr) {
|
|
117
|
+
if (esc) esc = false;
|
|
118
|
+
else if (c === "\\") esc = true;
|
|
119
|
+
else if (c === '"') inStr = false;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (c === '"') inStr = true;
|
|
123
|
+
else if (c === "{") depth++;
|
|
124
|
+
else if (c === "}") {
|
|
125
|
+
depth--;
|
|
126
|
+
if (depth === 0) {
|
|
127
|
+
try {
|
|
128
|
+
return JSON.parse(t.slice(start, i + 1));
|
|
129
|
+
} catch {
|
|
130
|
+
break; // try the next "{"
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
throw new Error("no JSON object found in the reply");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function parseAndValidate<T>(text: string, schema: JsonSchema): { value?: T; errors: string[] } {
|
|
140
|
+
let value: unknown;
|
|
141
|
+
try {
|
|
142
|
+
value = extractJson(text);
|
|
143
|
+
} catch (e) {
|
|
144
|
+
return { errors: [(e as Error).message] };
|
|
145
|
+
}
|
|
146
|
+
const errors = validate(value, schema);
|
|
147
|
+
return errors.length ? { errors } : { value: value as T, errors: [] };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function repairPrompt(prompt: string, badReply: string, errors: string[]): string {
|
|
151
|
+
return [
|
|
152
|
+
prompt,
|
|
153
|
+
"",
|
|
154
|
+
"---",
|
|
155
|
+
"Your previous reply was rejected by the validator:",
|
|
156
|
+
...errors.slice(0, 20).map((e) => `- ${e}`),
|
|
157
|
+
"",
|
|
158
|
+
"Previous reply:",
|
|
159
|
+
badReply.slice(0, 20000),
|
|
160
|
+
"",
|
|
161
|
+
"Return the corrected JSON object only. Keep the content; fix the format.",
|
|
162
|
+
].join("\n");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------------------------
|
|
166
|
+
// Concurrency
|
|
167
|
+
|
|
168
|
+
export class Semaphore {
|
|
169
|
+
private waiting: (() => void)[] = [];
|
|
170
|
+
private active = 0;
|
|
171
|
+
constructor(private readonly limit: number) {}
|
|
172
|
+
async run<T>(fn: () => Promise<T>): Promise<T> {
|
|
173
|
+
if (this.active >= this.limit) await new Promise<void>((r) => this.waiting.push(r));
|
|
174
|
+
this.active++;
|
|
175
|
+
try {
|
|
176
|
+
return await fn();
|
|
177
|
+
} finally {
|
|
178
|
+
this.active--;
|
|
179
|
+
this.waiting.shift()?.();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------------------------
|
|
185
|
+
// Transcript
|
|
186
|
+
|
|
187
|
+
export class Transcript {
|
|
188
|
+
private n = 0;
|
|
189
|
+
constructor(readonly dir: string) {
|
|
190
|
+
mkdirSync(dir, { recursive: true });
|
|
191
|
+
}
|
|
192
|
+
next(label: string): string {
|
|
193
|
+
this.n++;
|
|
194
|
+
return join(this.dir, `${String(this.n).padStart(2, "0")}-${label.replace(/[^A-Za-z0-9_.-]+/g, "_")}`);
|
|
195
|
+
}
|
|
196
|
+
write(base: string, suffix: string, content: string): void {
|
|
197
|
+
writeFileSync(base + suffix, content);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function promptFile(args: CompleteArgs): string {
|
|
202
|
+
return `# model: ${args.model}\n# label: ${args.label}\n\n===== SYSTEM =====\n${args.system}\n\n===== PROMPT =====\n${args.prompt}\n`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------------------------
|
|
206
|
+
// CLI backends: Claude Code (`claude -p`) and Codex (`codex exec`)
|
|
207
|
+
|
|
208
|
+
export interface RawReply {
|
|
209
|
+
text: string;
|
|
210
|
+
costUsd: number | null;
|
|
211
|
+
model: string;
|
|
212
|
+
durationMs: number;
|
|
213
|
+
usage: unknown;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** How to run one CLI: the command line, what goes to stdin, how to read the output. */
|
|
217
|
+
export interface CliDriver {
|
|
218
|
+
name: BackendName;
|
|
219
|
+
bin: string;
|
|
220
|
+
checkModel(model: string): void;
|
|
221
|
+
argv(args: CompleteArgs): string[];
|
|
222
|
+
stdin(args: CompleteArgs, prompt: string): string;
|
|
223
|
+
parse(stdout: string): RawReply;
|
|
224
|
+
/** What to tell the user when a call fails because nobody is signed in. */
|
|
225
|
+
loginHint: string;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Claude Code keeps one login per config directory, and "CLAUDE_CONFIG_DIR not set" is a
|
|
230
|
+
* different login from "CLAUDE_CONFIG_DIR=~/.claude". A shell alias that sets the variable for
|
|
231
|
+
* interactive use therefore leaves a plain `claude -p` on another, possibly expired, login.
|
|
232
|
+
*/
|
|
233
|
+
export function claudeLoginHint(env: Env = process.env): string {
|
|
234
|
+
const dir = env.CLAUDE_CONFIG_DIR;
|
|
235
|
+
return (
|
|
236
|
+
"run `claude` and sign in with your subscription (/login), then try again. " +
|
|
237
|
+
(dir
|
|
238
|
+
? `This run used the login of CLAUDE_CONFIG_DIR=${dir}`
|
|
239
|
+
: "CLAUDE_CONFIG_DIR is not set here, so the CLI's default login was used. If `claude` works for you interactively, check whether you start it through an alias that sets CLAUDE_CONFIG_DIR (`alias | grep claude`), and set the same value for pgroast, for example CLAUDE_CONFIG_DIR=~/.claude pgroast ...")
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Flags that take every tool away from the model:
|
|
245
|
+
* --tools "" no built-in tools
|
|
246
|
+
* --strict-mcp-config no MCP servers (none are passed)
|
|
247
|
+
* --safe-mode no CLAUDE.md, skills, plugins, hooks, custom agents
|
|
248
|
+
* --disable-slash-commands
|
|
249
|
+
* The init event of the stream is then checked: if it lists any tool or MCP server, the call fails.
|
|
250
|
+
*/
|
|
251
|
+
export function claudeArgs(model: string, system: string): string[] {
|
|
252
|
+
return [
|
|
253
|
+
"-p",
|
|
254
|
+
"--model",
|
|
255
|
+
model,
|
|
256
|
+
"--system-prompt",
|
|
257
|
+
system,
|
|
258
|
+
"--tools",
|
|
259
|
+
"",
|
|
260
|
+
"--strict-mcp-config",
|
|
261
|
+
"--safe-mode",
|
|
262
|
+
"--disable-slash-commands",
|
|
263
|
+
"--no-session-persistence",
|
|
264
|
+
"--output-format",
|
|
265
|
+
"stream-json",
|
|
266
|
+
"--verbose",
|
|
267
|
+
];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Read the stream-json output of `claude -p`. Throws when a tool is present or used. */
|
|
271
|
+
export function parseClaudeStream(stdout: string): RawReply {
|
|
272
|
+
let text: string | null = null;
|
|
273
|
+
let costUsd: number | null = null;
|
|
274
|
+
let model = "";
|
|
275
|
+
let durationMs = 0;
|
|
276
|
+
let usage: unknown = null;
|
|
277
|
+
let sawInit = false;
|
|
278
|
+
for (const line of stdout.split("\n")) {
|
|
279
|
+
if (!line.trim().startsWith("{")) continue;
|
|
280
|
+
let ev: Record<string, unknown>;
|
|
281
|
+
try {
|
|
282
|
+
ev = JSON.parse(line) as Record<string, unknown>;
|
|
283
|
+
} catch {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (ev.type === "system" && ev.subtype === "init") {
|
|
287
|
+
sawInit = true;
|
|
288
|
+
const tools = (ev.tools as unknown[]) ?? [];
|
|
289
|
+
const mcp = (ev.mcp_servers as unknown[]) ?? [];
|
|
290
|
+
if (tools.length > 0 || mcp.length > 0) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
`the model session has tools (${JSON.stringify(tools)}) or MCP servers (${JSON.stringify(mcp)}); refusing to use its output`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
// The subscription login must be in use, never an API key.
|
|
296
|
+
if (ev.apiKeySource != null && ev.apiKeySource !== "none") {
|
|
297
|
+
throw new Error(`claude is using an API key (source: ${String(ev.apiKeySource)}); PG Roast runs on the subscription login only`);
|
|
298
|
+
}
|
|
299
|
+
model = String(ev.model ?? "");
|
|
300
|
+
} else if (ev.type === "assistant") {
|
|
301
|
+
const content = ((ev.message as Record<string, unknown> | undefined)?.content as { type?: string }[]) ?? [];
|
|
302
|
+
if (content.some((c) => c.type === "tool_use" || c.type === "server_tool_use")) {
|
|
303
|
+
throw new Error("the model tried to call a tool; refusing to use its output");
|
|
304
|
+
}
|
|
305
|
+
} else if (ev.type === "result") {
|
|
306
|
+
if (ev.is_error) throw new Error(`claude returned an error: ${String(ev.result ?? ev.subtype ?? "unknown")}`);
|
|
307
|
+
text = String(ev.result ?? "");
|
|
308
|
+
costUsd = typeof ev.total_cost_usd === "number" ? ev.total_cost_usd : null;
|
|
309
|
+
durationMs = typeof ev.duration_ms === "number" ? ev.duration_ms : 0;
|
|
310
|
+
usage = ev.usage ?? null;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (!sawInit) throw new Error("no init event in the claude output; cannot confirm that tools are off");
|
|
314
|
+
if (text == null) throw new Error("no result event in the claude output");
|
|
315
|
+
if (model && !/(opus|mythos|fable)/i.test(model)) throw new Error(`claude answered with model "${model}", which is not allowed`);
|
|
316
|
+
return { text, costUsd, model, durationMs, usage };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function claudeDriver(bin = process.env.PGROAST_CLAUDE_BIN ?? "claude"): CliDriver {
|
|
320
|
+
return {
|
|
321
|
+
name: "claude",
|
|
322
|
+
bin,
|
|
323
|
+
checkModel: (m) => void assertAllowedModel(m),
|
|
324
|
+
argv: (a) => claudeArgs(a.model, a.system),
|
|
325
|
+
stdin: (_a, prompt) => prompt,
|
|
326
|
+
parse: parseClaudeStream,
|
|
327
|
+
get loginHint() {
|
|
328
|
+
return claudeLoginHint();
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Codex features that give the model a way to act or look things up. All are switched off. */
|
|
334
|
+
export const CODEX_DISABLED_FEATURES = [
|
|
335
|
+
"shell_tool",
|
|
336
|
+
"unified_exec",
|
|
337
|
+
"apps",
|
|
338
|
+
"plugins",
|
|
339
|
+
"browser_use",
|
|
340
|
+
"computer_use",
|
|
341
|
+
"in_app_browser",
|
|
342
|
+
"image_generation",
|
|
343
|
+
"tool_suggest",
|
|
344
|
+
"skill_search",
|
|
345
|
+
"memories",
|
|
346
|
+
] as const;
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* `codex exec` has no single "no tools" switch, so several layers are used:
|
|
350
|
+
* --disable <feature> shell, apps, plugins, browser, computer use, ... off
|
|
351
|
+
* -c web_search="disabled"
|
|
352
|
+
* --ignore-user-config no user MCP servers or profiles (the login is still used)
|
|
353
|
+
* --ignore-rules, --ephemeral, --skip-git-repo-check
|
|
354
|
+
* -s read-only and an empty working directory: nothing to read, no network, no writes
|
|
355
|
+
* and then the event stream is checked: any item that is not a message or reasoning fails the call.
|
|
356
|
+
*/
|
|
357
|
+
export function codexArgs(model: string, cwd: string): string[] {
|
|
358
|
+
return [
|
|
359
|
+
"exec",
|
|
360
|
+
"--json",
|
|
361
|
+
"--ephemeral",
|
|
362
|
+
"--skip-git-repo-check",
|
|
363
|
+
"--ignore-user-config",
|
|
364
|
+
"--ignore-rules",
|
|
365
|
+
"-s",
|
|
366
|
+
"read-only",
|
|
367
|
+
"-C",
|
|
368
|
+
cwd,
|
|
369
|
+
"-c",
|
|
370
|
+
'web_search="disabled"',
|
|
371
|
+
...CODEX_DISABLED_FEATURES.flatMap((f) => ["--disable", f]),
|
|
372
|
+
...(model && model !== CODEX_DEFAULT ? ["-m", model] : []),
|
|
373
|
+
"-", // the prompt comes from stdin
|
|
374
|
+
];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const CODEX_TEXT_ITEMS = new Set(["agent_message", "reasoning"]);
|
|
378
|
+
|
|
379
|
+
/** Read the JSONL events of `codex exec --json`. Throws when the model used any tool. */
|
|
380
|
+
export function parseCodexStream(stdout: string): RawReply {
|
|
381
|
+
let text: string | null = null;
|
|
382
|
+
let usage: unknown = null;
|
|
383
|
+
let model = "";
|
|
384
|
+
let sawEvent = false;
|
|
385
|
+
for (const line of stdout.split("\n")) {
|
|
386
|
+
if (!line.trim().startsWith("{")) continue;
|
|
387
|
+
let ev: Record<string, unknown>;
|
|
388
|
+
try {
|
|
389
|
+
ev = JSON.parse(line) as Record<string, unknown>;
|
|
390
|
+
} catch {
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
sawEvent = true;
|
|
394
|
+
const type = String(ev.type ?? "");
|
|
395
|
+
if (typeof ev.model === "string") model = ev.model;
|
|
396
|
+
if (type.startsWith("item.")) {
|
|
397
|
+
const item = (ev.item ?? {}) as Record<string, unknown>;
|
|
398
|
+
const kind = String(item.type ?? item.item_type ?? "");
|
|
399
|
+
if (kind === "error") throw new Error(`codex reported an error: ${String(item.message ?? "unknown")}`);
|
|
400
|
+
if (!CODEX_TEXT_ITEMS.has(kind)) {
|
|
401
|
+
throw new Error(`the model used a tool (${kind || "unknown item"}); refusing to use its output`);
|
|
402
|
+
}
|
|
403
|
+
if (kind === "agent_message" && type === "item.completed") text = String(item.text ?? "");
|
|
404
|
+
} else if (type === "turn.failed" || type === "error") {
|
|
405
|
+
const err = (ev.error ?? ev) as Record<string, unknown>;
|
|
406
|
+
throw new Error(`codex failed: ${String(err.message ?? JSON.stringify(ev)).slice(0, 500)}`);
|
|
407
|
+
} else if (type === "turn.completed") {
|
|
408
|
+
usage = ev.usage ?? null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (!sawEvent) throw new Error("no events in the codex output; cannot confirm that no tool was used");
|
|
412
|
+
if (text == null) throw new Error("no final message in the codex output");
|
|
413
|
+
return { text, costUsd: null, model: model || CODEX_DEFAULT, durationMs: 0, usage };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function codexDriver(cwd: string, bin = process.env.PGROAST_CODEX_BIN ?? "codex"): CliDriver {
|
|
417
|
+
return {
|
|
418
|
+
name: "codex",
|
|
419
|
+
bin,
|
|
420
|
+
checkModel: () => undefined,
|
|
421
|
+
argv: (a) => codexArgs(a.model, cwd),
|
|
422
|
+
// codex exec has no system-prompt flag: the instructions lead the prompt.
|
|
423
|
+
stdin: (a, prompt) =>
|
|
424
|
+
`${a.system}\n\n---\n\nYou have no tools in this task and need none: do not run commands, read files or search. Answer from the text below only.\n\n${prompt}`,
|
|
425
|
+
parse: parseCodexStream,
|
|
426
|
+
loginHint: "run `codex login` and sign in with your ChatGPT subscription, then try again",
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export interface CliBackendOptions {
|
|
431
|
+
backend?: BackendName;
|
|
432
|
+
transcriptDir: string;
|
|
433
|
+
concurrency?: number;
|
|
434
|
+
timeoutMs?: number;
|
|
435
|
+
log?: (msg: string) => void;
|
|
436
|
+
/** Injected for tests: replaces the process spawn. */
|
|
437
|
+
runner?: (args: CompleteArgs, prompt: string) => Promise<RawReply>;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Run a CLI once: argv, stdin in, stdout out. API key variables are removed from its environment. */
|
|
441
|
+
export function runCli(bin: string, argv: string[], stdin: string, cwd: string, timeoutMs: number): Promise<{ stdout: string; stderr: string; code: number | null }> {
|
|
442
|
+
return new Promise((resolve, reject) => {
|
|
443
|
+
const child = spawn(bin, argv, { cwd, stdio: ["pipe", "pipe", "pipe"], env: childEnv() });
|
|
444
|
+
let stdout = "";
|
|
445
|
+
let stderr = "";
|
|
446
|
+
const timer = setTimeout(() => {
|
|
447
|
+
child.kill("SIGKILL");
|
|
448
|
+
reject(new Error(`${bin} timed out after ${Math.round(timeoutMs / 1000)} s`));
|
|
449
|
+
}, timeoutMs);
|
|
450
|
+
child.stdout.on("data", (d: Buffer) => (stdout += d.toString("utf8")));
|
|
451
|
+
child.stderr.on("data", (d: Buffer) => (stderr += d.toString("utf8")));
|
|
452
|
+
child.on("error", (e) => {
|
|
453
|
+
clearTimeout(timer);
|
|
454
|
+
reject(e);
|
|
455
|
+
});
|
|
456
|
+
child.on("close", (code) => {
|
|
457
|
+
clearTimeout(timer);
|
|
458
|
+
resolve({ stdout, stderr, code });
|
|
459
|
+
});
|
|
460
|
+
child.stdin.on("error", () => undefined);
|
|
461
|
+
child.stdin.end(stdin);
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export class CliBackend implements LlmBackend {
|
|
466
|
+
readonly dryRun = false;
|
|
467
|
+
readonly driver: CliDriver;
|
|
468
|
+
private calls = 0;
|
|
469
|
+
private cost = 0;
|
|
470
|
+
private costKnown = true;
|
|
471
|
+
private readonly sem: Semaphore;
|
|
472
|
+
private readonly transcript: Transcript;
|
|
473
|
+
private readonly cwd: string;
|
|
474
|
+
|
|
475
|
+
constructor(private readonly opts: CliBackendOptions) {
|
|
476
|
+
this.sem = new Semaphore(opts.concurrency ?? Number(process.env.PGROAST_CONCURRENCY || 3));
|
|
477
|
+
this.transcript = new Transcript(opts.transcriptDir);
|
|
478
|
+
// An empty directory: nothing for a session to pick up, even if a tool slipped through.
|
|
479
|
+
this.cwd = mkdtempSync(join(tmpdir(), "pgroast-llm-"));
|
|
480
|
+
this.driver = (opts.backend ?? "claude") === "codex" ? codexDriver(this.cwd) : claudeDriver();
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
stats() {
|
|
484
|
+
return { calls: this.calls, costUsd: this.costKnown ? this.cost : null };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
private async runOnce(args: CompleteArgs, prompt: string): Promise<RawReply> {
|
|
488
|
+
if (this.opts.runner) return this.opts.runner(args, prompt);
|
|
489
|
+
const timeoutMs = this.opts.timeoutMs ?? Number(process.env.PGROAST_LLM_TIMEOUT_MS || 900_000);
|
|
490
|
+
const d = this.driver;
|
|
491
|
+
const r = await runCli(d.bin, d.argv(args), d.stdin(args, prompt), this.cwd, timeoutMs);
|
|
492
|
+
try {
|
|
493
|
+
return d.parse(r.stdout);
|
|
494
|
+
} catch (e) {
|
|
495
|
+
const detail = `${r.stdout.slice(-300)} ${r.stderr.slice(-500)}`;
|
|
496
|
+
const login = /log ?in|sign ?in|auth|unauthori[sz]ed|401|credential/i.test(detail + (e as Error).message) ? ` Not signed in? ${d.loginHint}.` : "";
|
|
497
|
+
throw new Error(`${(e as Error).message} (${d.name} exit ${r.code}; stderr: ${r.stderr.trim().slice(-400)})${login}`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async complete<T>(args: CompleteArgs): Promise<T> {
|
|
502
|
+
this.driver.checkModel(args.model);
|
|
503
|
+
return this.sem.run(async () => {
|
|
504
|
+
const log = this.opts.log ?? ((m: string) => console.error(m));
|
|
505
|
+
let prompt = args.prompt;
|
|
506
|
+
let lastErrors: string[] = [];
|
|
507
|
+
// One call, plus one repair retry when the reply is not valid JSON for the schema.
|
|
508
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
509
|
+
const base = this.transcript.next(attempt === 1 ? args.label : `${args.label}-repair`);
|
|
510
|
+
this.transcript.write(base, ".prompt.txt", promptFile({ ...args, prompt }));
|
|
511
|
+
const t0 = Date.now();
|
|
512
|
+
this.calls++;
|
|
513
|
+
let reply: RawReply;
|
|
514
|
+
try {
|
|
515
|
+
reply = await this.runOnce(args, prompt);
|
|
516
|
+
} catch (e) {
|
|
517
|
+
this.transcript.write(base, ".error.txt", String((e as Error).message));
|
|
518
|
+
throw e;
|
|
519
|
+
}
|
|
520
|
+
if (reply.costUsd == null) this.costKnown = false;
|
|
521
|
+
else this.cost += reply.costUsd;
|
|
522
|
+
this.transcript.write(base, ".response.txt", reply.text);
|
|
523
|
+
const parsed = parseAndValidate<T>(reply.text, args.schema);
|
|
524
|
+
this.transcript.write(
|
|
525
|
+
base,
|
|
526
|
+
".meta.json",
|
|
527
|
+
JSON.stringify(
|
|
528
|
+
{
|
|
529
|
+
label: args.label,
|
|
530
|
+
attempt,
|
|
531
|
+
backend: this.driver.name,
|
|
532
|
+
requested_model: args.model,
|
|
533
|
+
model: reply.model,
|
|
534
|
+
wall_ms: Date.now() - t0,
|
|
535
|
+
cost_usd: reply.costUsd,
|
|
536
|
+
usage: reply.usage,
|
|
537
|
+
validation_errors: parsed.errors,
|
|
538
|
+
},
|
|
539
|
+
null,
|
|
540
|
+
1,
|
|
541
|
+
),
|
|
542
|
+
);
|
|
543
|
+
log(` llm ${args.label}${attempt > 1 ? " (repair)" : ""}: ${reply.model}, ${Math.round((Date.now() - t0) / 1000)} s${parsed.errors.length ? `, INVALID: ${parsed.errors[0]}` : ""}`);
|
|
544
|
+
if (parsed.value !== undefined) return parsed.value;
|
|
545
|
+
lastErrors = parsed.errors;
|
|
546
|
+
prompt = repairPrompt(args.prompt, reply.text, parsed.errors);
|
|
547
|
+
}
|
|
548
|
+
throw new Error(`"${args.label}": reply still invalid after one repair retry: ${lastErrors.slice(0, 5).join("; ")}`);
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** Kept as a name for the default backend. */
|
|
554
|
+
export class ClaudeCliBackend extends CliBackend {
|
|
555
|
+
constructor(opts: Omit<CliBackendOptions, "backend">) {
|
|
556
|
+
super({ ...opts, backend: "claude" });
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ---------------------------------------------------------------------------------------------
|
|
561
|
+
// Dry run: print the prompts, call nothing, return a stub that fits the schema.
|
|
562
|
+
|
|
563
|
+
export function stubFor(schema: JsonSchema): unknown {
|
|
564
|
+
if (schema.nullable) return null;
|
|
565
|
+
switch (schema.type) {
|
|
566
|
+
case "string":
|
|
567
|
+
return schema.enum ? schema.enum[0] : "(dry run)";
|
|
568
|
+
case "integer":
|
|
569
|
+
case "number":
|
|
570
|
+
return schema.minimum ?? 0;
|
|
571
|
+
case "boolean":
|
|
572
|
+
return false;
|
|
573
|
+
case "array":
|
|
574
|
+
return Array.from({ length: schema.minItems ?? 0 }, () => stubFor(schema.items));
|
|
575
|
+
case "object":
|
|
576
|
+
return Object.fromEntries(Object.entries(schema.properties).map(([k, v]) => [k, stubFor(v)]));
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export class DryRunBackend implements LlmBackend {
|
|
581
|
+
readonly dryRun = true;
|
|
582
|
+
private calls = 0;
|
|
583
|
+
private readonly transcript: Transcript | null;
|
|
584
|
+
constructor(
|
|
585
|
+
private readonly opts: { transcriptDir?: string; print?: (s: string) => void; stubs?: Record<string, unknown> } = {},
|
|
586
|
+
) {
|
|
587
|
+
this.transcript = opts.transcriptDir ? new Transcript(opts.transcriptDir) : null;
|
|
588
|
+
}
|
|
589
|
+
stats() {
|
|
590
|
+
return { calls: this.calls, costUsd: 0 };
|
|
591
|
+
}
|
|
592
|
+
async complete<T>(args: CompleteArgs): Promise<T> {
|
|
593
|
+
this.calls++;
|
|
594
|
+
const text = promptFile(args);
|
|
595
|
+
if (this.transcript) this.transcript.write(this.transcript.next(args.label), ".prompt.txt", text);
|
|
596
|
+
(this.opts.print ?? ((s: string) => console.log(s)))(
|
|
597
|
+
`\n######## DRY RUN: ${args.label} (model ${args.model}; ${text.length} chars; not sent) ########\n${text}`,
|
|
598
|
+
);
|
|
599
|
+
return (this.opts.stubs?.[args.label] ?? stubFor(args.schema)) as T;
|
|
600
|
+
}
|
|
601
|
+
}
|