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
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// Pure scoring functions. The rules are those in docs/eval.md, fixed before
|
|
2
|
+
// any result existed. Nothing here calls a model or the network.
|
|
3
|
+
|
|
4
|
+
import type { FinalObjection, Roast, Verdict } from "../roast/schema.ts";
|
|
5
|
+
import { decisiveInWindow, msgidOf, type EvalCase, type Outcome } from "./cases.ts";
|
|
6
|
+
import { caseLevel, type ClassifyItem, type DecisiveLevel } from "./judge.ts";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------- (a) verdict
|
|
9
|
+
|
|
10
|
+
const LENIENT: Record<Outcome, readonly Verdict[]> = {
|
|
11
|
+
rejected: ["LIKELY-REJECT", "NEEDS-DESIGN-DISCUSSION"],
|
|
12
|
+
accepted: ["NO-FUNDAMENTAL-OBJECTION", "NEEDS-COMMITTER-EYES"],
|
|
13
|
+
reverted: ["LIKELY-REJECT", "NEEDS-DESIGN-DISCUSSION", "NEEDS-COMMITTER-EYES"],
|
|
14
|
+
};
|
|
15
|
+
const STRICT: Record<Outcome, readonly Verdict[]> = {
|
|
16
|
+
rejected: ["LIKELY-REJECT"],
|
|
17
|
+
accepted: ["NO-FUNDAMENTAL-OBJECTION"],
|
|
18
|
+
reverted: ["LIKELY-REJECT", "NEEDS-DESIGN-DISCUSSION"],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function verdictMatch(outcome: Outcome, verdict: Verdict): { lenient: boolean; strict: boolean } {
|
|
22
|
+
return { lenient: LENIENT[outcome].includes(verdict), strict: STRICT[outcome].includes(verdict) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------- objections by bucket
|
|
26
|
+
|
|
27
|
+
export type Bucket = "listed" | "other_surviving" | "refuted" | "withdrawn";
|
|
28
|
+
|
|
29
|
+
export interface BucketedObjection {
|
|
30
|
+
id: string;
|
|
31
|
+
bucket: Bucket;
|
|
32
|
+
severity: string;
|
|
33
|
+
title: string;
|
|
34
|
+
category: string;
|
|
35
|
+
level: string;
|
|
36
|
+
failure_scenario: string;
|
|
37
|
+
argument: string;
|
|
38
|
+
critic_reasoning: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function bucketedObjections(r: Roast): BucketedObjection[] {
|
|
42
|
+
const out: BucketedObjection[] = [];
|
|
43
|
+
const add = (list: FinalObjection[] | undefined, bucket: Bucket) => {
|
|
44
|
+
for (const o of list ?? []) {
|
|
45
|
+
out.push({
|
|
46
|
+
id: o.id, bucket, severity: o.severity, title: o.title, category: o.category, level: o.level,
|
|
47
|
+
failure_scenario: o.failure_scenario, argument: o.argument, critic_reasoning: o.critic?.reasoning ?? "",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
add(r.objections, "listed");
|
|
52
|
+
add(r.other_surviving, "other_surviving");
|
|
53
|
+
add(r.refuted, "refuted");
|
|
54
|
+
add((r as Roast & { withdrawn?: FinalObjection[] }).withdrawn, "withdrawn");
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------- (b) decisive objection
|
|
59
|
+
|
|
60
|
+
export type JudgeResult = "found" | "partially" | "not_found";
|
|
61
|
+
|
|
62
|
+
export interface JudgeItem {
|
|
63
|
+
decisive_index: number;
|
|
64
|
+
result: JudgeResult;
|
|
65
|
+
objection_id: string | null;
|
|
66
|
+
reason: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface DecisiveScore {
|
|
70
|
+
status: "scored" | "n/a" | "not_scored";
|
|
71
|
+
/** best result across the case's decisive objections */
|
|
72
|
+
result: JudgeResult | null;
|
|
73
|
+
objection_id: string | null;
|
|
74
|
+
/** looked up by code from the roast, not taken from the judge */
|
|
75
|
+
bucket: Bucket | null;
|
|
76
|
+
reason: string;
|
|
77
|
+
/** headline: found in listed or other_surviving */
|
|
78
|
+
found_surviving: boolean;
|
|
79
|
+
items: (JudgeItem & { bucket: Bucket | null })[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const RANK: Record<JudgeResult, number> = { found: 2, partially: 1, not_found: 0 };
|
|
83
|
+
const BUCKET_RANK: Record<Bucket, number> = { listed: 3, other_surviving: 2, refuted: 1, withdrawn: 0 };
|
|
84
|
+
|
|
85
|
+
export function scoreDecisive(c: EvalCase, r: Roast, judge: JudgeItem[] | null): DecisiveScore {
|
|
86
|
+
if (!c.decisive_objections.length) {
|
|
87
|
+
return { status: "n/a", result: null, objection_id: null, bucket: null, reason: "case has no decisive objection", found_surviving: false, items: [] };
|
|
88
|
+
}
|
|
89
|
+
if (!judge) {
|
|
90
|
+
return { status: "not_scored", result: null, objection_id: null, bucket: null, reason: "judge not run", found_surviving: false, items: [] };
|
|
91
|
+
}
|
|
92
|
+
const byId = new Map(bucketedObjections(r).map((o) => [o.id, o.bucket]));
|
|
93
|
+
const items = judge.map((j) => {
|
|
94
|
+
const bucket = j.objection_id ? (byId.get(j.objection_id) ?? null) : null;
|
|
95
|
+
// an id the roast does not contain cannot support "found"
|
|
96
|
+
const result: JudgeResult = j.result !== "not_found" && !bucket ? "not_found" : j.result;
|
|
97
|
+
return { ...j, result, bucket, reason: bucket || j.result === "not_found" ? j.reason : `${j.reason} [judge cited unknown id ${j.objection_id}]` };
|
|
98
|
+
});
|
|
99
|
+
const best = [...items].sort((a, b) =>
|
|
100
|
+
RANK[b.result] - RANK[a.result] || (b.bucket ? BUCKET_RANK[b.bucket] : -1) - (a.bucket ? BUCKET_RANK[a.bucket] : -1))[0];
|
|
101
|
+
if (!best) {
|
|
102
|
+
return { status: "not_scored", result: null, objection_id: null, bucket: null, reason: "judge returned no items", found_surviving: false, items };
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
status: "scored",
|
|
106
|
+
result: best.result,
|
|
107
|
+
objection_id: best.result === "not_found" ? null : best.objection_id,
|
|
108
|
+
bucket: best.result === "not_found" ? null : best.bucket,
|
|
109
|
+
reason: best.reason,
|
|
110
|
+
found_surviving: best.result === "found" && (best.bucket === "listed" || best.bucket === "other_surviving"),
|
|
111
|
+
items,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------- (c) earlier attempt
|
|
116
|
+
|
|
117
|
+
export interface CorpusThreadRow {
|
|
118
|
+
id: string;
|
|
119
|
+
date: string | null;
|
|
120
|
+
first_date: string | null;
|
|
121
|
+
thread_urls: string[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type EarlierStatus = "found" | "retrieved_only" | "not_found" | "not in corpus" | "n/a";
|
|
125
|
+
|
|
126
|
+
export interface EarlierScore {
|
|
127
|
+
status: EarlierStatus;
|
|
128
|
+
/** earlier-attempt thread ids that are in the corpus, with the row ids holding them */
|
|
129
|
+
in_corpus: { msgid: string; rows: string[]; retrievable_before_cutoff: boolean }[];
|
|
130
|
+
matched: string[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** before-rule of docs/corpus.md: every known date strictly before cutoff */
|
|
134
|
+
function datedBefore(row: CorpusThreadRow, cutoff: string): boolean {
|
|
135
|
+
const a = row.first_date ?? row.date, b = row.date ?? row.first_date;
|
|
136
|
+
return !!a && !!b && a < cutoff && b < cutoff;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function scoreEarlier(c: EvalCase, r: Roast, corpusRows: CorpusThreadRow[]): EarlierScore {
|
|
140
|
+
const wanted = [...new Set(c.earlier_attempts.map((e) => msgidOf(e.thread_url)).filter((x): x is string => !!x))];
|
|
141
|
+
if (!wanted.length) return { status: "n/a", in_corpus: [], matched: [] };
|
|
142
|
+
const rowsByMsgid = new Map<string, CorpusThreadRow[]>();
|
|
143
|
+
for (const row of corpusRows) {
|
|
144
|
+
for (const t of row.thread_urls) {
|
|
145
|
+
const m = msgidOf(t);
|
|
146
|
+
if (m && wanted.includes(m)) rowsByMsgid.set(m, [...(rowsByMsgid.get(m) ?? []), row]);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const inCorpus = wanted.filter((m) => rowsByMsgid.has(m)).map((m) => ({
|
|
150
|
+
msgid: m,
|
|
151
|
+
rows: rowsByMsgid.get(m)!.map((x) => x.id),
|
|
152
|
+
retrievable_before_cutoff: rowsByMsgid.get(m)!.some((x) => datedBefore(x, r.precedent_cutoff || c.cutoff_date)),
|
|
153
|
+
}));
|
|
154
|
+
const ids = (urls: string[]) => urls.map(msgidOf).filter((x): x is string => !!x);
|
|
155
|
+
const prior = new Set((r.prior_attempts ?? []).flatMap((p) => ids(p.thread_urls ?? [])));
|
|
156
|
+
const retrieved = new Set((r.precedents_retrieved ?? []).flatMap((p) => ids(p.thread_urls ?? [])));
|
|
157
|
+
const inPrior = wanted.filter((m) => prior.has(m));
|
|
158
|
+
if (inPrior.length) return { status: "found", in_corpus: inCorpus, matched: inPrior };
|
|
159
|
+
const inRetrieved = wanted.filter((m) => retrieved.has(m));
|
|
160
|
+
if (inRetrieved.length) return { status: "retrieved_only", in_corpus: inCorpus, matched: inRetrieved };
|
|
161
|
+
if (!inCorpus.length) return { status: "not in corpus", in_corpus: [], matched: [] };
|
|
162
|
+
return { status: "not_found", in_corpus: inCorpus, matched: [] };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------- (d) false alarms
|
|
166
|
+
|
|
167
|
+
export function survivingFatal(r: Roast): number {
|
|
168
|
+
return [...(r.objections ?? []), ...(r.other_surviving ?? [])].filter((o) => o.severity === "fatal").length;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function isFalseAlarm(c: EvalCase, r: Roast): boolean | null {
|
|
172
|
+
if (c.outcome !== "accepted") return null;
|
|
173
|
+
return r.verdict === "LIKELY-REJECT" || r.verdict === "NEEDS-DESIGN-DISCUSSION";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------- per case + aggregate
|
|
177
|
+
|
|
178
|
+
export interface CaseScore {
|
|
179
|
+
case_id: string;
|
|
180
|
+
title: string;
|
|
181
|
+
outcome: Outcome;
|
|
182
|
+
post_training_cutoff: boolean | null;
|
|
183
|
+
repeat: number;
|
|
184
|
+
/** most fundamental level among the decisive objections (classify call, made without the roast) */
|
|
185
|
+
decisive_level: DecisiveLevel | null;
|
|
186
|
+
decisive_levels: ClassifyItem[];
|
|
187
|
+
/** the decisive message is inside the shown window (position <= blind_n) */
|
|
188
|
+
decisive_in_window: boolean;
|
|
189
|
+
/** cutoff dates as used by the run (from case-meta.json / roast.json) */
|
|
190
|
+
cutoffs: { case_cutoff_date: string; last_shown_date: string | null; precedent_cutoff: string | null };
|
|
191
|
+
verdict: Verdict | null;
|
|
192
|
+
confidence: string | null;
|
|
193
|
+
error: string | null;
|
|
194
|
+
verdict_lenient: boolean | null;
|
|
195
|
+
verdict_strict: boolean | null;
|
|
196
|
+
decisive: DecisiveScore | null;
|
|
197
|
+
earlier: EarlierScore | null;
|
|
198
|
+
surviving_fatal: number | null;
|
|
199
|
+
false_alarm: boolean | null;
|
|
200
|
+
cost: { roast_calls: number; judge_calls: number; cost_usd: number | null; wall_ms: number | null };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function scoreCase(c: EvalCase, r: Roast | null, judge: JudgeItem[] | null, corpusRows: CorpusThreadRow[],
|
|
204
|
+
extra: { repeat: number; error?: string | null; judgeCalls?: number; judgeCost?: number | null; wallMs?: number | null; classify?: ClassifyItem[] | null;
|
|
205
|
+
lastShown?: string | null; precedentCutoff?: string | null }): CaseScore {
|
|
206
|
+
const levels = extra.classify ?? [];
|
|
207
|
+
const base = {
|
|
208
|
+
case_id: c.id, title: c.title, outcome: c.outcome, post_training_cutoff: c.post_training_cutoff, repeat: extra.repeat,
|
|
209
|
+
decisive_level: caseLevel(levels), decisive_levels: levels, decisive_in_window: decisiveInWindow(c),
|
|
210
|
+
cutoffs: {
|
|
211
|
+
case_cutoff_date: c.cutoff_date,
|
|
212
|
+
last_shown_date: extra.lastShown ?? null,
|
|
213
|
+
precedent_cutoff: extra.precedentCutoff ?? r?.precedent_cutoff ?? null,
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
if (!r) {
|
|
217
|
+
return {
|
|
218
|
+
...base, verdict: null, confidence: null, error: extra.error ?? "no roast.json",
|
|
219
|
+
verdict_lenient: null, verdict_strict: null, decisive: null, earlier: null, surviving_fatal: null, false_alarm: null,
|
|
220
|
+
cost: { roast_calls: 0, judge_calls: extra.judgeCalls ?? 0, cost_usd: null, wall_ms: extra.wallMs ?? null },
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const vm = verdictMatch(c.outcome, r.verdict);
|
|
224
|
+
const rc = r.stats?.cost_usd ?? null, jc = extra.judgeCost ?? null;
|
|
225
|
+
return {
|
|
226
|
+
...base, verdict: r.verdict, confidence: r.confidence, error: null,
|
|
227
|
+
verdict_lenient: vm.lenient, verdict_strict: vm.strict,
|
|
228
|
+
decisive: scoreDecisive(c, r, judge),
|
|
229
|
+
earlier: scoreEarlier(c, r, corpusRows),
|
|
230
|
+
surviving_fatal: survivingFatal(r),
|
|
231
|
+
false_alarm: isFalseAlarm(c, r),
|
|
232
|
+
cost: {
|
|
233
|
+
roast_calls: r.stats?.llm_calls ?? 0, judge_calls: extra.judgeCalls ?? 0,
|
|
234
|
+
cost_usd: rc === null && jc === null ? null : (rc ?? 0) + (jc ?? 0),
|
|
235
|
+
wall_ms: extra.wallMs ?? r.stats?.wall_ms ?? null,
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface Ratio { num: number; den: number; pct: number | null }
|
|
241
|
+
const ratio = (num: number, den: number): Ratio => ({ num, den, pct: den ? Math.round((1000 * num) / den) / 10 : null });
|
|
242
|
+
|
|
243
|
+
export interface Headline {
|
|
244
|
+
cases: number;
|
|
245
|
+
errors: number;
|
|
246
|
+
verdict_lenient: Ratio;
|
|
247
|
+
verdict_strict: Ratio;
|
|
248
|
+
decisive_found: Ratio;
|
|
249
|
+
decisive_found_or_partial: Ratio;
|
|
250
|
+
decisive_found_but_refuted: number;
|
|
251
|
+
earlier_found: Ratio;
|
|
252
|
+
earlier_not_in_corpus: number;
|
|
253
|
+
earlier_retrieved_only: number;
|
|
254
|
+
false_alarm_rate: Ratio;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function headline(scores: CaseScore[]): Headline {
|
|
258
|
+
const ok = scores.filter((s) => !s.error);
|
|
259
|
+
const dec = ok.filter((s) => s.decisive?.status === "scored");
|
|
260
|
+
const ear = ok.filter((s) => s.earlier && s.earlier.status !== "n/a" && s.earlier.status !== "not in corpus");
|
|
261
|
+
const acc = ok.filter((s) => s.outcome === "accepted");
|
|
262
|
+
return {
|
|
263
|
+
cases: scores.length,
|
|
264
|
+
errors: scores.length - ok.length,
|
|
265
|
+
verdict_lenient: ratio(ok.filter((s) => s.verdict_lenient).length, ok.length),
|
|
266
|
+
verdict_strict: ratio(ok.filter((s) => s.verdict_strict).length, ok.length),
|
|
267
|
+
decisive_found: ratio(dec.filter((s) => s.decisive!.found_surviving).length, dec.length),
|
|
268
|
+
decisive_found_or_partial: ratio(dec.filter((s) => s.decisive!.result === "found" || s.decisive!.result === "partially").length, dec.length),
|
|
269
|
+
decisive_found_but_refuted: dec.filter((s) => s.decisive!.result === "found" && s.decisive!.bucket === "refuted").length,
|
|
270
|
+
earlier_found: ratio(ear.filter((s) => s.earlier!.status === "found").length, ear.length),
|
|
271
|
+
earlier_not_in_corpus: ok.filter((s) => s.earlier?.status === "not in corpus").length,
|
|
272
|
+
earlier_retrieved_only: ok.filter((s) => s.earlier?.status === "retrieved_only").length,
|
|
273
|
+
false_alarm_rate: ratio(acc.filter((s) => s.false_alarm).length, acc.length),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// `pgroast doctor`: which CLIs are installed, their versions, and whether a trivial completion
|
|
2
|
+
// works on each with the subscription login. No API key is read or needed anywhere.
|
|
3
|
+
|
|
4
|
+
import { mkdtempSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { API_KEY_VARS, BACKENDS, CliBackend, childEnv, claudeDriver, codexDriver, modelsFromEnv, onPath, runCli, type BackendName } from "./llm.ts";
|
|
8
|
+
import type { JsonSchema } from "./schema.ts";
|
|
9
|
+
|
|
10
|
+
export interface DoctorResult {
|
|
11
|
+
backend: BackendName;
|
|
12
|
+
found: boolean;
|
|
13
|
+
path: string | null;
|
|
14
|
+
version: string | null;
|
|
15
|
+
model: string;
|
|
16
|
+
completion: "ok" | "failed" | "skipped";
|
|
17
|
+
error: string | null;
|
|
18
|
+
hint: string | null;
|
|
19
|
+
wallMs: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const PING_SCHEMA: JsonSchema = { type: "object", properties: { ok: { type: "boolean" } } };
|
|
23
|
+
|
|
24
|
+
export async function checkBackend(name: BackendName, opts: { skipCompletion?: boolean } = {}): Promise<DoctorResult> {
|
|
25
|
+
const cwd = mkdtempSync(join(tmpdir(), "pgroast-doctor-"));
|
|
26
|
+
const driver = name === "codex" ? codexDriver(cwd) : claudeDriver();
|
|
27
|
+
const path = onPath(driver.bin);
|
|
28
|
+
const res: DoctorResult = { backend: name, found: !!path, path, version: null, model: "", completion: "skipped", error: null, hint: null, wallMs: 0 };
|
|
29
|
+
if (!path) {
|
|
30
|
+
res.hint = name === "claude" ? "install Claude Code, then run `claude` and sign in" : "install Codex CLI, then run `codex login`";
|
|
31
|
+
return res;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const v = await runCli(driver.bin, ["--version"], "", cwd, 20_000);
|
|
35
|
+
res.version = v.stdout.trim().split("\n")[0] || null;
|
|
36
|
+
} catch (e) {
|
|
37
|
+
res.error = `--version failed: ${(e as Error).message}`;
|
|
38
|
+
}
|
|
39
|
+
let model: string;
|
|
40
|
+
try {
|
|
41
|
+
model = modelsFromEnv(process.env, name).roaster;
|
|
42
|
+
} catch (e) {
|
|
43
|
+
res.completion = "failed";
|
|
44
|
+
res.error = (e as Error).message;
|
|
45
|
+
return res;
|
|
46
|
+
}
|
|
47
|
+
res.model = model;
|
|
48
|
+
if (opts.skipCompletion) return res;
|
|
49
|
+
|
|
50
|
+
const t0 = Date.now();
|
|
51
|
+
try {
|
|
52
|
+
const backend = new CliBackend({ backend: name, transcriptDir: join(cwd, "llm"), timeoutMs: 120_000, log: () => undefined });
|
|
53
|
+
const out = await backend.complete<{ ok: boolean }>({
|
|
54
|
+
label: "doctor",
|
|
55
|
+
model,
|
|
56
|
+
system: "You are a JSON emitter. Reply with one JSON object and nothing else.",
|
|
57
|
+
prompt: 'Reply with exactly {"ok":true}',
|
|
58
|
+
schema: PING_SCHEMA,
|
|
59
|
+
});
|
|
60
|
+
res.completion = out.ok === true ? "ok" : "failed";
|
|
61
|
+
if (out.ok !== true) res.error = "unexpected reply";
|
|
62
|
+
} catch (e) {
|
|
63
|
+
res.completion = "failed";
|
|
64
|
+
res.error = (e as Error).message.replace(/\s+/g, " ").slice(0, 600);
|
|
65
|
+
res.hint = `if you are not signed in: ${driver.loginHint}`;
|
|
66
|
+
}
|
|
67
|
+
res.wallMs = Date.now() - t0;
|
|
68
|
+
return res;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function renderDoctor(results: DoctorResult[], env: Record<string, string | undefined> = process.env): string {
|
|
72
|
+
const lines: string[] = ["pgroast doctor", ""];
|
|
73
|
+
for (const r of results) {
|
|
74
|
+
lines.push(`${r.backend}:`);
|
|
75
|
+
if (!r.found) {
|
|
76
|
+
lines.push(" CLI: not found on PATH", ` -> ${r.hint}`, "");
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
lines.push(` CLI: ${r.path}`);
|
|
80
|
+
lines.push(` version: ${r.version ?? "unknown"}`);
|
|
81
|
+
lines.push(` model: ${r.model || "?"}`);
|
|
82
|
+
lines.push(
|
|
83
|
+
r.completion === "ok"
|
|
84
|
+
? ` completion: ok (${Math.round(r.wallMs / 1000)} s; tools off, subscription login)`
|
|
85
|
+
: r.completion === "skipped"
|
|
86
|
+
? " completion: not tested"
|
|
87
|
+
: ` completion: FAILED: ${r.error}`,
|
|
88
|
+
);
|
|
89
|
+
if (r.completion === "failed" && r.hint) lines.push(` -> ${r.hint}`);
|
|
90
|
+
lines.push("");
|
|
91
|
+
}
|
|
92
|
+
lines.push(`Claude Code login in use: ${env.CLAUDE_CONFIG_DIR ? `CLAUDE_CONFIG_DIR=${env.CLAUDE_CONFIG_DIR}` : "the default one (CLAUDE_CONFIG_DIR is not set)"}. Each config directory has its own login.`);
|
|
93
|
+
const set = API_KEY_VARS.filter((k) => env[k]);
|
|
94
|
+
lines.push(
|
|
95
|
+
set.length
|
|
96
|
+
? `API key variables set in this shell (${set.join(", ")}): ignored. PG Roast removes them from the CLI's environment and uses the subscription login.`
|
|
97
|
+
: "No API key variables are set; none are needed. PG Roast uses the CLI's subscription login.",
|
|
98
|
+
);
|
|
99
|
+
const usable = results.filter((r) => r.completion === "ok").map((r) => r.backend);
|
|
100
|
+
lines.push(usable.length ? `Usable backends: ${usable.join(", ")}.` : "No usable backend. Sign in to one of the CLIs and run `pgroast doctor` again.");
|
|
101
|
+
return lines.join("\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function runDoctor(): Promise<{ text: string; ok: boolean }> {
|
|
105
|
+
void childEnv; // the CLIs are always spawned through runCli, which strips API key variables
|
|
106
|
+
const results: DoctorResult[] = [];
|
|
107
|
+
for (const b of BACKENDS) results.push(await checkBackend(b));
|
|
108
|
+
return { text: renderDoctor(results), ok: results.some((r) => r.completion === "ok") };
|
|
109
|
+
}
|