codecartographer-pi 0.25.0 → 0.26.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/.codecarto/broadside/SKILL.md +4 -1
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/README.md +2 -2
- package/dist/core/broadside/client.d.ts +56 -0
- package/dist/core/broadside/client.js +200 -0
- package/dist/core/broadside/collect.d.ts +68 -0
- package/dist/core/broadside/collect.js +676 -0
- package/dist/core/broadside/constants.d.ts +51 -0
- package/dist/core/broadside/constants.js +74 -0
- package/dist/core/broadside/lenses.d.ts +31 -0
- package/dist/core/broadside/lenses.js +312 -0
- package/dist/core/broadside/models.d.ts +46 -0
- package/dist/core/broadside/models.js +321 -0
- package/dist/core/broadside/render.d.ts +20 -0
- package/dist/core/broadside/render.js +285 -0
- package/dist/core/broadside/repo.d.ts +58 -0
- package/dist/core/broadside/repo.js +592 -0
- package/dist/core/broadside/requests.d.ts +23 -0
- package/dist/core/broadside/requests.js +71 -0
- package/dist/core/broadside/results.d.ts +36 -0
- package/dist/core/broadside/results.js +163 -0
- package/dist/core/broadside/schemas.d.ts +2 -0
- package/dist/core/broadside/schemas.js +342 -0
- package/dist/core/broadside/state.d.ts +99 -0
- package/dist/core/broadside/state.js +384 -0
- package/dist/core/broadside/submit.d.ts +30 -0
- package/dist/core/broadside/submit.js +350 -0
- package/dist/core/broadside/types.d.ts +491 -0
- package/dist/core/broadside/types.js +107 -0
- package/dist/core/{broadside-verify.d.ts → broadside/verify.d.ts} +23 -2
- package/dist/core/{broadside-verify.js → broadside/verify.js} +43 -5
- package/dist/core/broadside.d.ts +14 -952
- package/dist/core/broadside.js +25 -3726
- package/dist/core/completion.js +91 -72
- package/dist/core/dashboard-writer.js +9 -1
- package/dist/core/index.d.ts +0 -1
- package/dist/core/index.js +0 -1
- package/dist/core/library.d.ts +24 -1
- package/dist/core/library.js +46 -15
- package/dist/core/orchestrator-config.js +22 -8
- package/dist/core/status.d.ts +42 -23
- package/dist/core/status.js +163 -137
- package/dist/core/workspace.d.ts +2 -0
- package/dist/core/workspace.js +49 -25
- package/dist/core/yaml.js +9 -3
- package/dist/extensions/codecarto/auto-runner.js +41 -23
- package/dist/extensions/codecarto/index.js +9 -4
- package/dist/extensions/codecarto/phase-compaction.js +6 -2
- package/dist/mcp-server/server.js +15 -4
- package/package.json +1 -1
package/dist/core/broadside.js
CHANGED
|
@@ -31,3729 +31,28 @@
|
|
|
31
31
|
// executable surfaces (Pi and MCP), not the pure template. What the template does
|
|
32
32
|
// carry is the reading guide for its output — `.codecarto/broadside/SKILL.md`,
|
|
33
33
|
// served by codecarto_skill under the name `broadside` (see readBroadsideSkill).
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
import
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
export
|
|
47
|
-
export
|
|
48
|
-
export
|
|
49
|
-
|
|
50
|
-
export
|
|
51
|
-
export
|
|
52
|
-
export
|
|
53
|
-
export
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
// came out at half its true cost and `max_cost` bound at twice what the user
|
|
60
|
-
// asked for. They are the offline fallback only; the live catalog wins.
|
|
61
|
-
export const BROADSIDE_INPUT_PRICE_PER_M = 0.375;
|
|
62
|
-
export const BROADSIDE_OUTPUT_PRICE_PER_M = 1.875;
|
|
63
|
-
// OpenRouter's public model catalog; pricing, context, and capabilities live
|
|
64
|
-
// per model id. The benchmarks endpoint adds coding/intelligence indices.
|
|
65
|
-
export const BROADSIDE_MODELS_URL = "https://openrouter.ai/api/v1/models";
|
|
66
|
-
export const BROADSIDE_BENCHMARKS_URL = "https://openrouter.ai/api/v1/benchmarks";
|
|
67
|
-
export const BROADSIDE_CATALOG_CACHE_FILE = "model-catalog.json";
|
|
68
|
-
/**
|
|
69
|
-
* What this repository's own submits learned about batch endpoints: which
|
|
70
|
-
* `:batch` ids OpenRouter accepted a job for and which it refused with
|
|
71
|
-
* "does not have a :batch endpoint". The catalog cannot tell the two apart
|
|
72
|
-
* (#141), so the `models` action annotates its rows from this file.
|
|
73
|
-
*/
|
|
74
|
-
export const BROADSIDE_ENDPOINTS_FILE = "batch-endpoints.json";
|
|
75
|
-
export const BROADSIDE_CATALOG_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
76
|
-
export const BROADSIDE_LENS_IDS = [
|
|
77
|
-
"architecture",
|
|
78
|
-
"api",
|
|
79
|
-
"security",
|
|
80
|
-
"defect",
|
|
81
|
-
"conventions",
|
|
82
|
-
"porting",
|
|
83
|
-
];
|
|
84
|
-
export const BROADSIDE_POLL_INTERVAL_MS = 15_000;
|
|
85
|
-
export const BROADSIDE_DEFAULT_POLL_BUDGET_MS = 25 * 60 * 1000;
|
|
86
|
-
/**
|
|
87
|
-
* The run expense limit in USD a repository gets before it configures one.
|
|
88
|
-
* Pi asks a human before submitting over the estimate; the MCP surface cannot,
|
|
89
|
-
* and shipped with no limit at all, so a host calling submit with the stock
|
|
90
|
-
* config spent whatever the estimate came to (#231). One dollar covers a
|
|
91
|
-
* six-lens run of a repository this size with room to spare; a larger one
|
|
92
|
-
* raises `max_cost` in config.yaml, passes `max_cost` on the call, or sets it
|
|
93
|
-
* to 0 for no limit.
|
|
94
|
-
*/
|
|
95
|
-
export const BROADSIDE_DEFAULT_MAX_COST = 1;
|
|
96
|
-
/**
|
|
97
|
-
* The reasoning control every lens request carries: low effort.
|
|
98
|
-
*
|
|
99
|
-
* It used to be a token cap — `max_tokens` at a quarter of the lens's output
|
|
100
|
-
* budget, so three quarters stayed for the answer. Measured live on
|
|
101
|
-
* `google/gemini-3.8-flash:batch` (0.22.0 verification, defect lens, cap
|
|
102
|
-
* 5,800 of a 6,000 budget): the model reasoned 5,218 tokens on the first
|
|
103
|
-
* pass and **11,518 under the same cap** on the doubled-budget retry —
|
|
104
|
-
* thinking scaled with `max_tokens` and the cap changed nothing, both
|
|
105
|
-
* results truncated, and the retry cost twice the original for no JSON.
|
|
106
|
-
* The same lens with `effort: "low"` reasoned 0 tokens, finished with
|
|
107
|
-
* `stop`, returned valid JSON, and cost a twelfth as much. Gemini 3.x
|
|
108
|
-
* models take a thinking *level*, not a budget, and OpenRouter forwards a
|
|
109
|
-
* `max_tokens` cap to them as nothing at all; `effort` is what it can
|
|
110
|
-
* translate for every provider (a level where the provider has levels, a
|
|
111
|
-
* fraction of the budget where it takes a budget). So the default asks for
|
|
112
|
-
* little thinking in the one vocabulary that reaches everyone.
|
|
113
|
-
*
|
|
114
|
-
* Deliberately not `enabled: false`: `google/gemini-3.8-flash:batch` refuses
|
|
115
|
-
* the whole batch with *"Reasoning is mandatory for this endpoint and cannot
|
|
116
|
-
* be disabled"*, turning a partial result into none at all. Low effort works
|
|
117
|
-
* whether or not a provider allows reasoning to be switched off.
|
|
118
|
-
*/
|
|
119
|
-
export const BROADSIDE_DEFAULT_REASONING = Object.freeze({ effort: "low" });
|
|
120
|
-
/** The reasoning control a lens request carries when config.yaml sets none. */
|
|
121
|
-
export function defaultReasoningFor() {
|
|
122
|
-
return { ...BROADSIDE_DEFAULT_REASONING };
|
|
123
|
-
}
|
|
124
|
-
/**
|
|
125
|
-
* The reasoning control a truncated slice is re-submitted with.
|
|
126
|
-
*
|
|
127
|
-
* A truncation on a reasoning-capable model is usually thinking that ate the
|
|
128
|
-
* answer's budget, and doubling `max_tokens` doubles the thinking where the
|
|
129
|
-
* provider ignores a token cap (see {@link BROADSIDE_DEFAULT_REASONING}). The
|
|
130
|
-
* retry therefore asks for low effort as well, replacing a `max_tokens` cap
|
|
131
|
-
* (OpenRouter refuses a request carrying both) and lowering a higher effort.
|
|
132
|
-
* An explicit `enabled: false` and an effort already at or below low are left
|
|
133
|
-
* as they are.
|
|
134
|
-
*/
|
|
135
|
-
export function retryReasoningFor(original) {
|
|
136
|
-
if (original?.enabled === false)
|
|
137
|
-
return { ...original };
|
|
138
|
-
if (original?.effort === "minimal" || original?.effort === "low")
|
|
139
|
-
return { ...original };
|
|
140
|
-
const { max_tokens: _cap, effort: _effort, ...rest } = original ?? {};
|
|
141
|
-
return { ...rest, effort: "low" };
|
|
142
|
-
}
|
|
143
|
-
export const BROADSIDE_RUN_SLOTS = ["synthesis", "triage", "retry"];
|
|
144
|
-
/**
|
|
145
|
-
* OpenRouter rejected the API key (HTTP 401/403). Thrown from the catalog
|
|
146
|
-
* lookup rather than swallowed into "could not price" or a silent built-in
|
|
147
|
-
* fallback: a run that cannot authenticate cannot submit either, and the
|
|
148
|
-
* message that reaches the user has to say so (#251).
|
|
149
|
-
*/
|
|
150
|
-
export class BroadsideAuthError extends Error {
|
|
151
|
-
httpStatus;
|
|
152
|
-
detail;
|
|
153
|
-
constructor(httpStatus, detail) {
|
|
154
|
-
super(`OpenRouter rejected the API key (HTTP ${httpStatus}${detail ? `: ${detail}` : ""}). ` +
|
|
155
|
-
"Check OPENROUTER_API_KEY, the api_key parameter, or api_key in .codecarto/broadside/config.yaml. Nothing was submitted.");
|
|
156
|
-
this.name = "BroadsideAuthError";
|
|
157
|
-
this.httpStatus = httpStatus;
|
|
158
|
-
this.detail = detail;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
/**
|
|
162
|
-
* `broadside/config.yaml` exists but cannot be used. A file that failed to
|
|
163
|
-
* parse used to be treated exactly like an absent one — defaults, including
|
|
164
|
-
* no spend cap and no lens routing, with no message — so a typo removed the
|
|
165
|
-
* user's own guard (#232). Only an absent file yields defaults now.
|
|
166
|
-
*/
|
|
167
|
-
export class BroadsideConfigError extends Error {
|
|
168
|
-
path;
|
|
169
|
-
constructor(path, detail) {
|
|
170
|
-
super(`Broad-Side config ${path} ${detail}. Fix or remove the file; nothing runs on defaults while it is unreadable.`);
|
|
171
|
-
this.name = "BroadsideConfigError";
|
|
172
|
-
this.path = path;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
/**
|
|
176
|
-
* `broadside/state.json` exists but cannot be read. It used to be read as
|
|
177
|
-
* empty and the next checkpoint wrote that empty state over it, losing the
|
|
178
|
-
* batch ids of every in-flight, already-paid run (#233). The corrupt file is
|
|
179
|
-
* preserved beside itself and nothing writes over it until someone looks.
|
|
180
|
-
*/
|
|
181
|
-
export class BroadsideStateError extends Error {
|
|
182
|
-
path;
|
|
183
|
-
backupPath;
|
|
184
|
-
constructor(path, backupPath, detail) {
|
|
185
|
-
super(`Broad-Side state ${path} ${detail}. A copy is preserved at ${backupPath}; the file is not overwritten. ` +
|
|
186
|
-
"Repair state.json from the copy (each run's batch ids are what collect needs), or move it aside to start fresh.");
|
|
187
|
-
this.name = "BroadsideStateError";
|
|
188
|
-
this.path = path;
|
|
189
|
-
this.backupPath = backupPath;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
/** Thrown when a confirm hook declines a run. Nothing was submitted. */
|
|
193
|
-
export class BroadsideCancelledError extends Error {
|
|
194
|
-
constructor(message = "Broad-Side submission cancelled. Nothing was submitted.") {
|
|
195
|
-
super(message);
|
|
196
|
-
this.name = "BroadsideCancelledError";
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
// ---------- JSON schemas (one per lens, plus synthesis) ----------
|
|
200
|
-
const SCHEMAS = {
|
|
201
|
-
architecture: {
|
|
202
|
-
name: "architecture_report",
|
|
203
|
-
strict: true,
|
|
204
|
-
schema: {
|
|
205
|
-
type: "object",
|
|
206
|
-
properties: {
|
|
207
|
-
tech_stack: {
|
|
208
|
-
type: "object",
|
|
209
|
-
properties: {
|
|
210
|
-
language: { type: "string" },
|
|
211
|
-
version: { type: "string" },
|
|
212
|
-
build_system: { type: "string" },
|
|
213
|
-
key_dependencies: { type: "array", items: { type: "string" } },
|
|
214
|
-
},
|
|
215
|
-
required: ["language", "build_system"],
|
|
216
|
-
additionalProperties: false,
|
|
217
|
-
},
|
|
218
|
-
module_architecture: {
|
|
219
|
-
type: "array",
|
|
220
|
-
items: {
|
|
221
|
-
type: "object",
|
|
222
|
-
properties: {
|
|
223
|
-
name: { type: "string" },
|
|
224
|
-
role: { type: "string" },
|
|
225
|
-
file_count: { type: "integer" },
|
|
226
|
-
depends_on: { type: "array", items: { type: "string" } },
|
|
227
|
-
},
|
|
228
|
-
required: ["name", "role"],
|
|
229
|
-
additionalProperties: false,
|
|
230
|
-
},
|
|
231
|
-
},
|
|
232
|
-
data_flow: { type: "string" },
|
|
233
|
-
entry_points: { type: "array", items: { type: "string" } },
|
|
234
|
-
notable_patterns: { type: "array", items: { type: "string" } },
|
|
235
|
-
},
|
|
236
|
-
required: ["tech_stack", "module_architecture", "data_flow", "entry_points"],
|
|
237
|
-
additionalProperties: false,
|
|
238
|
-
},
|
|
239
|
-
},
|
|
240
|
-
api_surface: {
|
|
241
|
-
name: "api_surface_report",
|
|
242
|
-
strict: true,
|
|
243
|
-
schema: {
|
|
244
|
-
type: "object",
|
|
245
|
-
properties: {
|
|
246
|
-
endpoints: {
|
|
247
|
-
type: "array",
|
|
248
|
-
items: {
|
|
249
|
-
type: "object",
|
|
250
|
-
properties: {
|
|
251
|
-
method: { type: "string" },
|
|
252
|
-
path: { type: "string" },
|
|
253
|
-
handler: { type: "string" },
|
|
254
|
-
auth_required: { type: "boolean" },
|
|
255
|
-
description: { type: "string" },
|
|
256
|
-
},
|
|
257
|
-
required: ["method", "path", "handler", "auth_required"],
|
|
258
|
-
additionalProperties: false,
|
|
259
|
-
},
|
|
260
|
-
},
|
|
261
|
-
data_types: {
|
|
262
|
-
type: "array",
|
|
263
|
-
items: {
|
|
264
|
-
type: "object",
|
|
265
|
-
properties: {
|
|
266
|
-
name: { type: "string" },
|
|
267
|
-
kind: { type: "string" },
|
|
268
|
-
fields_summary: { type: "string" },
|
|
269
|
-
},
|
|
270
|
-
required: ["name", "kind"],
|
|
271
|
-
additionalProperties: false,
|
|
272
|
-
},
|
|
273
|
-
},
|
|
274
|
-
authentication_flow: { type: "string" },
|
|
275
|
-
error_handling: { type: "string" },
|
|
276
|
-
},
|
|
277
|
-
required: ["endpoints"],
|
|
278
|
-
additionalProperties: false,
|
|
279
|
-
},
|
|
280
|
-
},
|
|
281
|
-
security: {
|
|
282
|
-
name: "security_review_report",
|
|
283
|
-
strict: true,
|
|
284
|
-
schema: {
|
|
285
|
-
type: "object",
|
|
286
|
-
properties: {
|
|
287
|
-
findings: {
|
|
288
|
-
type: "array",
|
|
289
|
-
items: {
|
|
290
|
-
type: "object",
|
|
291
|
-
properties: {
|
|
292
|
-
severity: { type: "string", enum: ["critical", "high", "medium", "low"] },
|
|
293
|
-
category: { type: "string" },
|
|
294
|
-
title: { type: "string" },
|
|
295
|
-
location: { type: "string" },
|
|
296
|
-
description: { type: "string" },
|
|
297
|
-
},
|
|
298
|
-
required: ["severity", "title", "description"],
|
|
299
|
-
additionalProperties: false,
|
|
300
|
-
},
|
|
301
|
-
},
|
|
302
|
-
overall_assessment: { type: "string" },
|
|
303
|
-
coverage_note: { type: "string" },
|
|
304
|
-
},
|
|
305
|
-
required: ["findings", "overall_assessment"],
|
|
306
|
-
additionalProperties: false,
|
|
307
|
-
},
|
|
308
|
-
},
|
|
309
|
-
defect_mechanical: {
|
|
310
|
-
name: "defect_scan_report",
|
|
311
|
-
strict: true,
|
|
312
|
-
schema: {
|
|
313
|
-
type: "object",
|
|
314
|
-
properties: {
|
|
315
|
-
module: { type: "string" },
|
|
316
|
-
findings: {
|
|
317
|
-
type: "array",
|
|
318
|
-
items: {
|
|
319
|
-
type: "object",
|
|
320
|
-
properties: {
|
|
321
|
-
severity: { type: "string", enum: ["high", "medium", "low"] },
|
|
322
|
-
pattern: { type: "string" },
|
|
323
|
-
title: { type: "string" },
|
|
324
|
-
location: { type: "string" },
|
|
325
|
-
description: { type: "string" },
|
|
326
|
-
suggestion: { type: "string" },
|
|
327
|
-
},
|
|
328
|
-
required: ["severity", "pattern", "title", "description"],
|
|
329
|
-
additionalProperties: false,
|
|
330
|
-
},
|
|
331
|
-
},
|
|
332
|
-
patterns_checked: { type: "array", items: { type: "string" } },
|
|
333
|
-
files_scanned: { type: "integer" },
|
|
334
|
-
overall_notes: { type: "string" },
|
|
335
|
-
},
|
|
336
|
-
required: ["module", "findings", "patterns_checked", "files_scanned"],
|
|
337
|
-
additionalProperties: false,
|
|
338
|
-
},
|
|
339
|
-
},
|
|
340
|
-
conventions: {
|
|
341
|
-
name: "conventions_report",
|
|
342
|
-
strict: true,
|
|
343
|
-
schema: {
|
|
344
|
-
type: "object",
|
|
345
|
-
properties: {
|
|
346
|
-
module: { type: "string" },
|
|
347
|
-
naming_conventions: {
|
|
348
|
-
type: "object",
|
|
349
|
-
properties: {
|
|
350
|
-
packages: { type: "string" },
|
|
351
|
-
types: { type: "string" },
|
|
352
|
-
functions: { type: "string" },
|
|
353
|
-
variables: { type: "string" },
|
|
354
|
-
files: { type: "string" },
|
|
355
|
-
tests: { type: "string" },
|
|
356
|
-
},
|
|
357
|
-
additionalProperties: false,
|
|
358
|
-
},
|
|
359
|
-
error_handling_pattern: { type: "string" },
|
|
360
|
-
logging_approach: { type: "string" },
|
|
361
|
-
test_patterns: { type: "string" },
|
|
362
|
-
code_organization: { type: "string" },
|
|
363
|
-
idioms: { type: "array", items: { type: "string" } },
|
|
364
|
-
inconsistencies: {
|
|
365
|
-
type: "array",
|
|
366
|
-
items: {
|
|
367
|
-
type: "object",
|
|
368
|
-
properties: {
|
|
369
|
-
description: { type: "string" },
|
|
370
|
-
locations: { type: "array", items: { type: "string" } },
|
|
371
|
-
},
|
|
372
|
-
required: ["description"],
|
|
373
|
-
additionalProperties: false,
|
|
374
|
-
},
|
|
375
|
-
},
|
|
376
|
-
promotable_conventions: {
|
|
377
|
-
type: "array",
|
|
378
|
-
items: {
|
|
379
|
-
type: "object",
|
|
380
|
-
properties: {
|
|
381
|
-
title: { type: "string" },
|
|
382
|
-
rule: { type: "string" },
|
|
383
|
-
evidence: { type: "string" },
|
|
384
|
-
},
|
|
385
|
-
required: ["title", "rule"],
|
|
386
|
-
additionalProperties: false,
|
|
387
|
-
},
|
|
388
|
-
},
|
|
389
|
-
files_scanned: { type: "integer" },
|
|
390
|
-
},
|
|
391
|
-
required: ["module", "naming_conventions", "files_scanned"],
|
|
392
|
-
additionalProperties: false,
|
|
393
|
-
},
|
|
394
|
-
},
|
|
395
|
-
porting: {
|
|
396
|
-
name: "porting_surface_report",
|
|
397
|
-
strict: true,
|
|
398
|
-
schema: {
|
|
399
|
-
type: "object",
|
|
400
|
-
properties: {
|
|
401
|
-
module: { type: "string" },
|
|
402
|
-
platform_coupling: {
|
|
403
|
-
type: "array",
|
|
404
|
-
items: {
|
|
405
|
-
type: "object",
|
|
406
|
-
properties: {
|
|
407
|
-
platform: { type: "string" },
|
|
408
|
-
mechanisms: { type: "array", items: { type: "string" } },
|
|
409
|
-
files: { type: "array", items: { type: "string" } },
|
|
410
|
-
},
|
|
411
|
-
required: ["platform", "mechanisms"],
|
|
412
|
-
additionalProperties: false,
|
|
413
|
-
},
|
|
414
|
-
},
|
|
415
|
-
external_dependencies: {
|
|
416
|
-
type: "array",
|
|
417
|
-
items: {
|
|
418
|
-
type: "object",
|
|
419
|
-
properties: {
|
|
420
|
-
name: { type: "string" },
|
|
421
|
-
role: { type: "string" },
|
|
422
|
-
replaceability: { type: "string" },
|
|
423
|
-
},
|
|
424
|
-
required: ["name"],
|
|
425
|
-
additionalProperties: false,
|
|
426
|
-
},
|
|
427
|
-
},
|
|
428
|
-
build_system_complexity: { type: "string" },
|
|
429
|
-
porting_risk_areas: {
|
|
430
|
-
type: "array",
|
|
431
|
-
items: {
|
|
432
|
-
type: "object",
|
|
433
|
-
properties: {
|
|
434
|
-
area: { type: "string" },
|
|
435
|
-
risk: { type: "string", enum: ["low", "medium", "high"] },
|
|
436
|
-
notes: { type: "string" },
|
|
437
|
-
},
|
|
438
|
-
required: ["area", "risk"],
|
|
439
|
-
additionalProperties: false,
|
|
440
|
-
},
|
|
441
|
-
},
|
|
442
|
-
files_scanned: { type: "integer" },
|
|
443
|
-
},
|
|
444
|
-
required: ["module", "platform_coupling", "files_scanned"],
|
|
445
|
-
additionalProperties: false,
|
|
446
|
-
},
|
|
447
|
-
},
|
|
448
|
-
synthesis: {
|
|
449
|
-
name: "synthesis_report",
|
|
450
|
-
strict: true,
|
|
451
|
-
schema: {
|
|
452
|
-
type: "object",
|
|
453
|
-
properties: {
|
|
454
|
-
executive_summary: { type: "string" },
|
|
455
|
-
severity_summary: {
|
|
456
|
-
type: "object",
|
|
457
|
-
properties: {
|
|
458
|
-
critical: { type: "integer" },
|
|
459
|
-
high: { type: "integer" },
|
|
460
|
-
medium: { type: "integer" },
|
|
461
|
-
low: { type: "integer" },
|
|
462
|
-
},
|
|
463
|
-
required: ["critical", "high", "medium", "low"],
|
|
464
|
-
additionalProperties: false,
|
|
465
|
-
},
|
|
466
|
-
top_findings: {
|
|
467
|
-
type: "array",
|
|
468
|
-
items: {
|
|
469
|
-
type: "object",
|
|
470
|
-
properties: {
|
|
471
|
-
title: { type: "string" },
|
|
472
|
-
severity: { type: "string" },
|
|
473
|
-
source_lens: { type: "string" },
|
|
474
|
-
summary: { type: "string" },
|
|
475
|
-
},
|
|
476
|
-
required: ["title", "severity", "source_lens", "summary"],
|
|
477
|
-
additionalProperties: false,
|
|
478
|
-
},
|
|
479
|
-
},
|
|
480
|
-
module_assessments: {
|
|
481
|
-
type: "array",
|
|
482
|
-
items: {
|
|
483
|
-
type: "object",
|
|
484
|
-
properties: {
|
|
485
|
-
module: { type: "string" },
|
|
486
|
-
quality_notes: { type: "string" },
|
|
487
|
-
risk_level: { type: "string", enum: ["low", "medium", "high"] },
|
|
488
|
-
},
|
|
489
|
-
required: ["module", "risk_level"],
|
|
490
|
-
additionalProperties: false,
|
|
491
|
-
},
|
|
492
|
-
},
|
|
493
|
-
porting_readiness: { type: "string" },
|
|
494
|
-
gaps_and_unknowns: { type: "array", items: { type: "string" } },
|
|
495
|
-
coverage: { type: "string" },
|
|
496
|
-
},
|
|
497
|
-
required: ["executive_summary", "severity_summary", "top_findings"],
|
|
498
|
-
additionalProperties: false,
|
|
499
|
-
},
|
|
500
|
-
},
|
|
501
|
-
triage: {
|
|
502
|
-
name: "triage_report",
|
|
503
|
-
strict: true,
|
|
504
|
-
schema: {
|
|
505
|
-
type: "object",
|
|
506
|
-
properties: {
|
|
507
|
-
summary: { type: "string" },
|
|
508
|
-
items: {
|
|
509
|
-
type: "array",
|
|
510
|
-
items: {
|
|
511
|
-
type: "object",
|
|
512
|
-
properties: {
|
|
513
|
-
title: { type: "string" },
|
|
514
|
-
severity: { type: "string" },
|
|
515
|
-
module: { type: "string" },
|
|
516
|
-
impact: { type: "string", enum: ["high", "medium", "low"] },
|
|
517
|
-
difficulty: { type: "string", enum: ["high", "medium", "low"] },
|
|
518
|
-
priority: { type: "string" },
|
|
519
|
-
effort_estimate: { type: "string" },
|
|
520
|
-
rationale: { type: "string" },
|
|
521
|
-
},
|
|
522
|
-
required: ["title", "severity", "module", "impact", "difficulty", "priority", "rationale"],
|
|
523
|
-
additionalProperties: false,
|
|
524
|
-
},
|
|
525
|
-
},
|
|
526
|
-
omitted: {
|
|
527
|
-
type: "array",
|
|
528
|
-
items: { type: "string" },
|
|
529
|
-
description: "Leads deliberately dropped from the queue and why (duplicates, too vague, out of scope)",
|
|
530
|
-
},
|
|
531
|
-
},
|
|
532
|
-
required: ["summary", "items"],
|
|
533
|
-
additionalProperties: false,
|
|
534
|
-
},
|
|
535
|
-
},
|
|
536
|
-
};
|
|
537
|
-
const TS_PROFILE = {
|
|
538
|
-
defectPatterns: [
|
|
539
|
-
"Null/undefined dereference risks (unchecked optional access)",
|
|
540
|
-
"Error handling gaps (unhandled promise rejections, swallowed catches)",
|
|
541
|
-
"Resource leaks (unclosed handles, missing cleanup, dangling timers/listeners)",
|
|
542
|
-
"Race conditions (shared mutable state, async interleavings without guards)",
|
|
543
|
-
"Integer/precision assumptions in arithmetic",
|
|
544
|
-
"Unsafe type assumptions (as-casts, any leaks, non-null assertions)",
|
|
545
|
-
"Panic-prone code (out-of-bounds access, runtime TypeError paths)",
|
|
546
|
-
"Timezone/locale assumptions",
|
|
547
|
-
],
|
|
548
|
-
conventionCategories: [
|
|
549
|
-
{ key: "packages", label: "modules and imports" },
|
|
550
|
-
{ key: "types", label: "interfaces and type aliases" },
|
|
551
|
-
{ key: "functions", label: "functions (camelCase), components (PascalCase)" },
|
|
552
|
-
{ key: "variables", label: "variables and constants (camelCase)" },
|
|
553
|
-
{ key: "files", label: "file naming (kebab vs camel) and folder organization" },
|
|
554
|
-
{ key: "tests", label: "test files (*.test.ts, describe/it patterns)" },
|
|
555
|
-
],
|
|
556
|
-
idiomHints: ["strict null checks usage", "async/await vs promise chains", "dependency injection patterns"],
|
|
557
|
-
};
|
|
558
|
-
const LANGUAGE_PROFILES = {
|
|
559
|
-
go: {
|
|
560
|
-
defectPatterns: [
|
|
561
|
-
"Nil pointer dereference risks (unchecked returns, missing nil guards)",
|
|
562
|
-
"Error handling gaps (ignored errors, deferred errors unchecked)",
|
|
563
|
-
"Resource leaks (unclosed files, connections, goroutines without ctx)",
|
|
564
|
-
"Race conditions (shared state without sync, channel misuse)",
|
|
565
|
-
"Integer overflow/underflow in arithmetic or bounds",
|
|
566
|
-
"Unsafe type assertions without ok check",
|
|
567
|
-
"Panic-prone code (slice out of bounds, map access without ok)",
|
|
568
|
-
"Timezone/locale assumptions",
|
|
569
|
-
],
|
|
570
|
-
conventionCategories: [
|
|
571
|
-
{ key: "packages", label: "packages" },
|
|
572
|
-
{ key: "types", label: "types and interfaces" },
|
|
573
|
-
{ key: "functions", label: "functions and methods" },
|
|
574
|
-
{ key: "variables", label: "variables and fields" },
|
|
575
|
-
{ key: "files", label: "file and directory organization" },
|
|
576
|
-
{ key: "tests", label: "test files and table-driven tests" },
|
|
577
|
-
],
|
|
578
|
-
idiomHints: ["error wrapping with %w", "zero-value construction"],
|
|
579
|
-
},
|
|
580
|
-
python: {
|
|
581
|
-
defectPatterns: [
|
|
582
|
-
"None dereference risks (unchecked optional returns, AttributeError paths)",
|
|
583
|
-
"Exception handling gaps (bare except, swallowed exceptions, broad catch-all)",
|
|
584
|
-
"Resource leaks (unclosed files, sockets, connections, context managers)",
|
|
585
|
-
"Race conditions (shared mutable state, threading without locks, async pitfalls)",
|
|
586
|
-
"Integer/float precision assumptions in arithmetic",
|
|
587
|
-
"Unsafe type assumptions (unpacking mismatches, isinstance without fallback)",
|
|
588
|
-
"Panic-prone code (IndexError/KeyError paths, unbounded slicing)",
|
|
589
|
-
"Timezone/locale assumptions (naive datetimes)",
|
|
590
|
-
],
|
|
591
|
-
conventionCategories: [
|
|
592
|
-
{ key: "packages", label: "modules and packages" },
|
|
593
|
-
{ key: "types", label: "classes and type hints" },
|
|
594
|
-
{ key: "functions", label: "functions and methods (snake_case vs camelCase)" },
|
|
595
|
-
{ key: "variables", label: "variables and constants" },
|
|
596
|
-
{ key: "files", label: "file and module organization" },
|
|
597
|
-
{ key: "tests", label: "test files (pytest fixtures, naming)" },
|
|
598
|
-
],
|
|
599
|
-
idiomHints: ["dunder method usage", "context manager idioms", "dataclass/pydantic models"],
|
|
600
|
-
},
|
|
601
|
-
rust: {
|
|
602
|
-
defectPatterns: [
|
|
603
|
-
"Unwrap/expect panics on fallible paths",
|
|
604
|
-
"Error handling gaps (swallowed Results, lossy conversions)",
|
|
605
|
-
"Resource leaks (unclosed handles, drop order assumptions)",
|
|
606
|
-
"Data races and Send/Sync violations (unsafe blocks, interior mutability misuse)",
|
|
607
|
-
"Integer overflow/underflow (arithmetic, casting)",
|
|
608
|
-
"Unsafe type assumptions (transmute/casts without invariants)",
|
|
609
|
-
"Panic-prone code (indexing, slicing, unreachable! in library paths)",
|
|
610
|
-
"Timezone/locale assumptions",
|
|
611
|
-
],
|
|
612
|
-
conventionCategories: [
|
|
613
|
-
{ key: "packages", label: "crates and modules" },
|
|
614
|
-
{ key: "types", label: "structs, enums, and traits" },
|
|
615
|
-
{ key: "functions", label: "functions and methods (snake_case)" },
|
|
616
|
-
{ key: "variables", label: "variables and constants (SCREAMING_SNAKE)" },
|
|
617
|
-
{ key: "files", label: "module file organization" },
|
|
618
|
-
{ key: "tests", label: "test modules and #[cfg(test)] patterns" },
|
|
619
|
-
],
|
|
620
|
-
idiomHints: ["Result/Option handling with ?", "builder patterns", "trait-based extension"],
|
|
621
|
-
},
|
|
622
|
-
typescript: TS_PROFILE,
|
|
623
|
-
javascript: TS_PROFILE,
|
|
624
|
-
default: {
|
|
625
|
-
defectPatterns: [
|
|
626
|
-
"Null/undefined dereference risks (unchecked optional access)",
|
|
627
|
-
"Error handling gaps (ignored or swallowed errors)",
|
|
628
|
-
"Resource leaks (unclosed files, connections, handles)",
|
|
629
|
-
"Race conditions (shared mutable state without synchronization)",
|
|
630
|
-
"Integer overflow/underflow in arithmetic or bounds",
|
|
631
|
-
"Unsafe type assumptions and unchecked casts",
|
|
632
|
-
"Panic-prone code (out-of-bounds access, missing keys)",
|
|
633
|
-
"Timezone/locale assumptions",
|
|
634
|
-
],
|
|
635
|
-
conventionCategories: [
|
|
636
|
-
{ key: "packages", label: "modules, packages, or namespaces" },
|
|
637
|
-
{ key: "types", label: "types, classes, and interfaces" },
|
|
638
|
-
{ key: "functions", label: "functions and methods" },
|
|
639
|
-
{ key: "variables", label: "variables and constants" },
|
|
640
|
-
{ key: "files", label: "file and directory organization" },
|
|
641
|
-
{ key: "tests", label: "test files and test organization" },
|
|
642
|
-
],
|
|
643
|
-
idiomHints: [],
|
|
644
|
-
},
|
|
645
|
-
};
|
|
646
|
-
function languageProfile(language) {
|
|
647
|
-
return LANGUAGE_PROFILES[language] ?? LANGUAGE_PROFILES.default;
|
|
648
|
-
}
|
|
649
|
-
const LENSES = {
|
|
650
|
-
architecture: {
|
|
651
|
-
id: "architecture",
|
|
652
|
-
name: "Architecture, tech stack & module map",
|
|
653
|
-
description: "Repo-wide structural analysis from the manifest, entry point, README, and file tree.",
|
|
654
|
-
schemaName: "architecture",
|
|
655
|
-
sliceBy: "none",
|
|
656
|
-
maxChars: 0, // repo-info lens; no file slurping
|
|
657
|
-
maxTokens: 8000,
|
|
658
|
-
globsFor: () => [],
|
|
659
|
-
systemPrompt: () => "You are a senior software architect performing a structural analysis of a " +
|
|
660
|
-
"codebase. You receive the project manifest, entry point, README excerpt, and " +
|
|
661
|
-
"file tree. Return a JSON object following the architecture_report schema " +
|
|
662
|
-
"exactly. All findings must be traceable to the provided files — cite file " +
|
|
663
|
-
"paths. If you can't determine something, say so rather than guessing.",
|
|
664
|
-
userPrompt: (info) => {
|
|
665
|
-
const manifest = info.manifest
|
|
666
|
-
? `## ${info.manifest.path}\n\`\`\`\n${info.manifest.content}\n\`\`\`\n\n`
|
|
667
|
-
: "## Manifest\n[no manifest found]\n\n";
|
|
668
|
-
return ("Analyze the architecture of this project.\n\n" +
|
|
669
|
-
manifest +
|
|
670
|
-
`## Entry point\n\`\`\`\n${info.mainFile || "[missing]"}\n\`\`\`\n\n` +
|
|
671
|
-
`## README (first 4000 chars)\n${info.readmeFirst || "[missing]"}\n\n` +
|
|
672
|
-
`## File tree (depth 3, capped)\n${info.fileTree || "[missing]"}\n\n` +
|
|
673
|
-
"## File counts by extension\n```json\n" +
|
|
674
|
-
JSON.stringify(info.fileCounts) +
|
|
675
|
-
"\n```\n\n" +
|
|
676
|
-
"Return the architecture_report JSON schema.");
|
|
677
|
-
},
|
|
678
|
-
},
|
|
679
|
-
api: {
|
|
680
|
-
id: "api",
|
|
681
|
-
name: "API surface audit",
|
|
682
|
-
description: "Endpoint catalog, request/response types, auth flow, error handling.",
|
|
683
|
-
schemaName: "api_surface",
|
|
684
|
-
sliceBy: "none",
|
|
685
|
-
maxChars: 70_000,
|
|
686
|
-
maxTokens: 8000,
|
|
687
|
-
skipTestFiles: true,
|
|
688
|
-
globsFor: (info) => info.language === "go"
|
|
689
|
-
? ["server/**/*.go", "server/*.go", "api/**/*.go", "api/*.go"]
|
|
690
|
-
: [
|
|
691
|
-
"server/**",
|
|
692
|
-
"api/**",
|
|
693
|
-
"src/server/**",
|
|
694
|
-
"src/api/**",
|
|
695
|
-
"mcp-server/**",
|
|
696
|
-
"**/*routes*",
|
|
697
|
-
"**/*router*",
|
|
698
|
-
"**/*handler*",
|
|
699
|
-
"**/*endpoint*",
|
|
700
|
-
],
|
|
701
|
-
fallbackGlobsFor: (info) => [info.sourceGlob],
|
|
702
|
-
systemPrompt: () => "You are a senior API auditor. Given source files from an HTTP server, " +
|
|
703
|
-
"extract every HTTP endpoint (method, path, handler function, auth requirement) " +
|
|
704
|
-
"and every key request/response data type. Return a JSON object following the " +
|
|
705
|
-
"api_surface_report schema exactly. Cite specific file:line locations.",
|
|
706
|
-
userPrompt: (info, source, moduleName) => "Extract the full API surface from these server source files:\n\n" +
|
|
707
|
-
source +
|
|
708
|
-
"\n\nReturn the api_surface_report JSON schema.",
|
|
709
|
-
},
|
|
710
|
-
security: {
|
|
711
|
-
id: "security",
|
|
712
|
-
name: "Security review",
|
|
713
|
-
description: "Auth, authorization, input validation, TLS, secrets, trust boundaries.",
|
|
714
|
-
schemaName: "security",
|
|
715
|
-
sliceBy: "none",
|
|
716
|
-
maxChars: 70_000,
|
|
717
|
-
maxTokens: 8000,
|
|
718
|
-
skipTestFiles: true,
|
|
719
|
-
globsFor: (info) => info.language === "go"
|
|
720
|
-
? ["server/**/*.go", "server/*.go", "**/auth*.go", "**/middleware/**/*.go", "SECURITY.md"]
|
|
721
|
-
: ["server/**", "**/auth*", "**/middleware/**", "SECURITY.md"],
|
|
722
|
-
fallbackGlobsFor: (info) => [info.sourceGlob],
|
|
723
|
-
systemPrompt: () => "You are a security engineer performing a first-pass review of a codebase. " +
|
|
724
|
-
"Given source files, identify potential security issues — focusing on " +
|
|
725
|
-
"authentication, authorization, input validation, TLS, secrets handling, " +
|
|
726
|
-
"and trust boundaries. Return a JSON object following the security_review_report " +
|
|
727
|
-
"schema. Rate severity as critical/high/medium/low. Be specific: cite file:line. " +
|
|
728
|
-
"If the provided files don't cover an area, state the gap in coverage_note.",
|
|
729
|
-
userPrompt: (info, source, moduleName) => "Review these server source files for security issues:\n\n" +
|
|
730
|
-
source +
|
|
731
|
-
"\n\nReturn the security_review_report JSON schema.",
|
|
732
|
-
},
|
|
733
|
-
defect: {
|
|
734
|
-
id: "defect",
|
|
735
|
-
name: "Mechanical defect scan",
|
|
736
|
-
description: "Nil derefs, error gaps, leaks, races, panics — pattern-based, sliced per module.",
|
|
737
|
-
schemaName: "defect_mechanical",
|
|
738
|
-
sliceBy: "auto",
|
|
739
|
-
maxChars: 60_000,
|
|
740
|
-
maxTokens: 6000,
|
|
741
|
-
globsFor: (info) => [info.sourceGlob],
|
|
742
|
-
systemPrompt: (info) => {
|
|
743
|
-
const profile = languageProfile(info.language);
|
|
744
|
-
const patterns = profile.defectPatterns.map((p, i) => ` ${i + 1}. ${p}`).join("\n");
|
|
745
|
-
return (`You are a senior code reviewer performing an automated defect scan on ${info.language} ` +
|
|
746
|
-
"source files. Look for these specific patterns:\n" +
|
|
747
|
-
patterns +
|
|
748
|
-
"\n\n" +
|
|
749
|
-
"Return a JSON object following the defect_scan_report schema. " +
|
|
750
|
-
"Cite file:line for every finding. List which patterns you checked. " +
|
|
751
|
-
"If the code looks clean for a pattern, say so rather than staying silent. " +
|
|
752
|
-
"Prefer precision over volume — 3 solid findings beat 15 vague ones.\n\n" +
|
|
753
|
-
// The verification pass (#143) confirmed 2 of the 12 top findings a
|
|
754
|
-
// scan produced with the paragraph above alone; the other ten were
|
|
755
|
-
// casts and assertions every caller satisfied, guards that lived one
|
|
756
|
-
// call away, or environments the project does not target. The rubric
|
|
757
|
-
// the verifier applies is asked of the scan itself, up front.
|
|
758
|
-
"A finding is a reachable failure: name in the description the concrete input, call site, or sequence " +
|
|
759
|
-
"that reaches it and what then goes wrong. A cast, assertion, `any`, or non-null `!` that every caller " +
|
|
760
|
-
"you can see satisfies, a hypothetical about a runtime or environment the project does not target, or a " +
|
|
761
|
-
"style or type-hygiene observation is not a defect — leave it out, or if it is worth a note, report it " +
|
|
762
|
-
"at severity low under the pattern name `type-hygiene` so it ranks apart from reachable failures. " +
|
|
763
|
-
"When the guard you looked for may live in another module, say which check you could not find " +
|
|
764
|
-
"rather than asserting it is absent; severity high or medium is for failures you traced to a trigger.");
|
|
765
|
-
},
|
|
766
|
-
userPrompt: (info, source, moduleName) => `Scan this ${info.language} module for mechanical defects.\n\n` +
|
|
767
|
-
`Module: ${moduleName}\n\n` +
|
|
768
|
-
"## Source files\n\n" +
|
|
769
|
-
source +
|
|
770
|
-
"\n\nReturn the defect_scan_report JSON schema.",
|
|
771
|
-
},
|
|
772
|
-
conventions: {
|
|
773
|
-
id: "conventions",
|
|
774
|
-
name: "Convention extraction",
|
|
775
|
-
description: "Naming, error handling, idioms, inconsistencies, promotable conventions.",
|
|
776
|
-
schemaName: "conventions",
|
|
777
|
-
sliceBy: "auto",
|
|
778
|
-
maxChars: 60_000,
|
|
779
|
-
maxTokens: 6000,
|
|
780
|
-
globsFor: (info) => [info.sourceGlob],
|
|
781
|
-
systemPrompt: (info) => {
|
|
782
|
-
const profile = languageProfile(info.language);
|
|
783
|
-
const categories = profile.conventionCategories.map((c) => `${c.key} (${c.label})`).join(", ");
|
|
784
|
-
const idiomHint = profile.idiomHints.length > 0
|
|
785
|
-
? ` Keep an eye out for ${info.language} idioms such as ${profile.idiomHints.join(", ")}.`
|
|
786
|
-
: "";
|
|
787
|
-
return (`You are a code style analyst extracting conventions from ${info.language} source files. ` +
|
|
788
|
-
"Catalog naming conventions per category — " + categories + " — plus the dominant " +
|
|
789
|
-
"error-handling pattern, logging approach, test organization patterns, file/package " +
|
|
790
|
-
"organization rules, and recurring idioms." + idiomHint +
|
|
791
|
-
" Also flag inconsistencies — places where the same convention is violated. " +
|
|
792
|
-
"If you find well-established conventions worth formalizing, list them as " +
|
|
793
|
-
"promotable_conventions with a title, rule, and evidence from the code. " +
|
|
794
|
-
"Return a JSON object following the conventions_report schema.");
|
|
795
|
-
},
|
|
796
|
-
userPrompt: (info, source, moduleName) => "Extract coding conventions from this module.\n\n" +
|
|
797
|
-
`Module: ${moduleName}\n\n` +
|
|
798
|
-
"## Source files\n\n" +
|
|
799
|
-
source +
|
|
800
|
-
"\n\nReturn the conventions_report JSON schema.",
|
|
801
|
-
},
|
|
802
|
-
porting: {
|
|
803
|
-
id: "porting",
|
|
804
|
-
name: "Porting surface assessment",
|
|
805
|
-
description: "Platform coupling, external deps, build complexity, porting risk areas.",
|
|
806
|
-
schemaName: "porting",
|
|
807
|
-
sliceBy: "auto",
|
|
808
|
-
maxChars: 60_000,
|
|
809
|
-
maxTokens: 6000,
|
|
810
|
-
skipTestFiles: true,
|
|
811
|
-
globsFor: (info) => [
|
|
812
|
-
info.sourceGlob,
|
|
813
|
-
"**/*.c",
|
|
814
|
-
"**/*.h",
|
|
815
|
-
"**/*.cpp",
|
|
816
|
-
"**/*.cc",
|
|
817
|
-
"**/*.m",
|
|
818
|
-
"**/*.mm",
|
|
819
|
-
"**/CMakeLists.txt",
|
|
820
|
-
"**/*.cmake",
|
|
821
|
-
"go.mod",
|
|
822
|
-
],
|
|
823
|
-
systemPrompt: () => "You are a software portability analyst. Examine source files and " +
|
|
824
|
-
"identify everything that ties this codebase to a specific platform, OS, " +
|
|
825
|
-
"architecture, or external dependency. Catalog: platform-specific build tags, " +
|
|
826
|
-
"FFI usage, OS-specific syscalls, external library bindings, and " +
|
|
827
|
-
"compile-time constants that encode platform assumptions. " +
|
|
828
|
-
"For each external dependency, note whether it could be replaced by a " +
|
|
829
|
-
"cross-platform alternative. Assess the build system complexity. " +
|
|
830
|
-
"Return a JSON object following the porting_surface_report schema.",
|
|
831
|
-
userPrompt: (info, source, moduleName) => "Assess porting surface for this module.\n\n" +
|
|
832
|
-
`Module: ${moduleName}\n\n` +
|
|
833
|
-
"## Source files\n\n" +
|
|
834
|
-
source +
|
|
835
|
-
"\n\nReturn the porting_surface_report JSON schema.",
|
|
836
|
-
},
|
|
837
|
-
};
|
|
838
|
-
export function getLens(lensId) {
|
|
839
|
-
return LENSES[lensId];
|
|
840
|
-
}
|
|
841
|
-
export function listLenses() {
|
|
842
|
-
return BROADSIDE_LENS_IDS.map((id) => LENSES[id]);
|
|
843
|
-
}
|
|
844
|
-
// ---------- repo info ----------
|
|
845
|
-
const SKIP_DIR_NAMES = new Set([
|
|
846
|
-
".git",
|
|
847
|
-
".github",
|
|
848
|
-
".claude",
|
|
849
|
-
".opencode",
|
|
850
|
-
".codecarto",
|
|
851
|
-
"node_modules",
|
|
852
|
-
"vendor",
|
|
853
|
-
"dist",
|
|
854
|
-
"build",
|
|
855
|
-
"target",
|
|
856
|
-
"testdata",
|
|
857
|
-
"__pycache__",
|
|
858
|
-
]);
|
|
859
|
-
const SKIP_FILE_EXTENSIONS = new Set([
|
|
860
|
-
".png",
|
|
861
|
-
".jpg",
|
|
862
|
-
".jpeg",
|
|
863
|
-
".gif",
|
|
864
|
-
".svg",
|
|
865
|
-
".ico",
|
|
866
|
-
".icns",
|
|
867
|
-
".bmp",
|
|
868
|
-
".webp",
|
|
869
|
-
".mp3",
|
|
870
|
-
".mp4",
|
|
871
|
-
".mov",
|
|
872
|
-
".avi",
|
|
873
|
-
".wav",
|
|
874
|
-
".ogg",
|
|
875
|
-
".zip",
|
|
876
|
-
".gz",
|
|
877
|
-
".tar",
|
|
878
|
-
".bz2",
|
|
879
|
-
".xz",
|
|
880
|
-
".7z",
|
|
881
|
-
".pdf",
|
|
882
|
-
".woff",
|
|
883
|
-
".woff2",
|
|
884
|
-
".ttf",
|
|
885
|
-
".eot",
|
|
886
|
-
".otf",
|
|
887
|
-
".bin",
|
|
888
|
-
".exe",
|
|
889
|
-
".dll",
|
|
890
|
-
".so",
|
|
891
|
-
".dylib",
|
|
892
|
-
".a",
|
|
893
|
-
".o",
|
|
894
|
-
".obj",
|
|
895
|
-
".class",
|
|
896
|
-
".jar",
|
|
897
|
-
".war",
|
|
898
|
-
".pyc",
|
|
899
|
-
".wasm",
|
|
900
|
-
".model",
|
|
901
|
-
".bpe",
|
|
902
|
-
]);
|
|
903
|
-
/**
|
|
904
|
-
* Manifest files and the languages each one can mean. `package.json` covers
|
|
905
|
-
* both TypeScript and JavaScript; which of the two a repository is comes from
|
|
906
|
-
* counting its source files, not from the manifest.
|
|
907
|
-
*/
|
|
908
|
-
const MANIFEST_CANDIDATES = [
|
|
909
|
-
["go.mod", ["go"]],
|
|
910
|
-
["package.json", ["typescript", "javascript"]],
|
|
911
|
-
["Cargo.toml", ["rust"]],
|
|
912
|
-
["pyproject.toml", ["python"]],
|
|
913
|
-
["setup.py", ["python"]],
|
|
914
|
-
["requirements.txt", ["python"]],
|
|
915
|
-
];
|
|
916
|
-
/** The languages Broad-Side can scan; anything else is refused at submit. */
|
|
917
|
-
export const BROADSIDE_LANGUAGES = ["go", "python", "rust", "typescript", "javascript"];
|
|
918
|
-
/** Chars of the entry-point file and the manifest that ride in the architecture prompt (#249). */
|
|
919
|
-
const REPO_INFO_FILE_CAP = 20_000;
|
|
920
|
-
const SOURCE_SPECS = {
|
|
921
|
-
go: { glob: "**/*.go", exts: [".go"] },
|
|
922
|
-
python: { glob: "**/*.py", exts: [".py"] },
|
|
923
|
-
rust: { glob: "**/*.rs", exts: [".rs"] },
|
|
924
|
-
typescript: { glob: "**/*.ts", exts: [".ts", ".tsx"] },
|
|
925
|
-
javascript: { glob: "**/*.js", exts: [".js", ".jsx"] },
|
|
926
|
-
};
|
|
927
|
-
/**
|
|
928
|
-
* The files a run scans, and where they came from. Contents are always read
|
|
929
|
-
* from the working tree, so the list is the working tree's too: tracked files
|
|
930
|
-
* plus untracked ones git does not ignore, minus files deleted on disk. The
|
|
931
|
-
* list used to come from `git ls-tree HEAD`, so a run mixed the committed
|
|
932
|
-
* file list with uncommitted contents and never saw an untracked file (#248).
|
|
933
|
-
* A target that is not a git repository gets a bounded walk.
|
|
934
|
-
*/
|
|
935
|
-
export async function listRepoFiles(targetDir) {
|
|
936
|
-
try {
|
|
937
|
-
const listed = await execFileAsync("git", ["-C", targetDir, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { maxBuffer: 64 * 1024 * 1024, timeout: GIT_TIMEOUT_MS });
|
|
938
|
-
const deleted = await execFileAsync("git", ["-C", targetDir, "ls-files", "-z", "--deleted"], {
|
|
939
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
940
|
-
timeout: GIT_TIMEOUT_MS,
|
|
941
|
-
});
|
|
942
|
-
const gone = new Set(deleted.stdout.split("\0").filter(Boolean));
|
|
943
|
-
const files = listed.stdout.split("\0").filter((path) => path && !gone.has(path));
|
|
944
|
-
return { files, snapshot: "working-tree" };
|
|
945
|
-
}
|
|
946
|
-
catch {
|
|
947
|
-
return { files: await walkFiles(targetDir, targetDir, 0, 30_000), snapshot: "walk" };
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
async function gitHead(targetDir) {
|
|
951
|
-
try {
|
|
952
|
-
const { stdout } = await execFileAsync("git", ["-C", targetDir, "rev-parse", "HEAD"], { maxBuffer: 1024 * 1024, timeout: GIT_TIMEOUT_MS });
|
|
953
|
-
return stdout.trim() || null;
|
|
954
|
-
}
|
|
955
|
-
catch {
|
|
956
|
-
return null;
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
async function gitDirty(targetDir) {
|
|
960
|
-
try {
|
|
961
|
-
const { stdout } = await execFileAsync("git", ["-C", targetDir, "status", "--porcelain"], { maxBuffer: 1024 * 1024, timeout: GIT_TIMEOUT_MS });
|
|
962
|
-
return stdout.trim().length > 0;
|
|
963
|
-
}
|
|
964
|
-
catch {
|
|
965
|
-
return false;
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
|
-
/**
|
|
969
|
-
* Repo-relative paths changed since `baseHead` (or all files when there is
|
|
970
|
-
* no base). Returns null when the diff cannot be computed (non-git tree,
|
|
971
|
-
* missing base commit) so callers fall back to a full scan.
|
|
972
|
-
*/
|
|
973
|
-
async function changedFilesSince(targetDir, baseHead) {
|
|
974
|
-
if (!baseHead)
|
|
975
|
-
return null;
|
|
976
|
-
try {
|
|
977
|
-
const { stdout } = await execFileAsync("git", ["-C", targetDir, "diff", "--name-only", baseHead, "HEAD"], { maxBuffer: 64 * 1024 * 1024, timeout: GIT_TIMEOUT_MS });
|
|
978
|
-
return new Set(stdout.split("\n").filter(Boolean));
|
|
979
|
-
}
|
|
980
|
-
catch {
|
|
981
|
-
return null;
|
|
982
|
-
}
|
|
983
|
-
}
|
|
984
|
-
async function walkFiles(rootDir, dir, depth, remaining) {
|
|
985
|
-
if (remaining <= 0)
|
|
986
|
-
return [];
|
|
987
|
-
let out = [];
|
|
988
|
-
let entries = [];
|
|
989
|
-
try {
|
|
990
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
991
|
-
}
|
|
992
|
-
catch {
|
|
993
|
-
return out;
|
|
994
|
-
}
|
|
995
|
-
for (const entry of entries) {
|
|
996
|
-
if (entry.name.startsWith("."))
|
|
997
|
-
continue;
|
|
998
|
-
if (entry.isDirectory()) {
|
|
999
|
-
if (SKIP_DIR_NAMES.has(entry.name))
|
|
1000
|
-
continue;
|
|
1001
|
-
if (depth > 8)
|
|
1002
|
-
continue;
|
|
1003
|
-
const children = await walkFiles(rootDir, join(dir, entry.name), depth + 1, remaining - out.length);
|
|
1004
|
-
out = out.concat(children);
|
|
1005
|
-
}
|
|
1006
|
-
else if (entry.isFile()) {
|
|
1007
|
-
// relative() rather than slice(rootDir.length + 1): the hand-rolled
|
|
1008
|
-
// slice cut one character too many whenever rootDir carried a trailing
|
|
1009
|
-
// separator, and mangled every path outright when rootDir was "/".
|
|
1010
|
-
const rel = relative(rootDir, join(dir, entry.name)).split("\\").join("/");
|
|
1011
|
-
out.push(rel);
|
|
1012
|
-
}
|
|
1013
|
-
}
|
|
1014
|
-
return out;
|
|
1015
|
-
}
|
|
1016
|
-
function sourceFileCount(language, fileCounts) {
|
|
1017
|
-
return (SOURCE_SPECS[language]?.exts ?? []).reduce((sum, ext) => sum + (fileCounts[ext] ?? 0), 0);
|
|
1018
|
-
}
|
|
1019
|
-
/**
|
|
1020
|
-
* The language the lenses scan as. The manifests present name the candidates
|
|
1021
|
-
* (all of them, not the first one found: a Python service with a
|
|
1022
|
-
* `package.json` for its docs tooling is not a TypeScript repository), and
|
|
1023
|
-
* among candidates the one with the most source files wins; without a
|
|
1024
|
-
* manifest, the language with the most source files; without any source
|
|
1025
|
-
* file, `unknown` — which submit refuses rather than scanning nothing and
|
|
1026
|
-
* paying for it (#250). Ties keep manifest order.
|
|
1027
|
-
*/
|
|
1028
|
-
function detectLanguage(fileCounts, manifestPaths) {
|
|
1029
|
-
const candidates = [];
|
|
1030
|
-
for (const [candidate, languages] of MANIFEST_CANDIDATES) {
|
|
1031
|
-
if (!manifestPaths.includes(candidate))
|
|
1032
|
-
continue;
|
|
1033
|
-
for (const language of languages)
|
|
1034
|
-
if (!candidates.includes(language))
|
|
1035
|
-
candidates.push(language);
|
|
1036
|
-
}
|
|
1037
|
-
const pool = candidates.length > 0 ? candidates : [...BROADSIDE_LANGUAGES];
|
|
1038
|
-
let best = null;
|
|
1039
|
-
let bestCount = -1;
|
|
1040
|
-
for (const language of pool) {
|
|
1041
|
-
const count = sourceFileCount(language, fileCounts);
|
|
1042
|
-
if (count > bestCount) {
|
|
1043
|
-
best = language;
|
|
1044
|
-
bestCount = count;
|
|
1045
|
-
}
|
|
1046
|
-
}
|
|
1047
|
-
if (bestCount > 0)
|
|
1048
|
-
return best;
|
|
1049
|
-
// A manifest with no source files behind it still names the language;
|
|
1050
|
-
// submit reports the empty count. No manifest and no source: unknown.
|
|
1051
|
-
return candidates[0] ?? "unknown";
|
|
1052
|
-
}
|
|
1053
|
-
/** Cut a file that rides whole in a prompt down to the cap, saying so (#249). */
|
|
1054
|
-
function capForPrompt(content, cap) {
|
|
1055
|
-
if (content.length <= cap)
|
|
1056
|
-
return content;
|
|
1057
|
-
return `${content.slice(0, cap)}\n… [truncated: ${cap.toLocaleString()} of ${content.length.toLocaleString()} chars shown]\n`;
|
|
1058
|
-
}
|
|
1059
|
-
export async function collectRepoInfo(targetDir, opts = {}) {
|
|
1060
|
-
const redact = opts.redact ?? true;
|
|
1061
|
-
const { files: allFiles, snapshot } = await listRepoFiles(targetDir);
|
|
1062
|
-
// Named credential stores are out of every lens (isSlurpable); listed here
|
|
1063
|
-
// so the submit report can say so.
|
|
1064
|
-
const secretFilesSkipped = allFiles.filter((path) => isSecretFile(path)).sort();
|
|
1065
|
-
let redactedValues = 0;
|
|
1066
|
-
// The entry point, manifest, and README ride in the architecture prompt
|
|
1067
|
-
// as text, so they get the same pass the slices do (#252).
|
|
1068
|
-
const clean = (text) => {
|
|
1069
|
-
if (!redact)
|
|
1070
|
-
return text;
|
|
1071
|
-
const redaction = redactSecrets(text);
|
|
1072
|
-
redactedValues += redaction.count;
|
|
1073
|
-
return redaction.text;
|
|
1074
|
-
};
|
|
1075
|
-
const fileCounts = {};
|
|
1076
|
-
for (const f of allFiles) {
|
|
1077
|
-
const slash = f.lastIndexOf("/");
|
|
1078
|
-
const base = slash >= 0 ? f.slice(slash + 1) : f;
|
|
1079
|
-
const dot = base.lastIndexOf(".");
|
|
1080
|
-
const ext = dot > 0 ? base.slice(dot).toLowerCase() : "(no ext)";
|
|
1081
|
-
fileCounts[ext] = (fileCounts[ext] ?? 0) + 1;
|
|
1082
|
-
}
|
|
1083
|
-
const sortedCounts = {};
|
|
1084
|
-
for (const [ext, n] of Object.entries(fileCounts).sort((a, b) => b[1] - a[1])) {
|
|
1085
|
-
sortedCounts[ext] = n;
|
|
1086
|
-
}
|
|
1087
|
-
// Every manifest present counts toward language detection; the first one
|
|
1088
|
-
// found is the one the architecture prompt shows.
|
|
1089
|
-
const manifestPaths = [];
|
|
1090
|
-
for (const [candidate] of MANIFEST_CANDIDATES) {
|
|
1091
|
-
if (await pathExists(join(targetDir, candidate)))
|
|
1092
|
-
manifestPaths.push(candidate);
|
|
1093
|
-
}
|
|
1094
|
-
const language = detectLanguage(sortedCounts, manifestPaths);
|
|
1095
|
-
// Show the manifest that belongs to the detected language when there is
|
|
1096
|
-
// one, so a polyglot repo's prompt does not open with the other stack's file.
|
|
1097
|
-
const manifestPath = manifestPaths.find((path) => MANIFEST_CANDIDATES.find(([candidate]) => candidate === path)?.[1].includes(language))
|
|
1098
|
-
?? manifestPaths[0]
|
|
1099
|
-
?? null;
|
|
1100
|
-
let manifest = null;
|
|
1101
|
-
if (manifestPath) {
|
|
1102
|
-
try {
|
|
1103
|
-
manifest = { path: manifestPath, content: capForPrompt(clean(await readFile(join(targetDir, manifestPath), "utf8")), REPO_INFO_FILE_CAP) };
|
|
1104
|
-
}
|
|
1105
|
-
catch {
|
|
1106
|
-
manifest = null;
|
|
1107
|
-
}
|
|
1108
|
-
}
|
|
1109
|
-
// Read whole and unbounded before, and then estimated at a flat 6,000
|
|
1110
|
-
// chars: a large entry point shipped in full while the cap was checked
|
|
1111
|
-
// against a number that had nothing to do with it (#249).
|
|
1112
|
-
let mainFile = "";
|
|
1113
|
-
for (const candidate of ["main.go", "main.py", "src/main.rs", "src/index.ts", "index.ts", "src/index.js", "index.js"]) {
|
|
1114
|
-
const p = join(targetDir, candidate);
|
|
1115
|
-
if (await pathExists(p)) {
|
|
1116
|
-
try {
|
|
1117
|
-
mainFile = capForPrompt(clean(await readFile(p, "utf8")), REPO_INFO_FILE_CAP);
|
|
1118
|
-
}
|
|
1119
|
-
catch {
|
|
1120
|
-
mainFile = "";
|
|
1121
|
-
}
|
|
1122
|
-
break;
|
|
1123
|
-
}
|
|
1124
|
-
}
|
|
1125
|
-
let readmeFirst = "";
|
|
1126
|
-
const readmePath = join(targetDir, "README.md");
|
|
1127
|
-
if (await pathExists(readmePath)) {
|
|
1128
|
-
try {
|
|
1129
|
-
readmeFirst = clean((await readFile(readmePath, "utf8")).slice(0, 4000));
|
|
1130
|
-
}
|
|
1131
|
-
catch {
|
|
1132
|
-
readmeFirst = "";
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
const fileTree = buildFileTree(allFiles);
|
|
1136
|
-
// An unknown language used to fall through to Go's globs, so the code
|
|
1137
|
-
// lenses matched nothing and the run paid for empty batches (#250).
|
|
1138
|
-
const sourceSpec = SOURCE_SPECS[language] ?? { glob: "", exts: [] };
|
|
1139
|
-
const name = targetDir.split(/[\\/]/).filter(Boolean).pop() ?? "repo";
|
|
1140
|
-
const sourceFiles = allFiles.filter((path) => isSlurpable(path) && sourceSpec.exts.some((ext) => path.toLowerCase().endsWith(ext))).length;
|
|
1141
|
-
return {
|
|
1142
|
-
name,
|
|
1143
|
-
path: targetDir,
|
|
1144
|
-
language,
|
|
1145
|
-
manifest,
|
|
1146
|
-
mainFile,
|
|
1147
|
-
readmeFirst,
|
|
1148
|
-
fileTree,
|
|
1149
|
-
fileCounts: sortedCounts,
|
|
1150
|
-
sourceGlob: sourceSpec.glob,
|
|
1151
|
-
sourceExts: sourceSpec.exts,
|
|
1152
|
-
sourceFileCount: sourceFiles,
|
|
1153
|
-
snapshot,
|
|
1154
|
-
secretFilesSkipped,
|
|
1155
|
-
redactedValues,
|
|
1156
|
-
};
|
|
1157
|
-
}
|
|
1158
|
-
function buildFileTree(allFiles, maxDepth = 3, maxLines = 200) {
|
|
1159
|
-
const lines = [];
|
|
1160
|
-
let count = 0;
|
|
1161
|
-
for (const f of allFiles) {
|
|
1162
|
-
if (f.split("/").length - 1 > maxDepth)
|
|
1163
|
-
continue;
|
|
1164
|
-
if (f.startsWith(".git/") || f.startsWith(".github/"))
|
|
1165
|
-
continue;
|
|
1166
|
-
if (f.endsWith(".sum") || f.endsWith(".lock"))
|
|
1167
|
-
continue;
|
|
1168
|
-
lines.push(f);
|
|
1169
|
-
count += 1;
|
|
1170
|
-
if (count >= maxLines) {
|
|
1171
|
-
lines.push(`... (${allFiles.length} total files, showing first ${maxLines})`);
|
|
1172
|
-
break;
|
|
1173
|
-
}
|
|
1174
|
-
}
|
|
1175
|
-
return lines.join("\n");
|
|
1176
|
-
}
|
|
1177
|
-
// ---------- glob matching & file slurping ----------
|
|
1178
|
-
function globToRegExp(glob) {
|
|
1179
|
-
let re = "";
|
|
1180
|
-
for (let i = 0; i < glob.length; i++) {
|
|
1181
|
-
const c = glob[i];
|
|
1182
|
-
if (c === "*") {
|
|
1183
|
-
if (glob[i + 1] === "*") {
|
|
1184
|
-
// `**/` matches zero or more directories; a trailing `**`
|
|
1185
|
-
// matches anything including slashes.
|
|
1186
|
-
if (glob[i + 2] === "/") {
|
|
1187
|
-
re += "(?:.*/)?";
|
|
1188
|
-
i += 2;
|
|
1189
|
-
}
|
|
1190
|
-
else {
|
|
1191
|
-
re += ".*";
|
|
1192
|
-
i += 1;
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
|
-
else {
|
|
1196
|
-
re += "[^/]*";
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
else if (c === "?") {
|
|
1200
|
-
re += "[^/]";
|
|
1201
|
-
}
|
|
1202
|
-
else {
|
|
1203
|
-
re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
1204
|
-
}
|
|
1205
|
-
}
|
|
1206
|
-
return new RegExp(`^${re}$`);
|
|
1207
|
-
}
|
|
1208
|
-
function matchesAnyGlob(path, globs) {
|
|
1209
|
-
for (const glob of globs) {
|
|
1210
|
-
if (globToRegExp(glob).test(path))
|
|
1211
|
-
return true;
|
|
1212
|
-
}
|
|
1213
|
-
return false;
|
|
1214
|
-
}
|
|
1215
|
-
export function isSlurpable(relPath) {
|
|
1216
|
-
// A credential store is never a lens input, whatever its globs say (#252).
|
|
1217
|
-
if (isSecretFile(relPath))
|
|
1218
|
-
return false;
|
|
1219
|
-
const segments = relPath.split("/");
|
|
1220
|
-
for (const seg of segments) {
|
|
1221
|
-
if (SKIP_DIR_NAMES.has(seg))
|
|
1222
|
-
return false;
|
|
1223
|
-
}
|
|
1224
|
-
const slash = relPath.lastIndexOf("/");
|
|
1225
|
-
const base = slash >= 0 ? relPath.slice(slash + 1) : relPath;
|
|
1226
|
-
const dot = base.lastIndexOf(".");
|
|
1227
|
-
if (dot > 0 && SKIP_FILE_EXTENSIONS.has(base.slice(dot).toLowerCase()))
|
|
1228
|
-
return false;
|
|
1229
|
-
return true;
|
|
1230
|
-
}
|
|
1231
|
-
function sanitizeId(segment) {
|
|
1232
|
-
return segment.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "root";
|
|
1233
|
-
}
|
|
1234
|
-
function topLevelModule(relPath) {
|
|
1235
|
-
const slash = relPath.indexOf("/");
|
|
1236
|
-
return slash >= 0 ? relPath.slice(0, slash) : "root";
|
|
1237
|
-
}
|
|
1238
|
-
function isTestFile(relPath) {
|
|
1239
|
-
const base = relPath.slice(relPath.lastIndexOf("/") + 1);
|
|
1240
|
-
return /[._](test|spec)\.[a-z]+$/i.test(base) || base.includes("_test.");
|
|
1241
|
-
}
|
|
1242
|
-
/**
|
|
1243
|
-
* "auto" slicing: directory-slice when the repo is large enough that a
|
|
1244
|
-
* single whole-repo slice would overflow the lens's char cap, otherwise a
|
|
1245
|
-
* single slice. The threshold is the lens's own cap — a repo whose matching
|
|
1246
|
-
* files fit in one slice gains nothing from per-module splitting, and a
|
|
1247
|
-
* small repo pays for it in extra requests.
|
|
1248
|
-
*/
|
|
1249
|
-
function resolveSliceMode(lens, files, totalChars) {
|
|
1250
|
-
if (lens.sliceBy !== "auto")
|
|
1251
|
-
return lens.sliceBy;
|
|
1252
|
-
return totalChars > lens.maxChars ? "directory" : "none";
|
|
1253
|
-
}
|
|
1254
|
-
function collectFilesMatching(allFiles, lens, globs) {
|
|
1255
|
-
if (globs.length === 0)
|
|
1256
|
-
return [];
|
|
1257
|
-
const out = [];
|
|
1258
|
-
for (const f of allFiles) {
|
|
1259
|
-
if (!isSlurpable(f))
|
|
1260
|
-
continue;
|
|
1261
|
-
if (lens.skipTestFiles && isTestFile(f))
|
|
1262
|
-
continue;
|
|
1263
|
-
if (!matchesAnyGlob(f, globs))
|
|
1264
|
-
continue;
|
|
1265
|
-
out.push({ relPath: f, moduleName: topLevelModule(f) });
|
|
1266
|
-
}
|
|
1267
|
-
return out;
|
|
1268
|
-
}
|
|
1269
|
-
/** Code in any language Broad-Side scans as, whatever this repo's is. */
|
|
1270
|
-
const SOURCE_EXTENSIONS = new Set(Object.values(SOURCE_SPECS).flatMap((spec) => spec.exts));
|
|
1271
|
-
function isSourceFile(relPath) {
|
|
1272
|
-
const dot = relPath.lastIndexOf(".");
|
|
1273
|
-
return dot > relPath.lastIndexOf("/") && SOURCE_EXTENSIONS.has(relPath.slice(dot).toLowerCase());
|
|
1274
|
-
}
|
|
1275
|
-
/** `a, b, c and 4 more` — a matched-file list short enough for a status line. */
|
|
1276
|
-
function listSome(paths, max = 3) {
|
|
1277
|
-
if (paths.length <= max)
|
|
1278
|
-
return paths.join(", ");
|
|
1279
|
-
return `${paths.slice(0, max).join(", ")} and ${paths.length - max} more`;
|
|
1280
|
-
}
|
|
1281
|
-
/**
|
|
1282
|
-
* The files a lens will read: its targeted globs, or — when those match no
|
|
1283
|
-
* source file and the lens declares a fallback — the fallback globs on top
|
|
1284
|
-
* of whatever did match, with a sentence saying so (#319). The sentence
|
|
1285
|
-
* travels to the estimate, the batch entry, and the prompt, so a fallback
|
|
1286
|
-
* scan is never a silent one.
|
|
1287
|
-
*
|
|
1288
|
-
* "No source file" rather than "no file": a policy document or a config
|
|
1289
|
-
* file under a targeted path satisfies the globs and leaves the lens with
|
|
1290
|
-
* nothing to review, and the coverage note it writes back is the only sign.
|
|
1291
|
-
*/
|
|
1292
|
-
export function selectLensFiles(allFiles, lens, info) {
|
|
1293
|
-
const globs = lens.globsFor(info).filter(Boolean);
|
|
1294
|
-
const targeted = collectFilesMatching(allFiles, lens, globs);
|
|
1295
|
-
if (globs.length === 0 || !lens.fallbackGlobsFor)
|
|
1296
|
-
return { files: targeted };
|
|
1297
|
-
if (targeted.some((f) => isSourceFile(f.relPath)))
|
|
1298
|
-
return { files: targeted };
|
|
1299
|
-
const fallbackGlobs = lens.fallbackGlobsFor(info).filter(Boolean);
|
|
1300
|
-
const matched = new Set(targeted.map((f) => f.relPath));
|
|
1301
|
-
const sources = collectFilesMatching(allFiles, lens, fallbackGlobs).filter((f) => !matched.has(f.relPath));
|
|
1302
|
-
if (sources.length === 0)
|
|
1303
|
-
return { files: targeted };
|
|
1304
|
-
const excluded = lens.skipTestFiles ? "test files excluded" : "";
|
|
1305
|
-
const scanned = `scanned all ${info.language} sources (${fallbackGlobs.join(", ")})`;
|
|
1306
|
-
return {
|
|
1307
|
-
// What did match rides first: the policy the model is about to check
|
|
1308
|
-
// the code against, ahead of the code.
|
|
1309
|
-
files: [...targeted, ...sources],
|
|
1310
|
-
fallback: targeted.length === 0
|
|
1311
|
-
? `no files matched ${globs.join(", ")}${excluded ? ` (${excluded})` : ""}; ${scanned} instead`
|
|
1312
|
-
: `no source files matched ${globs.join(", ")} (only ${listSome(targeted.map((f) => f.relPath))}` +
|
|
1313
|
-
`${excluded ? `; ${excluded}` : ""}); ${scanned} as well`,
|
|
1314
|
-
};
|
|
1315
|
-
}
|
|
1316
|
-
function collectLensFiles(allFiles, lens, info) {
|
|
1317
|
-
return selectLensFiles(allFiles, lens, info).files;
|
|
1318
|
-
}
|
|
1319
|
-
async function slurpFileList(targetDir, files, maxChars, redact = true) {
|
|
1320
|
-
const slices = [];
|
|
1321
|
-
let currentModule = "";
|
|
1322
|
-
let parts = [];
|
|
1323
|
-
let running = 0;
|
|
1324
|
-
let fileCount = 0;
|
|
1325
|
-
let filePaths = [];
|
|
1326
|
-
let redactedValues = 0;
|
|
1327
|
-
let redactedFiles = [];
|
|
1328
|
-
const flush = () => {
|
|
1329
|
-
if (parts.length === 0)
|
|
1330
|
-
return;
|
|
1331
|
-
slices.push({
|
|
1332
|
-
moduleName: currentModule,
|
|
1333
|
-
content: parts.join("\n"),
|
|
1334
|
-
fileCount,
|
|
1335
|
-
chars: running,
|
|
1336
|
-
files: filePaths,
|
|
1337
|
-
redactedValues,
|
|
1338
|
-
redactedFiles,
|
|
1339
|
-
});
|
|
1340
|
-
parts = [];
|
|
1341
|
-
running = 0;
|
|
1342
|
-
fileCount = 0;
|
|
1343
|
-
filePaths = [];
|
|
1344
|
-
redactedValues = 0;
|
|
1345
|
-
redactedFiles = [];
|
|
1346
|
-
};
|
|
1347
|
-
for (const file of files) {
|
|
1348
|
-
let content = "";
|
|
1349
|
-
try {
|
|
1350
|
-
content = await readFile(join(targetDir, file.relPath), "utf8");
|
|
1351
|
-
}
|
|
1352
|
-
catch (error) {
|
|
1353
|
-
// The listing is the working tree's, so this is a race with a
|
|
1354
|
-
// concurrent delete rather than a listed-but-deleted file; skip it.
|
|
1355
|
-
if (error.code === "ENOENT")
|
|
1356
|
-
continue;
|
|
1357
|
-
content = "[BINARY or UNREADABLE]";
|
|
1358
|
-
}
|
|
1359
|
-
if (redact) {
|
|
1360
|
-
// Before the slice is built, so the count and the chars the estimate
|
|
1361
|
-
// sees are of what is actually sent (#252).
|
|
1362
|
-
const redaction = redactSecrets(content);
|
|
1363
|
-
if (redaction.count > 0) {
|
|
1364
|
-
content = redaction.text;
|
|
1365
|
-
redactedValues += redaction.count;
|
|
1366
|
-
redactedFiles.push(file.relPath);
|
|
1367
|
-
}
|
|
1368
|
-
}
|
|
1369
|
-
const block = `=== ${file.relPath} ===\n${content}\n`;
|
|
1370
|
-
if (file.moduleName !== currentModule && parts.length > 0) {
|
|
1371
|
-
flush();
|
|
1372
|
-
}
|
|
1373
|
-
currentModule = file.moduleName;
|
|
1374
|
-
if (running + block.length > maxChars && parts.length > 0) {
|
|
1375
|
-
// Slice is full: flush it and start another slice for the same module
|
|
1376
|
-
// rather than truncating, so big modules get full coverage.
|
|
1377
|
-
flush();
|
|
1378
|
-
currentModule = file.moduleName;
|
|
1379
|
-
}
|
|
1380
|
-
parts.push(block);
|
|
1381
|
-
running += block.length;
|
|
1382
|
-
fileCount += 1;
|
|
1383
|
-
filePaths.push(file.relPath);
|
|
1384
|
-
}
|
|
1385
|
-
flush();
|
|
1386
|
-
return slices;
|
|
1387
|
-
}
|
|
1388
|
-
export async function gatherSlices(targetDir, lens, info, opts = {}) {
|
|
1389
|
-
const redact = opts.redact ?? true;
|
|
1390
|
-
if (lens.sliceBy === "none" && lens.globsFor(info).length === 0) {
|
|
1391
|
-
// Repo-info lens (architecture): the prompt is built from info alone.
|
|
1392
|
-
return [{ moduleName: "root", content: "", fileCount: 0, chars: 0, files: [] }];
|
|
1393
|
-
}
|
|
1394
|
-
const { files: allFiles } = await listRepoFiles(targetDir);
|
|
1395
|
-
const { files, fallback } = selectLensFiles(allFiles, lens, info);
|
|
1396
|
-
const totalChars = await sumFileSizes(targetDir, files);
|
|
1397
|
-
const mode = resolveSliceMode(lens, files, totalChars);
|
|
1398
|
-
const slices = mode === "none"
|
|
1399
|
-
// Whole-repo slice: one module named after the repo, so a small
|
|
1400
|
-
// repo produces a single request instead of one per directory.
|
|
1401
|
-
? await slurpFileList(targetDir, files.map((f) => ({ ...f, moduleName: info.name })), lens.maxChars, redact)
|
|
1402
|
-
: await slurpFileList(targetDir, files, lens.maxChars, redact);
|
|
1403
|
-
if (fallback)
|
|
1404
|
-
for (const slice of slices)
|
|
1405
|
-
slice.fallback = fallback;
|
|
1406
|
-
return slices;
|
|
1407
|
-
}
|
|
1408
|
-
async function sumFileSizes(targetDir, files) {
|
|
1409
|
-
let total = 0;
|
|
1410
|
-
for (const f of files) {
|
|
1411
|
-
try {
|
|
1412
|
-
total += (await stat(join(targetDir, f.relPath))).size;
|
|
1413
|
-
}
|
|
1414
|
-
catch {
|
|
1415
|
-
// Unreadable file — slurpFileList substitutes a placeholder.
|
|
1416
|
-
}
|
|
1417
|
-
}
|
|
1418
|
-
return total;
|
|
1419
|
-
}
|
|
1420
|
-
// ---------- request building ----------
|
|
1421
|
-
export function buildBatchRequest(lens, info, slice, index, sliceCount, model = BROADSIDE_MODEL, maxTokensOverride, reasoningOverride) {
|
|
1422
|
-
const moduleTag = sanitizeId(slice.moduleName);
|
|
1423
|
-
const customId = sliceCount > 1 ? `${lens.id}-${moduleTag}-${index + 1}` : `${lens.id}-${moduleTag}`;
|
|
1424
|
-
return {
|
|
1425
|
-
custom_id: customId,
|
|
1426
|
-
body: {
|
|
1427
|
-
model,
|
|
1428
|
-
messages: [
|
|
1429
|
-
{ role: "system", content: lens.systemPrompt(info) },
|
|
1430
|
-
{
|
|
1431
|
-
role: "user",
|
|
1432
|
-
content:
|
|
1433
|
-
// A fallback scan is not "server source files": say what it is,
|
|
1434
|
-
// so the model judges the trust boundary wherever it appears
|
|
1435
|
-
// and does not report the missing server/ as a finding (#319).
|
|
1436
|
-
(slice.fallback
|
|
1437
|
-
? `NOTE: this repository has no source files under the paths this lens usually reads (${slice.fallback}). ` +
|
|
1438
|
-
"What follows is every source file it has, after anything those paths did match; locate the trust boundary and the request-handling code wherever they live.\n\n"
|
|
1439
|
-
: "") + lens.userPrompt(info, slice.content, slice.moduleName),
|
|
1440
|
-
},
|
|
1441
|
-
],
|
|
1442
|
-
response_format: { type: "json_schema", json_schema: SCHEMAS[lens.schemaName] },
|
|
1443
|
-
max_tokens: maxTokensOverride ?? lens.maxTokens,
|
|
1444
|
-
// Always sent, never inherited: an absent field means the model's
|
|
1445
|
-
// own default, and that default is what truncated the JSON.
|
|
1446
|
-
reasoning: reasoningOverride ?? lens.reasoning ?? defaultReasoningFor(),
|
|
1447
|
-
},
|
|
1448
|
-
};
|
|
1449
|
-
}
|
|
1450
|
-
/**
|
|
1451
|
-
* Pre-flight cost estimate for one lens.
|
|
1452
|
-
*
|
|
1453
|
-
* Every slice is its own batch request, so both halves scale with the slice
|
|
1454
|
-
* count. The output half used to be a single `maxTokens * 0.75` for the whole
|
|
1455
|
-
* lens no matter how many requests it sent — on a repository that sliced into
|
|
1456
|
-
* 13 modules that budgeted one request's output and shipped thirteen, and a
|
|
1457
|
-
* live run came in at roughly 3x its estimate. Since this number is what
|
|
1458
|
-
* `max_cost` binds against, under-counting it lets a run outspend the cap the
|
|
1459
|
-
* user set.
|
|
1460
|
-
*
|
|
1461
|
-
* @param info - Repo info, when the caller has it: lets the estimate include
|
|
1462
|
-
* the system prompt and JSON schema each request carries. Omitted, the
|
|
1463
|
-
* estimate covers slice content only, which is what the old signature did.
|
|
1464
|
-
*/
|
|
1465
|
-
export function estimateCost(lens, slices, pricing, maxTokensOverride, info) {
|
|
1466
|
-
// With repo info, size each request from the user prompt that would be
|
|
1467
|
-
// sent, which is what the architecture lens is made of: it used to be
|
|
1468
|
-
// estimated at a flat 6,000 chars while the entry point, manifest, README
|
|
1469
|
-
// excerpt and file tree it carries ran to whatever they ran to (#249).
|
|
1470
|
-
// Without info, the slice content alone is what the old signature covered.
|
|
1471
|
-
const sliceChars = slices.reduce((sum, s) => sum + (info ? lens.userPrompt(info, s.content, s.moduleName).length : lens.maxChars === 0 ? 6000 : s.chars), 0);
|
|
1472
|
-
// The system prompt and the response schema ride on every request, so they
|
|
1473
|
-
// are paid once per slice rather than once per lens.
|
|
1474
|
-
const perRequestOverhead = info
|
|
1475
|
-
? (lens.systemPrompt(info)?.length ?? 0) + JSON.stringify(SCHEMAS[lens.schemaName] ?? {}).length
|
|
1476
|
-
: 0;
|
|
1477
|
-
const inputTokens = Math.ceil((sliceChars + perRequestOverhead * slices.length) / 4);
|
|
1478
|
-
const outputTokens = slices.length * Math.ceil((maxTokensOverride ?? lens.maxTokens) * 0.75);
|
|
1479
|
-
const cost = (inputTokens / 1_000_000) * pricing.inputPerM +
|
|
1480
|
-
(outputTokens / 1_000_000) * pricing.outputPerM;
|
|
1481
|
-
return { inputTokens, outputTokens, cost };
|
|
1482
|
-
}
|
|
1483
|
-
// ---------- state & config ----------
|
|
1484
|
-
export function broadsideDirFor(cwd) {
|
|
1485
|
-
return join(cwd, ".codecarto", BROADSIDE_DIR);
|
|
1486
|
-
}
|
|
1487
|
-
/**
|
|
1488
|
-
* Read the Broad-Side reading guide.
|
|
1489
|
-
*
|
|
1490
|
-
* It is deliberately not a post-pipeline skill under `.codecarto/skills/`: a
|
|
1491
|
-
* scout run is read *before* or *during* the interactive pipeline, and the
|
|
1492
|
-
* post-pipeline machinery gates on a completed run and wraps its prompt in
|
|
1493
|
-
* post-pipeline framing that would be false here. It is also readable on a
|
|
1494
|
-
* repository that has scout state and no workspace at all, which is why this
|
|
1495
|
-
* falls back to the packaged copy.
|
|
1496
|
-
*
|
|
1497
|
-
* @param cwd - Absolute path to the target repository.
|
|
1498
|
-
* @returns the skill text and the path it came from.
|
|
1499
|
-
* @throws when neither the workspace copy nor the packaged copy exists.
|
|
1500
|
-
*/
|
|
1501
|
-
export async function readBroadsideSkill(cwd) {
|
|
1502
|
-
const candidates = [
|
|
1503
|
-
join(broadsideDirFor(cwd), "SKILL.md"),
|
|
1504
|
-
join(packagedWorkspaceDir, BROADSIDE_DIR, "SKILL.md"),
|
|
1505
|
-
];
|
|
1506
|
-
for (const path of candidates) {
|
|
1507
|
-
if (await pathExists(path))
|
|
1508
|
-
return { path, content: await readFile(path, "utf8") };
|
|
1509
|
-
}
|
|
1510
|
-
throw new Error(`Broad-Side skill not found at ${candidates.join(" or ")}. Reinstall codecartographer-pi.`);
|
|
1511
|
-
}
|
|
1512
|
-
export function defaultBroadsideState() {
|
|
1513
|
-
return { schema_version: BROADSIDE_STATE_SCHEMA_VERSION, runs: [] };
|
|
1514
|
-
}
|
|
1515
|
-
export async function loadBroadsideState(broadsideDir) {
|
|
1516
|
-
const statePath = join(broadsideDir, BROADSIDE_STATE_FILE);
|
|
1517
|
-
if (!(await pathExists(statePath)))
|
|
1518
|
-
return defaultBroadsideState();
|
|
1519
|
-
const text = await readFile(statePath, "utf8");
|
|
1520
|
-
let raw;
|
|
1521
|
-
try {
|
|
1522
|
-
raw = JSON.parse(text);
|
|
1523
|
-
}
|
|
1524
|
-
catch (error) {
|
|
1525
|
-
throw new BroadsideStateError(statePath, await preserveCorruptState(statePath, text), `could not be parsed (${error instanceof Error ? error.message : String(error)})`);
|
|
1526
|
-
}
|
|
1527
|
-
if (!raw || typeof raw !== "object" || !Array.isArray(raw.runs)) {
|
|
1528
|
-
throw new BroadsideStateError(statePath, await preserveCorruptState(statePath, text), "is not a state file (expected an object with a runs array)");
|
|
1529
|
-
}
|
|
1530
|
-
return raw;
|
|
1531
|
-
}
|
|
1532
|
-
/**
|
|
1533
|
-
* Copy an unreadable state file to `state.json.corrupt-<hash>` beside it,
|
|
1534
|
-
* named by content so repeated loads do not multiply copies. Returns the
|
|
1535
|
-
* copy's path (the existing one, when the same content was preserved before).
|
|
1536
|
-
*/
|
|
1537
|
-
async function preserveCorruptState(statePath, text) {
|
|
1538
|
-
const digest = createHash("sha1").update(text).digest("hex").slice(0, 8);
|
|
1539
|
-
const backupPath = `${statePath}.corrupt-${digest}`;
|
|
1540
|
-
if (!(await pathExists(backupPath)))
|
|
1541
|
-
await writeFile(backupPath, text, "utf8");
|
|
1542
|
-
return backupPath;
|
|
1543
|
-
}
|
|
1544
|
-
/**
|
|
1545
|
-
* Overwrite `state.json` wholesale with `state`.
|
|
1546
|
-
*
|
|
1547
|
-
* Prefer {@link persistBroadsideRun} anywhere a live operation is recording its
|
|
1548
|
-
* own progress — this entry point replaces the file, so any run a concurrent
|
|
1549
|
-
* process recorded in the meantime is erased. It remains the right call for
|
|
1550
|
-
* seeding a fresh workspace and for test fixtures, where "make the file exactly
|
|
1551
|
-
* this" is the intent.
|
|
1552
|
-
*/
|
|
1553
|
-
export async function saveBroadsideState(broadsideDir, state) {
|
|
1554
|
-
await mkdir(broadsideDir, { recursive: true });
|
|
1555
|
-
const statePath = join(broadsideDir, BROADSIDE_STATE_FILE);
|
|
1556
|
-
const lock = await acquireLock(`${statePath}.lock`);
|
|
1557
|
-
try {
|
|
1558
|
-
await writeBroadsideStateFile(statePath, state);
|
|
1559
|
-
}
|
|
1560
|
-
finally {
|
|
1561
|
-
await lock.release();
|
|
1562
|
-
}
|
|
1563
|
-
}
|
|
1564
|
-
/** Serialize through a temp file so a crash mid-write cannot truncate state.json. */
|
|
1565
|
-
async function writeBroadsideStateFile(statePath, state) {
|
|
1566
|
-
await atomicWriteFile(statePath, `${JSON.stringify(state, null, "\t")}\n`);
|
|
1567
|
-
}
|
|
1568
|
-
/**
|
|
1569
|
-
* Read-modify-write `state.json` under a lock.
|
|
1570
|
-
*
|
|
1571
|
-
* The lock is held only for the read-modify-write, never for the surrounding
|
|
1572
|
-
* operation: a `collect` can poll for the better part of an hour, and holding
|
|
1573
|
-
* the lock across that would push every concurrent caller past the 5s lock
|
|
1574
|
-
* timeout.
|
|
1575
|
-
*/
|
|
1576
|
-
export async function updateBroadsideStateAtomically(broadsideDir, mutate) {
|
|
1577
|
-
await mkdir(broadsideDir, { recursive: true });
|
|
1578
|
-
const statePath = join(broadsideDir, BROADSIDE_STATE_FILE);
|
|
1579
|
-
const lock = await acquireLock(`${statePath}.lock`);
|
|
1580
|
-
try {
|
|
1581
|
-
const state = await loadBroadsideState(broadsideDir);
|
|
1582
|
-
await mutate(state);
|
|
1583
|
-
await writeBroadsideStateFile(statePath, state);
|
|
1584
|
-
return state;
|
|
1585
|
-
}
|
|
1586
|
-
finally {
|
|
1587
|
-
await lock.release();
|
|
1588
|
-
}
|
|
1589
|
-
}
|
|
1590
|
-
/**
|
|
1591
|
-
* Record one run's current shape, merged into whatever is on disk *now*.
|
|
1592
|
-
*
|
|
1593
|
-
* Broad-Side operations are long-lived and hold their state in memory while
|
|
1594
|
-
* they poll. Writing that snapshot back wholesale silently erased any run a
|
|
1595
|
-
* concurrent operation had recorded since it was loaded, orphaning that run's
|
|
1596
|
-
* paid results on disk — present as files, invisible to `list`, and unreachable
|
|
1597
|
-
* by `collect`, which finds its run by position in `state.runs`. Observed live:
|
|
1598
|
-
* a submit at 23:35 was erased by a collect that had loaded state before it and
|
|
1599
|
-
* wrote back at 00:08.
|
|
1600
|
-
*
|
|
1601
|
-
* Merging by run id also self-heals: a run erased by an older writer is
|
|
1602
|
-
* restored the next time its own operation checkpoints.
|
|
1603
|
-
*/
|
|
1604
|
-
export async function persistBroadsideRun(broadsideDir, run) {
|
|
1605
|
-
return updateBroadsideStateAtomically(broadsideDir, (state) => {
|
|
1606
|
-
const index = state.runs.findIndex((candidate) => candidate.id === run.id);
|
|
1607
|
-
if (index === -1)
|
|
1608
|
-
state.runs.push(run);
|
|
1609
|
-
else
|
|
1610
|
-
state.runs[index] = run;
|
|
1611
|
-
});
|
|
1612
|
-
}
|
|
1613
|
-
/** Where a lens batch entry stands, for keeping the more advanced of two. */
|
|
1614
|
-
function batchEntryRank(entry) {
|
|
1615
|
-
if (!entry)
|
|
1616
|
-
return -1;
|
|
1617
|
-
if (BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status))
|
|
1618
|
-
return 2;
|
|
1619
|
-
if (entry.batchId)
|
|
1620
|
-
return 1;
|
|
1621
|
-
return 0;
|
|
1622
|
-
}
|
|
1623
|
-
/** Where a post-pass entry stands: unclaimed, claimed, submitted, settled. */
|
|
1624
|
-
function passEntryRank(entry) {
|
|
1625
|
-
if (!entry || entry.status === "pending")
|
|
1626
|
-
return 0;
|
|
1627
|
-
if (entry.status === "submitted")
|
|
1628
|
-
return entry.batchId ? 2 : 1;
|
|
1629
|
-
return 3;
|
|
1630
|
-
}
|
|
1631
|
-
/** Where the retry pass stands: absent, claimed, submitted, settled. */
|
|
1632
|
-
function retryEntryRank(entry) {
|
|
1633
|
-
if (!entry)
|
|
1634
|
-
return 0;
|
|
1635
|
-
if (entry.status === "submitted")
|
|
1636
|
-
return entry.batches.length > 0 ? 2 : 1;
|
|
1637
|
-
return 3;
|
|
1638
|
-
}
|
|
1639
|
-
/**
|
|
1640
|
-
* Record a collect's view of its run, keeping whatever is further along on
|
|
1641
|
-
* disk (#322).
|
|
1642
|
-
*
|
|
1643
|
-
* Two collects on one run each hold the run in memory and each used to write
|
|
1644
|
-
* the whole thing back, so the last writer replaced the other's post-pass
|
|
1645
|
-
* entries with its own — and both had submitted their own post-passes, since
|
|
1646
|
-
* each decided from the copy it loaded at entry. This writer merges slot by
|
|
1647
|
-
* slot: a post-pass or retry entry that is further along on disk (claimed
|
|
1648
|
-
* over pending, submitted over claimed, settled over submitted) wins and is
|
|
1649
|
-
* copied into `run`, so the caller reports what is true; a lens entry never
|
|
1650
|
-
* goes backwards from terminal to polling. A tie keeps this collect's copy,
|
|
1651
|
-
* so the collect that settled a pass records its cost. Submitting is guarded
|
|
1652
|
-
* separately by {@link claimRunSlot}.
|
|
1653
|
-
*/
|
|
1654
|
-
export async function persistBroadsideRunMerging(broadsideDir, run) {
|
|
1655
|
-
return updateBroadsideStateAtomically(broadsideDir, (state) => {
|
|
1656
|
-
const index = state.runs.findIndex((candidate) => candidate.id === run.id);
|
|
1657
|
-
const onDisk = index === -1 ? undefined : state.runs[index];
|
|
1658
|
-
if (onDisk) {
|
|
1659
|
-
if (passEntryRank(onDisk.synthesis) > passEntryRank(run.synthesis))
|
|
1660
|
-
run.synthesis = onDisk.synthesis;
|
|
1661
|
-
if (passEntryRank(onDisk.triage) > passEntryRank(run.triage))
|
|
1662
|
-
run.triage = onDisk.triage;
|
|
1663
|
-
if (retryEntryRank(onDisk.retry) > retryEntryRank(run.retry))
|
|
1664
|
-
run.retry = onDisk.retry;
|
|
1665
|
-
// A verification pass another process recorded is never dropped by
|
|
1666
|
-
// a collect that never knew about it; a newer pass replaces an older.
|
|
1667
|
-
if (onDisk.verify && (!run.verify || onDisk.verify.at > run.verify.at))
|
|
1668
|
-
run.verify = onDisk.verify;
|
|
1669
|
-
for (const [lensId, theirs] of Object.entries(onDisk.batches)) {
|
|
1670
|
-
if (theirs && batchEntryRank(theirs) > batchEntryRank(run.batches[lensId]))
|
|
1671
|
-
run.batches[lensId] = theirs;
|
|
1672
|
-
}
|
|
1673
|
-
}
|
|
1674
|
-
if (index === -1)
|
|
1675
|
-
state.runs.push(run);
|
|
1676
|
-
else
|
|
1677
|
-
state.runs[index] = run;
|
|
1678
|
-
});
|
|
1679
|
-
}
|
|
1680
|
-
/**
|
|
1681
|
-
* Claim one spending slot of a run for this collect (#322).
|
|
1682
|
-
*
|
|
1683
|
-
* Read-modify-write under the state lock: if the slot on disk is still
|
|
1684
|
-
* unclaimed (`pending`, or absent for the retry), it is marked `submitted`
|
|
1685
|
-
* with no batch id *before* any network call and `true` comes back — this
|
|
1686
|
-
* collect owns it and may submit. Otherwise another collect got there first:
|
|
1687
|
-
* its entry is copied into `run` and `false` comes back. An adopted entry
|
|
1688
|
-
* with a batch id can be polled (polling is idempotent); one without an id
|
|
1689
|
-
* is a claim whose owner has not recorded the id yet, and is reported as in
|
|
1690
|
-
* flight elsewhere.
|
|
1691
|
-
*/
|
|
1692
|
-
export async function claimRunSlot(broadsideDir, run, slot) {
|
|
1693
|
-
let owned = false;
|
|
1694
|
-
const claimedAt = new Date().toISOString();
|
|
1695
|
-
await updateBroadsideStateAtomically(broadsideDir, (state) => {
|
|
1696
|
-
const index = state.runs.findIndex((candidate) => candidate.id === run.id);
|
|
1697
|
-
const onDisk = index === -1 ? undefined : state.runs[index];
|
|
1698
|
-
const theirs = onDisk?.[slot];
|
|
1699
|
-
const unclaimed = slot === "retry" ? theirs === undefined : theirs?.status === "pending";
|
|
1700
|
-
if (onDisk && !unclaimed) {
|
|
1701
|
-
run[slot] = theirs;
|
|
1702
|
-
owned = false;
|
|
1703
|
-
return;
|
|
1704
|
-
}
|
|
1705
|
-
owned = true;
|
|
1706
|
-
if (slot === "retry") {
|
|
1707
|
-
run.retry = { status: "submitted", batches: [], claimedAt };
|
|
1708
|
-
}
|
|
1709
|
-
else {
|
|
1710
|
-
run[slot] = { ...run[slot], status: "submitted", batchId: undefined };
|
|
1711
|
-
}
|
|
1712
|
-
if (!onDisk) {
|
|
1713
|
-
state.runs.push(run);
|
|
1714
|
-
}
|
|
1715
|
-
else {
|
|
1716
|
-
onDisk[slot] = run[slot];
|
|
1717
|
-
}
|
|
1718
|
-
});
|
|
1719
|
-
return owned;
|
|
1720
|
-
}
|
|
1721
|
-
/**
|
|
1722
|
-
* Put a run's settled post-passes back to `pending` on disk so the next
|
|
1723
|
-
* claim re-runs them (#338). A pass another collect has in flight is left
|
|
1724
|
-
* alone — its result is still coming. The replaced results' cost moves to
|
|
1725
|
-
* `retiredCost`, so the run's total keeps counting money it spent. Returns
|
|
1726
|
-
* the passes that were reset, in the order they will be re-run.
|
|
1727
|
-
*/
|
|
1728
|
-
export async function resetRunPostPasses(broadsideDir, run, wanted) {
|
|
1729
|
-
const reset = [];
|
|
1730
|
-
await updateBroadsideStateAtomically(broadsideDir, (state) => {
|
|
1731
|
-
const index = state.runs.findIndex((candidate) => candidate.id === run.id);
|
|
1732
|
-
const onDisk = index === -1 ? run : state.runs[index];
|
|
1733
|
-
for (const kind of ["synthesis", "triage"]) {
|
|
1734
|
-
if (!wanted[kind])
|
|
1735
|
-
continue;
|
|
1736
|
-
const theirs = onDisk[kind] ?? { status: "pending" };
|
|
1737
|
-
if (theirs.status !== "completed" && theirs.status !== "failed") {
|
|
1738
|
-
// pending: nothing to reset; submitted: in flight elsewhere.
|
|
1739
|
-
run[kind] = theirs;
|
|
1740
|
-
continue;
|
|
1741
|
-
}
|
|
1742
|
-
if (theirs.cost)
|
|
1743
|
-
onDisk.retiredCost = (onDisk.retiredCost ?? 0) + theirs.cost;
|
|
1744
|
-
onDisk[kind] = { status: "pending" };
|
|
1745
|
-
run[kind] = onDisk[kind];
|
|
1746
|
-
run.retiredCost = onDisk.retiredCost;
|
|
1747
|
-
reset.push(kind);
|
|
1748
|
-
}
|
|
1749
|
-
if (index === -1)
|
|
1750
|
-
state.runs.push(run);
|
|
1751
|
-
});
|
|
1752
|
-
return reset;
|
|
1753
|
-
}
|
|
1754
|
-
/** Read a `reasoning:` block from config.yaml, ignoring anything malformed. */
|
|
1755
|
-
function parseReasoningConfig(raw) {
|
|
1756
|
-
if (raw === false)
|
|
1757
|
-
return { enabled: false };
|
|
1758
|
-
if (raw === true)
|
|
1759
|
-
return { enabled: true };
|
|
1760
|
-
if (!raw || typeof raw !== "object")
|
|
1761
|
-
return null;
|
|
1762
|
-
const value = raw;
|
|
1763
|
-
const out = {};
|
|
1764
|
-
if (typeof value.enabled === "boolean")
|
|
1765
|
-
out.enabled = value.enabled;
|
|
1766
|
-
if (value.effort === "minimal" || value.effort === "low" || value.effort === "medium" || value.effort === "high")
|
|
1767
|
-
out.effort = value.effort;
|
|
1768
|
-
if (typeof value.max_tokens === "number" && value.max_tokens > 0)
|
|
1769
|
-
out.max_tokens = value.max_tokens;
|
|
1770
|
-
return Object.keys(out).length > 0 ? out : null;
|
|
1771
|
-
}
|
|
1772
|
-
export async function loadBroadsideConfig(broadsideDir) {
|
|
1773
|
-
const configPath = join(broadsideDir, BROADSIDE_CONFIG_FILE);
|
|
1774
|
-
let raw = {};
|
|
1775
|
-
if (await pathExists(configPath)) {
|
|
1776
|
-
let parsed;
|
|
1777
|
-
try {
|
|
1778
|
-
parsed = await loadYamlFile(configPath);
|
|
1779
|
-
}
|
|
1780
|
-
catch (error) {
|
|
1781
|
-
throw new BroadsideConfigError(configPath, `could not be parsed (${error instanceof Error ? error.message : String(error)})`);
|
|
1782
|
-
}
|
|
1783
|
-
if (parsed !== null && parsed !== undefined) {
|
|
1784
|
-
if (typeof parsed !== "object" || Array.isArray(parsed))
|
|
1785
|
-
throw new BroadsideConfigError(configPath, "is not a YAML mapping");
|
|
1786
|
-
raw = parsed;
|
|
1787
|
-
}
|
|
1788
|
-
// OpenRouter accepts `reasoning.effort` or `reasoning.max_tokens`, not
|
|
1789
|
-
// both: a request carrying both is refused per request *after* the batch
|
|
1790
|
-
// is accepted, so every lens fails at $0 with the reason in each
|
|
1791
|
-
// result's error. Seen live on 0.22.0 with the two keys set together.
|
|
1792
|
-
// Refuse here, where the file can be fixed, rather than submit a run
|
|
1793
|
-
// that cannot produce a result.
|
|
1794
|
-
const reasoning = raw.reasoning;
|
|
1795
|
-
if (reasoning && typeof reasoning === "object" && !Array.isArray(reasoning)) {
|
|
1796
|
-
const value = reasoning;
|
|
1797
|
-
const hasEffort = typeof value.effort === "string";
|
|
1798
|
-
const hasBudget = typeof value.max_tokens === "number" && value.max_tokens > 0;
|
|
1799
|
-
if (hasEffort && hasBudget) {
|
|
1800
|
-
throw new BroadsideConfigError(configPath, 'sets both reasoning.effort and reasoning.max_tokens; OpenRouter accepts one or the other ("Only one of reasoning.effort and reasoning.max_tokens can be specified"), and every lens request would fail after the batch is accepted. Keep one');
|
|
1801
|
-
}
|
|
1802
|
-
}
|
|
1803
|
-
}
|
|
1804
|
-
return buildBroadsideConfig(raw);
|
|
1805
|
-
}
|
|
1806
|
-
/** The shipped defaults: what an absent config.yaml means. */
|
|
1807
|
-
export function defaultBroadsideConfig() {
|
|
1808
|
-
return buildBroadsideConfig({});
|
|
1809
|
-
}
|
|
1810
|
-
function buildBroadsideConfig(raw) {
|
|
1811
|
-
const lenses = Array.isArray(raw.default_lenses)
|
|
1812
|
-
? (raw.default_lenses.filter((l) => BROADSIDE_LENS_IDS.includes(l)))
|
|
1813
|
-
: [];
|
|
1814
|
-
const rawPricing = (raw.pricing ?? {});
|
|
1815
|
-
const inputOverride = typeof rawPricing.input_per_m === "number" ? rawPricing.input_per_m : undefined;
|
|
1816
|
-
const outputOverride = typeof rawPricing.output_per_m === "number" ? rawPricing.output_per_m : undefined;
|
|
1817
|
-
// A malformed value falls back to the shipped default rather than failing
|
|
1818
|
-
// the run: config.yaml is hand-edited, and a typo in a poll budget must not
|
|
1819
|
-
// cost a user their batches.
|
|
1820
|
-
const flag = (key, fallback) => typeof raw[key] === "boolean" ? raw[key] : fallback;
|
|
1821
|
-
// An override for an unknown lens id is dropped rather than carried: it can
|
|
1822
|
-
// only be a typo, and a silently-ignored key that looks applied is worse
|
|
1823
|
-
// than one that never appears.
|
|
1824
|
-
const lensModels = {};
|
|
1825
|
-
const rawLensModels = (raw.lens_models ?? {});
|
|
1826
|
-
for (const lensId of BROADSIDE_LENS_IDS) {
|
|
1827
|
-
const value = rawLensModels[lensId];
|
|
1828
|
-
if (typeof value === "string" && value.trim())
|
|
1829
|
-
lensModels[lensId] = value.trim();
|
|
1830
|
-
}
|
|
1831
|
-
return {
|
|
1832
|
-
model: typeof raw.model === "string" && raw.model.trim() ? raw.model.trim() : BROADSIDE_MODEL,
|
|
1833
|
-
apiKey: typeof raw.api_key === "string" ? raw.api_key.trim() : "",
|
|
1834
|
-
defaultLenses: lenses.length > 0 ? lenses : [...BROADSIDE_LENS_IDS],
|
|
1835
|
-
// Absent: the shipped default. An explicit 0 is "no limit", spelled out
|
|
1836
|
-
// on purpose; a negative or non-numeric value is not a limit at all.
|
|
1837
|
-
maxCost: typeof raw.max_cost === "number" && raw.max_cost >= 0 ? raw.max_cost : BROADSIDE_DEFAULT_MAX_COST,
|
|
1838
|
-
pricing: inputOverride !== undefined && outputOverride !== undefined
|
|
1839
|
-
? { inputPerM: inputOverride, outputPerM: outputOverride }
|
|
1840
|
-
: null,
|
|
1841
|
-
lensModels,
|
|
1842
|
-
// An escape hatch, not a knob to reach for: a model whose reasoning is
|
|
1843
|
-
// worth paying for needs its lens maxTokens raised to cover both the
|
|
1844
|
-
// thinking and the answer, or the JSON truncates exactly as before.
|
|
1845
|
-
reasoning: parseReasoningConfig(raw.reasoning),
|
|
1846
|
-
incremental: flag("incremental", false),
|
|
1847
|
-
retryTruncated: flag("retry_truncated", true),
|
|
1848
|
-
includeSynthesis: flag("include_synthesis", true),
|
|
1849
|
-
includeTriage: flag("include_triage", true),
|
|
1850
|
-
waitSeconds: typeof raw.wait_seconds === "number" && raw.wait_seconds > 0 ? raw.wait_seconds : 0,
|
|
1851
|
-
redactSecrets: flag("redact_secrets", true),
|
|
1852
|
-
};
|
|
1853
|
-
}
|
|
1854
|
-
// ---------- model catalog, pricing, benchmarks ----------
|
|
1855
|
-
/** The catalog cache schema this build writes; a file from another is not read. */
|
|
1856
|
-
export const BROADSIDE_CATALOG_CACHE_SCHEMA = 3;
|
|
1857
|
-
async function readCatalogCache(broadsideDir) {
|
|
1858
|
-
const cachePath = join(broadsideDir, BROADSIDE_CATALOG_CACHE_FILE);
|
|
1859
|
-
if (!(await pathExists(cachePath)))
|
|
1860
|
-
return null;
|
|
1861
|
-
try {
|
|
1862
|
-
const parsed = JSON.parse(await readFile(cachePath, "utf8"));
|
|
1863
|
-
if (!parsed || typeof parsed !== "object" || !parsed.models || typeof parsed.models !== "object")
|
|
1864
|
-
return null;
|
|
1865
|
-
if (parsed.schema_version !== BROADSIDE_CATALOG_CACHE_SCHEMA && parsed.schema_version !== 2)
|
|
1866
|
-
return null;
|
|
1867
|
-
return parsed;
|
|
1868
|
-
}
|
|
1869
|
-
catch {
|
|
1870
|
-
return null;
|
|
1871
|
-
}
|
|
1872
|
-
}
|
|
1873
|
-
/** When a cached entry was fetched: its own stamp, or the file's for a schema-2 cache. */
|
|
1874
|
-
function catalogEntryFetchedAt(cache, model) {
|
|
1875
|
-
const stamp = cache.models[model]?.fetched_at ?? cache.fetched_at;
|
|
1876
|
-
return new Date(stamp).getTime();
|
|
1877
|
-
}
|
|
1878
|
-
async function writeCatalogCache(broadsideDir, cache) {
|
|
1879
|
-
await mkdir(broadsideDir, { recursive: true });
|
|
1880
|
-
await writeFile(join(broadsideDir, BROADSIDE_CATALOG_CACHE_FILE), `${JSON.stringify(cache, null, "\t")}\n`, "utf8");
|
|
1881
|
-
}
|
|
1882
|
-
const BROADSIDE_ENDPOINTS_SCHEMA = 1;
|
|
1883
|
-
export async function readBatchEndpoints(broadsideDir) {
|
|
1884
|
-
const path = join(broadsideDir, BROADSIDE_ENDPOINTS_FILE);
|
|
1885
|
-
if (!(await pathExists(path)))
|
|
1886
|
-
return {};
|
|
1887
|
-
try {
|
|
1888
|
-
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
1889
|
-
if (!parsed || typeof parsed !== "object" || parsed.schema_version !== BROADSIDE_ENDPOINTS_SCHEMA)
|
|
1890
|
-
return {};
|
|
1891
|
-
if (!parsed.models || typeof parsed.models !== "object")
|
|
1892
|
-
return {};
|
|
1893
|
-
const out = {};
|
|
1894
|
-
for (const [model, record] of Object.entries(parsed.models)) {
|
|
1895
|
-
if (!record || typeof record !== "object")
|
|
1896
|
-
continue;
|
|
1897
|
-
if (record.status !== "accepted" && record.status !== "rejected")
|
|
1898
|
-
continue;
|
|
1899
|
-
if (typeof record.at !== "string")
|
|
1900
|
-
continue;
|
|
1901
|
-
out[model] = { status: record.status, at: record.at, ...(typeof record.error === "string" && { error: record.error }) };
|
|
1902
|
-
}
|
|
1903
|
-
return out;
|
|
1904
|
-
}
|
|
1905
|
-
catch {
|
|
1906
|
-
// An unreadable memory is an empty one: it only annotates a listing.
|
|
1907
|
-
return {};
|
|
1908
|
-
}
|
|
1909
|
-
}
|
|
1910
|
-
/**
|
|
1911
|
-
* The refusal OpenRouter returns for a catalog id that has no batch endpoint
|
|
1912
|
-
* behind it. Matched loosely: the message is the only signal there is.
|
|
1913
|
-
*/
|
|
1914
|
-
const NO_BATCH_ENDPOINT_RE = /does not have a :batch endpoint/i;
|
|
1915
|
-
/** The refusal for a full per-account concurrent batch-job quota. */
|
|
1916
|
-
const BATCH_QUOTA_RE = /job-submission-count/i;
|
|
1917
|
-
/**
|
|
1918
|
-
* Remember what a submit learned about each model it posted to. An accepted
|
|
1919
|
-
* job proves the endpoint exists; a "does not have a :batch endpoint"
|
|
1920
|
-
* refusal proves it does not. Any other rejection (quota, malformed request,
|
|
1921
|
-
* auth) says nothing about the endpoint and leaves the record alone.
|
|
1922
|
-
*/
|
|
1923
|
-
export async function recordBatchEndpoints(broadsideDir, outcomes) {
|
|
1924
|
-
const at = new Date().toISOString();
|
|
1925
|
-
const updates = {};
|
|
1926
|
-
for (const { model, batchId, error } of outcomes) {
|
|
1927
|
-
if (batchId) {
|
|
1928
|
-
updates[model] = { status: "accepted", at };
|
|
1929
|
-
continue;
|
|
1930
|
-
}
|
|
1931
|
-
const message = describeBatchError(error);
|
|
1932
|
-
if (message && NO_BATCH_ENDPOINT_RE.test(message)) {
|
|
1933
|
-
updates[model] = { status: "rejected", at, error: message };
|
|
1934
|
-
}
|
|
1935
|
-
}
|
|
1936
|
-
if (Object.keys(updates).length === 0)
|
|
1937
|
-
return;
|
|
1938
|
-
const models = { ...(await readBatchEndpoints(broadsideDir)), ...updates };
|
|
1939
|
-
await mkdir(broadsideDir, { recursive: true });
|
|
1940
|
-
const file = { schema_version: BROADSIDE_ENDPOINTS_SCHEMA, models };
|
|
1941
|
-
await atomicWriteFile(join(broadsideDir, BROADSIDE_ENDPOINTS_FILE), `${JSON.stringify(file, null, "\t")}\n`);
|
|
1942
|
-
}
|
|
1943
|
-
function parseCatalogEntry(raw) {
|
|
1944
|
-
const id = String(raw.id ?? "");
|
|
1945
|
-
if (!id)
|
|
1946
|
-
return null;
|
|
1947
|
-
const p = (raw.pricing ?? {});
|
|
1948
|
-
const input = typeof p.prompt === "string" ? Number(p.prompt) : NaN;
|
|
1949
|
-
const output = typeof p.completion === "string" ? Number(p.completion) : NaN;
|
|
1950
|
-
if (!Number.isFinite(input) || !Number.isFinite(output))
|
|
1951
|
-
return null;
|
|
1952
|
-
const cached = typeof p.cached_input === "string" ? Number(p.cached_input) : NaN;
|
|
1953
|
-
const topProvider = (raw.top_provider ?? {});
|
|
1954
|
-
const contextLength = typeof raw.context_length === "number" ? raw.context_length : undefined;
|
|
1955
|
-
const maxCompletion = typeof topProvider.max_completion_tokens === "number" ? topProvider.max_completion_tokens : undefined;
|
|
1956
|
-
return {
|
|
1957
|
-
id,
|
|
1958
|
-
name: String(raw.name ?? id),
|
|
1959
|
-
inputPerM: input * 1_000_000,
|
|
1960
|
-
outputPerM: output * 1_000_000,
|
|
1961
|
-
cachedInputPerM: Number.isFinite(cached) ? cached * 1_000_000 : undefined,
|
|
1962
|
-
contextLength,
|
|
1963
|
-
maxCompletionTokens: maxCompletion,
|
|
1964
|
-
supportedParameters: Array.isArray(raw.supported_parameters)
|
|
1965
|
-
? raw.supported_parameters.map((entry) => String(entry))
|
|
1966
|
-
: [],
|
|
1967
|
-
expirationDate: typeof raw.expiration_date === "string" ? raw.expiration_date : null,
|
|
1968
|
-
};
|
|
1969
|
-
}
|
|
1970
|
-
export function builtInCatalogEntry(model) {
|
|
1971
|
-
// The default model's rates are compile-time constants; its capabilities
|
|
1972
|
-
// are asserted from the shipped configuration (1M context, 64K output,
|
|
1973
|
-
// structured outputs used by every lens).
|
|
1974
|
-
if (model !== BROADSIDE_MODEL)
|
|
1975
|
-
return null;
|
|
1976
|
-
return {
|
|
1977
|
-
id: BROADSIDE_MODEL,
|
|
1978
|
-
name: "Google: Gemini 3.7 Flash (batch)",
|
|
1979
|
-
inputPerM: BROADSIDE_INPUT_PRICE_PER_M,
|
|
1980
|
-
outputPerM: BROADSIDE_OUTPUT_PRICE_PER_M,
|
|
1981
|
-
contextLength: 1_048_576,
|
|
1982
|
-
maxCompletionTokens: 65_536,
|
|
1983
|
-
supportedParameters: ["tools", "structured_outputs", "json_schema", "response_format"],
|
|
1984
|
-
expirationDate: null,
|
|
1985
|
-
};
|
|
1986
|
-
}
|
|
1987
|
-
export function builtInPricing(model) {
|
|
1988
|
-
const entry = builtInCatalogEntry(model);
|
|
1989
|
-
if (!entry)
|
|
1990
|
-
return null;
|
|
1991
|
-
return { inputPerM: entry.inputPerM, outputPerM: entry.outputPerM, source: "built-in" };
|
|
1992
|
-
}
|
|
1993
|
-
export async function resolveCatalogEntry(broadsideDir, config, model, apiKey, fetcher = fetch) {
|
|
1994
|
-
// Manual overrides always win for pricing — the user is asserting a rate,
|
|
1995
|
-
// and a config assertion is cheaper to respect than to second-guess.
|
|
1996
|
-
// Capabilities stay unknown in that case: nothing is refused, nothing
|
|
1997
|
-
// is clamped, and the submit text says the pricing came from config.
|
|
1998
|
-
if (config.pricing) {
|
|
1999
|
-
return {
|
|
2000
|
-
model,
|
|
2001
|
-
source: "config",
|
|
2002
|
-
entry: {
|
|
2003
|
-
id: model,
|
|
2004
|
-
name: model,
|
|
2005
|
-
inputPerM: config.pricing.inputPerM,
|
|
2006
|
-
outputPerM: config.pricing.outputPerM,
|
|
2007
|
-
supportedParameters: [],
|
|
2008
|
-
},
|
|
2009
|
-
};
|
|
2010
|
-
}
|
|
2011
|
-
// On-disk cache first, then the live catalog — for the default model too.
|
|
2012
|
-
// Hardcoded rates used to short-circuit here, which meant a stale constant
|
|
2013
|
-
// could never self-correct even though the catalog was already being
|
|
2014
|
-
// fetched for every other model. The authoritative source wins; the
|
|
2015
|
-
// constants below are what we fall back to when the network is unavailable.
|
|
2016
|
-
const cache = await readCatalogCache(broadsideDir);
|
|
2017
|
-
const cached = cache?.models[model];
|
|
2018
|
-
if (cache && cached && Date.now() - catalogEntryFetchedAt(cache, model) < BROADSIDE_CATALOG_CACHE_TTL_MS) {
|
|
2019
|
-
return { model, source: "cache", entry: cached };
|
|
2020
|
-
}
|
|
2021
|
-
// What went wrong when the live lookup produced nothing, for the error
|
|
2022
|
-
// below: a 401 and a dead network used to read the same — "could not
|
|
2023
|
-
// resolve per-token pricing" — or, for the default model, nothing at all.
|
|
2024
|
-
let live = null;
|
|
2025
|
-
let catalogFailure = null;
|
|
2026
|
-
try {
|
|
2027
|
-
const resp = await fetcher(BROADSIDE_MODELS_URL, {
|
|
2028
|
-
method: "GET",
|
|
2029
|
-
headers: { Authorization: `Bearer ${apiKey}` },
|
|
2030
|
-
signal: AbortSignal.timeout(30_000),
|
|
2031
|
-
});
|
|
2032
|
-
if (resp.status === 401 || resp.status === 403) {
|
|
2033
|
-
throw new BroadsideAuthError(resp.status, await responseDetail(resp));
|
|
2034
|
-
}
|
|
2035
|
-
if (resp.ok === false) {
|
|
2036
|
-
catalogFailure = `the model catalog request failed (HTTP ${resp.status}${await responseDetail(resp).then((d) => (d ? `: ${d}` : ""))})`;
|
|
2037
|
-
}
|
|
2038
|
-
else {
|
|
2039
|
-
const data = (await resp.json());
|
|
2040
|
-
const hit = (data.data ?? []).find((m) => String(m.id) === model);
|
|
2041
|
-
if (hit)
|
|
2042
|
-
live = parseCatalogEntry(hit);
|
|
2043
|
-
else
|
|
2044
|
-
catalogFailure = `the model catalog has no entry for "${model}"`;
|
|
2045
|
-
}
|
|
2046
|
-
}
|
|
2047
|
-
catch (error) {
|
|
2048
|
-
if (error instanceof BroadsideAuthError)
|
|
2049
|
-
throw error;
|
|
2050
|
-
live = null;
|
|
2051
|
-
catalogFailure = `the model catalog could not be fetched (${error instanceof Error ? error.message : String(error)})`;
|
|
2052
|
-
}
|
|
2053
|
-
if (live) {
|
|
2054
|
-
const now = new Date().toISOString();
|
|
2055
|
-
const updated = {
|
|
2056
|
-
schema_version: BROADSIDE_CATALOG_CACHE_SCHEMA,
|
|
2057
|
-
fetched_at: now,
|
|
2058
|
-
// Other entries keep their own stamps (a schema-2 file's entries
|
|
2059
|
-
// inherit the file's, once, on this upgrade); only this model is fresh.
|
|
2060
|
-
models: Object.fromEntries(Object.entries(cache?.models ?? {}).map(([id, entry]) => [id, { ...entry, fetched_at: entry.fetched_at ?? cache.fetched_at }])),
|
|
2061
|
-
};
|
|
2062
|
-
updated.models[model] = { ...live, fetched_at: now };
|
|
2063
|
-
await writeCatalogCache(broadsideDir, updated);
|
|
2064
|
-
return { model, source: "live", entry: live };
|
|
2065
|
-
}
|
|
2066
|
-
// Offline fallback: the default model's rates and capabilities are known at
|
|
2067
|
-
// compile time, so a network failure does not have to stop a run.
|
|
2068
|
-
const builtIn = builtInCatalogEntry(model);
|
|
2069
|
-
if (builtIn)
|
|
2070
|
-
return { model, source: "built-in", entry: builtIn };
|
|
2071
|
-
throw new Error(`Could not resolve per-token pricing for batch model "${model}": ${catalogFailure ?? "no catalog entry"}. ` +
|
|
2072
|
-
"Set pricing.input_per_m and pricing.output_per_m in .codecarto/broadside/config.yaml " +
|
|
2073
|
-
"(USD per million tokens), or check the model id against https://openrouter.ai/models?variant=batch.");
|
|
2074
|
-
}
|
|
2075
|
-
/** A short, safe excerpt of an error response body for a message. */
|
|
2076
|
-
async function responseDetail(resp) {
|
|
2077
|
-
try {
|
|
2078
|
-
if (typeof resp.text === "function") {
|
|
2079
|
-
const text = (await resp.text()).trim();
|
|
2080
|
-
try {
|
|
2081
|
-
const parsed = JSON.parse(text);
|
|
2082
|
-
const message = typeof parsed?.error === "string" ? parsed.error : parsed?.error?.message;
|
|
2083
|
-
if (typeof message === "string" && message)
|
|
2084
|
-
return message.slice(0, 200);
|
|
2085
|
-
}
|
|
2086
|
-
catch {
|
|
2087
|
-
// not JSON; fall through to the raw excerpt
|
|
2088
|
-
}
|
|
2089
|
-
return text.replace(/\s+/g, " ").slice(0, 200);
|
|
2090
|
-
}
|
|
2091
|
-
if (typeof resp.json === "function") {
|
|
2092
|
-
const parsed = (await resp.json());
|
|
2093
|
-
const message = typeof parsed?.error === "string" ? parsed.error : parsed?.error?.message;
|
|
2094
|
-
return typeof message === "string" ? message.slice(0, 200) : "";
|
|
2095
|
-
}
|
|
2096
|
-
}
|
|
2097
|
-
catch {
|
|
2098
|
-
// an unreadable body adds nothing to the message
|
|
2099
|
-
}
|
|
2100
|
-
return "";
|
|
2101
|
-
}
|
|
2102
|
-
export async function resolveModelPricing(broadsideDir, config, model, apiKey, fetcher = fetch) {
|
|
2103
|
-
const { source, entry } = await resolveCatalogEntry(broadsideDir, config, model, apiKey, fetcher);
|
|
2104
|
-
if (!entry)
|
|
2105
|
-
throw new Error(`No pricing resolved for ${model}.`);
|
|
2106
|
-
return { inputPerM: entry.inputPerM, outputPerM: entry.outputPerM, source };
|
|
2107
|
-
}
|
|
2108
|
-
/** Base slug with the OpenRouter variant suffix (e.g. `:batch`) stripped. */
|
|
2109
|
-
function baseSlug(modelId) {
|
|
2110
|
-
const idx = modelId.indexOf(":");
|
|
2111
|
-
return idx >= 0 ? modelId.slice(0, idx) : modelId;
|
|
2112
|
-
}
|
|
2113
|
-
export async function fetchCodingBenchmarks(apiKey, fetcher = fetch) {
|
|
2114
|
-
try {
|
|
2115
|
-
const resp = await fetcher(`${BROADSIDE_BENCHMARKS_URL}?source=artificial-analysis&task_type=coding`, {
|
|
2116
|
-
method: "GET",
|
|
2117
|
-
headers: { Authorization: `Bearer ${apiKey}` },
|
|
2118
|
-
signal: AbortSignal.timeout(30_000),
|
|
2119
|
-
});
|
|
2120
|
-
const data = (await resp.json());
|
|
2121
|
-
const byBaseSlug = {};
|
|
2122
|
-
for (const row of data.data ?? []) {
|
|
2123
|
-
const slug = baseSlug(String(row.model_permaslug ?? ""));
|
|
2124
|
-
if (!slug)
|
|
2125
|
-
continue;
|
|
2126
|
-
const toIndex = (v) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
|
|
2127
|
-
byBaseSlug[slug] = {
|
|
2128
|
-
codingIndex: toIndex(row.coding_index),
|
|
2129
|
-
intelligenceIndex: toIndex(row.intelligence_index),
|
|
2130
|
-
};
|
|
2131
|
-
}
|
|
2132
|
-
return { byBaseSlug, meta: data.meta ?? {} };
|
|
2133
|
-
}
|
|
2134
|
-
catch {
|
|
2135
|
-
return null;
|
|
2136
|
-
}
|
|
2137
|
-
}
|
|
2138
|
-
export async function listBatchModels(broadsideDir, config, apiKey, opts = {}) {
|
|
2139
|
-
const fetcher = opts.fetcher ?? fetch;
|
|
2140
|
-
const resp = await fetcher(BROADSIDE_MODELS_URL, {
|
|
2141
|
-
method: "GET",
|
|
2142
|
-
headers: { Authorization: `Bearer ${apiKey}` },
|
|
2143
|
-
signal: AbortSignal.timeout(30_000),
|
|
2144
|
-
});
|
|
2145
|
-
const data = (await resp.json());
|
|
2146
|
-
const entries = [];
|
|
2147
|
-
const seen = new Set();
|
|
2148
|
-
for (const raw of data.data ?? []) {
|
|
2149
|
-
const entry = parseCatalogEntry(raw);
|
|
2150
|
-
if (!entry || seen.has(entry.id))
|
|
2151
|
-
continue;
|
|
2152
|
-
seen.add(entry.id);
|
|
2153
|
-
if (!entry.id.endsWith(":batch"))
|
|
2154
|
-
continue;
|
|
2155
|
-
entries.push(entry);
|
|
2156
|
-
}
|
|
2157
|
-
entries.sort((a, b) => a.inputPerM + a.outputPerM - (b.inputPerM + b.outputPerM));
|
|
2158
|
-
// Persist the catalog so the next submit's pricing resolution hits cache.
|
|
2159
|
-
const fetchedAt = new Date().toISOString();
|
|
2160
|
-
const cache = { schema_version: BROADSIDE_CATALOG_CACHE_SCHEMA, fetched_at: fetchedAt, models: {} };
|
|
2161
|
-
for (const entry of entries)
|
|
2162
|
-
cache.models[entry.id] = { ...entry, fetched_at: fetchedAt };
|
|
2163
|
-
await writeCatalogCache(broadsideDir, cache);
|
|
2164
|
-
const benchmarks = opts.includeBenchmarks ? await fetchCodingBenchmarks(apiKey, fetcher) : null;
|
|
2165
|
-
const endpoints = await readBatchEndpoints(broadsideDir);
|
|
2166
|
-
return { entries, source: "live", benchmarks, defaultModel: config.model, endpoints };
|
|
2167
|
-
}
|
|
2168
|
-
export async function submitBatch(batchRequests, apiKey, fetcher = fetch, model = BROADSIDE_MODEL) {
|
|
2169
|
-
// The OpenRouter batch endpoint stream-parses the body and requires
|
|
2170
|
-
// `endpoint` and `model` to serialize before `requests` — key order matters.
|
|
2171
|
-
const payload = {
|
|
2172
|
-
endpoint: "/v1/chat/completions",
|
|
2173
|
-
model,
|
|
2174
|
-
requests: batchRequests,
|
|
2175
|
-
};
|
|
2176
|
-
const resp = await fetcher(BROADSIDE_BATCH_URL, {
|
|
2177
|
-
method: "POST",
|
|
2178
|
-
headers: {
|
|
2179
|
-
Authorization: `Bearer ${apiKey}`,
|
|
2180
|
-
"Content-Type": "application/json",
|
|
2181
|
-
},
|
|
2182
|
-
body: JSON.stringify(payload),
|
|
2183
|
-
signal: AbortSignal.timeout(30_000),
|
|
2184
|
-
});
|
|
2185
|
-
const data = (await resp.json());
|
|
2186
|
-
if (resp.status !== 202) {
|
|
2187
|
-
return { batchId: "", status: "rejected", error: data };
|
|
2188
|
-
}
|
|
2189
|
-
return { batchId: String(data.id), status: String(data.status) };
|
|
2190
|
-
}
|
|
2191
|
-
export async function fetchBatch(batchId, apiKey, fetcher = fetch) {
|
|
2192
|
-
const resp = await fetcher(`${BROADSIDE_BATCH_URL}/${batchId}`, {
|
|
2193
|
-
method: "GET",
|
|
2194
|
-
headers: { Authorization: `Bearer ${apiKey}` },
|
|
2195
|
-
signal: AbortSignal.timeout(30_000),
|
|
2196
|
-
});
|
|
2197
|
-
let data;
|
|
2198
|
-
try {
|
|
2199
|
-
data = (await resp.json());
|
|
2200
|
-
}
|
|
2201
|
-
catch (error) {
|
|
2202
|
-
// A gateway error page is not JSON. It used to throw out of here and
|
|
2203
|
-
// be retried as if the network were down; keep the status instead.
|
|
2204
|
-
data = { error: `non-JSON response (${error instanceof Error ? error.message : String(error)})` };
|
|
2205
|
-
}
|
|
2206
|
-
if (!data || typeof data !== "object")
|
|
2207
|
-
data = { error: "empty response" };
|
|
2208
|
-
// Surface the HTTP status so the poller can bail fast on auth expiry
|
|
2209
|
-
// instead of retrying a dead key for the whole budget.
|
|
2210
|
-
data.http_status = resp.status;
|
|
2211
|
-
return data;
|
|
2212
|
-
}
|
|
2213
|
-
/**
|
|
2214
|
-
* Batch statuses that will never produce a result.
|
|
2215
|
-
*
|
|
2216
|
-
* Deliberately excludes the synthetic `timeout` this module returns when a poll
|
|
2217
|
-
* budget expires: that batch is still running server-side and has already been
|
|
2218
|
-
* charged, so callers must come back for it rather than retire it.
|
|
2219
|
-
*/
|
|
2220
|
-
export const BROADSIDE_DEAD_BATCH_STATUSES = ["failed", "expired", "cancelled", "auth-failed"];
|
|
2221
|
-
/**
|
|
2222
|
-
* Batch entry statuses collect never polls again: the dead ones above, plus
|
|
2223
|
-
* `completed`, plus the two a submit assigns without a batch (`skipped`: no
|
|
2224
|
-
* matching files; `rejected`: the provider refused it). The 0.19.1 changelog
|
|
2225
|
-
* called the dead set "a named constant rather than two hand-maintained
|
|
2226
|
-
* lists"; this set was still three literal copies (self-audit sem 5.8).
|
|
2227
|
-
*/
|
|
2228
|
-
export const BROADSIDE_TERMINAL_ENTRY_STATUSES = ["completed", ...BROADSIDE_DEAD_BATCH_STATUSES, "skipped", "rejected"];
|
|
2229
|
-
export async function pollBatchUntilTerminal(batchId, apiKey, opts = {}) {
|
|
2230
|
-
const deadline = Date.now() + (opts.deadlineMs ?? BROADSIDE_DEFAULT_POLL_BUDGET_MS);
|
|
2231
|
-
const intervalMs = opts.pollIntervalMs ?? BROADSIDE_POLL_INTERVAL_MS;
|
|
2232
|
-
const fetcher = opts.fetcher ?? fetch;
|
|
2233
|
-
// A poll that runs out of budget without one good response is not a slow
|
|
2234
|
-
// batch. The last thing that went wrong rides on the timeout so the report
|
|
2235
|
-
// can tell a dead network or a failing gateway from a batch still running.
|
|
2236
|
-
let lastError = null;
|
|
2237
|
-
let sawBatch = false;
|
|
2238
|
-
const timedOut = () => ({
|
|
2239
|
-
id: batchId,
|
|
2240
|
-
status: "timeout",
|
|
2241
|
-
...(lastError && !sawBatch && { error: `no successful poll response; last error: ${lastError}` }),
|
|
2242
|
-
...(lastError && sawBatch && { last_error: lastError }),
|
|
2243
|
-
});
|
|
2244
|
-
for (;;) {
|
|
2245
|
-
if (opts.signal?.aborted)
|
|
2246
|
-
return { ...timedOut(), aborted: true };
|
|
2247
|
-
let batch;
|
|
2248
|
-
try {
|
|
2249
|
-
batch = await fetchBatch(batchId, apiKey, fetcher);
|
|
2250
|
-
}
|
|
2251
|
-
catch (error) {
|
|
2252
|
-
lastError = `fetch failed (${error instanceof Error ? error.message : String(error)})`;
|
|
2253
|
-
if (Date.now() >= deadline)
|
|
2254
|
-
return timedOut();
|
|
2255
|
-
await sleep(intervalMs);
|
|
2256
|
-
continue;
|
|
2257
|
-
}
|
|
2258
|
-
const httpStatus = Number(batch.http_status ?? 200);
|
|
2259
|
-
if (httpStatus === 401 || httpStatus === 403) {
|
|
2260
|
-
return { id: batchId, status: "auth-failed", error: batch.error ?? batch };
|
|
2261
|
-
}
|
|
2262
|
-
if (httpStatus >= 400) {
|
|
2263
|
-
// A gateway or server error: retry within the budget, remembered.
|
|
2264
|
-
const detail = typeof batch.error === "string" ? batch.error : JSON.stringify(batch.error ?? "");
|
|
2265
|
-
lastError = `HTTP ${httpStatus}${detail ? ` (${detail.slice(0, 200)})` : ""}`;
|
|
2266
|
-
if (Date.now() >= deadline)
|
|
2267
|
-
return timedOut();
|
|
2268
|
-
await sleep(intervalMs);
|
|
2269
|
-
continue;
|
|
2270
|
-
}
|
|
2271
|
-
sawBatch = true;
|
|
2272
|
-
const status = String(batch.status ?? "unknown");
|
|
2273
|
-
const counts = (batch.request_counts ?? {});
|
|
2274
|
-
opts.onStatus?.(status, counts);
|
|
2275
|
-
if (status === "completed" || BROADSIDE_DEAD_BATCH_STATUSES.includes(status))
|
|
2276
|
-
return batch;
|
|
2277
|
-
if (Date.now() >= deadline)
|
|
2278
|
-
return timedOut();
|
|
2279
|
-
await sleepUnlessAborted(intervalMs, opts.signal);
|
|
2280
|
-
}
|
|
2281
|
-
}
|
|
2282
|
-
/** Sleep, but wake at once when the signal fires so an abort is not a poll interval late. */
|
|
2283
|
-
function sleepUnlessAborted(ms, signal) {
|
|
2284
|
-
if (!signal)
|
|
2285
|
-
return sleep(ms);
|
|
2286
|
-
if (signal.aborted)
|
|
2287
|
-
return Promise.resolve();
|
|
2288
|
-
return new Promise((resolve) => {
|
|
2289
|
-
const timer = setTimeout(() => {
|
|
2290
|
-
signal.removeEventListener("abort", onAbort);
|
|
2291
|
-
resolve();
|
|
2292
|
-
}, ms);
|
|
2293
|
-
const onAbort = () => {
|
|
2294
|
-
clearTimeout(timer);
|
|
2295
|
-
resolve();
|
|
2296
|
-
};
|
|
2297
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
2298
|
-
});
|
|
2299
|
-
}
|
|
2300
|
-
/**
|
|
2301
|
-
* Poll several batch ids in parallel against one shared deadline. Collect
|
|
2302
|
-
* previously polled one lens at a time, so a slow first lens serialized the
|
|
2303
|
-
* wall clock for lenses that had already finished server-side (#136). The
|
|
2304
|
-
* onStatus callback identifies the lens so progress output stays readable
|
|
2305
|
-
* even while the polls interleave.
|
|
2306
|
-
*/
|
|
2307
|
-
export async function pollBatchesConcurrently(entries, apiKey, opts = {}) {
|
|
2308
|
-
const results = new Map();
|
|
2309
|
-
const deadlineMs = opts.deadlineMs ?? BROADSIDE_DEFAULT_POLL_BUDGET_MS;
|
|
2310
|
-
await Promise.all(entries.map(async ({ lensId, batchId }) => {
|
|
2311
|
-
const batch = await pollBatchUntilTerminal(batchId, apiKey, {
|
|
2312
|
-
deadlineMs,
|
|
2313
|
-
fetcher: opts.fetcher,
|
|
2314
|
-
pollIntervalMs: opts.pollIntervalMs,
|
|
2315
|
-
signal: opts.signal,
|
|
2316
|
-
onStatus: (status, counts) => opts.onStatus?.(lensId, status, counts),
|
|
2317
|
-
});
|
|
2318
|
-
results.set(batchId, batch);
|
|
2319
|
-
}));
|
|
2320
|
-
return results;
|
|
2321
|
-
}
|
|
2322
|
-
// ---------- run orchestration ----------
|
|
2323
|
-
export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
2324
|
-
const lensIds = opts.lenses ?? BROADSIDE_LENS_IDS;
|
|
2325
|
-
const broadsideDir = broadsideDirFor(cwd);
|
|
2326
|
-
const model = opts.model ?? BROADSIDE_MODEL;
|
|
2327
|
-
// Resolve a catalog entry per distinct model before anything is submitted:
|
|
2328
|
-
// the guardrail must know real per-token rates, and every lens requires
|
|
2329
|
-
// structured-output support that not all batch models offer. Lenses may run
|
|
2330
|
-
// on different models (config `lens_models`), so each one is pre-flighted.
|
|
2331
|
-
const config = await loadBroadsideConfig(broadsideDir);
|
|
2332
|
-
const redact = config.redactSecrets;
|
|
2333
|
-
const info = await collectRepoInfo(cwd, { redact });
|
|
2334
|
-
// Before pricing, before the network, before any state write: a run on a
|
|
2335
|
-
// language the lenses cannot scan used to submit empty batches and pay for
|
|
2336
|
-
// them (#250).
|
|
2337
|
-
if (info.language === "unknown") {
|
|
2338
|
-
throw new Error(`Broad-Side could not tell what language this repository is: no ${MANIFEST_CANDIDATES.map(([candidate]) => candidate).join(", ")} ` +
|
|
2339
|
-
`and no source files in a language the lenses can scan (${BROADSIDE_LANGUAGES.join(", ")}). Nothing was submitted.`);
|
|
2340
|
-
}
|
|
2341
|
-
if (info.sourceFileCount === 0) {
|
|
2342
|
-
throw new Error(`Broad-Side found no ${info.language} source files to scan (detected from ${info.manifest?.path ?? "the file counts"}; ` +
|
|
2343
|
-
`the lenses look for ${info.sourceExts.join(", ")}). Nothing was submitted.`);
|
|
2344
|
-
}
|
|
2345
|
-
const lensModels = { ...config.lensModels, ...opts.lensModels };
|
|
2346
|
-
const modelForLens = (lensId) => lensModels[lensId] ?? model;
|
|
2347
|
-
const resolved = new Map();
|
|
2348
|
-
for (const candidate of new Set([model, ...lensIds.map(modelForLens)])) {
|
|
2349
|
-
const catalog = await resolveCatalogEntry(broadsideDir, config, candidate, apiKey, opts.fetcher);
|
|
2350
|
-
const entry = catalog.entry;
|
|
2351
|
-
const supportsStructuredOutputs = entry.supportedParameters.length === 0 ||
|
|
2352
|
-
entry.supportedParameters.some((p) => ["structured_outputs", "json_schema", "response_format", "structuredoutputs"].includes(p.toLowerCase()));
|
|
2353
|
-
if (!supportsStructuredOutputs) {
|
|
2354
|
-
throw new Error(`Batch model "${candidate}" does not advertise structured-output support ` +
|
|
2355
|
-
`(supported_parameters: ${entry.supportedParameters.join(", ") || "unknown"}), but every ` +
|
|
2356
|
-
"Broad-Side lens requires json_schema response_format. Choose another batch model " +
|
|
2357
|
-
"(codecarto_broadside action 'models') or pass a pricing override only if you know it works.");
|
|
2358
|
-
}
|
|
2359
|
-
resolved.set(candidate, {
|
|
2360
|
-
entry,
|
|
2361
|
-
supportsStructuredOutputs,
|
|
2362
|
-
pricing: { inputPerM: entry.inputPerM, outputPerM: entry.outputPerM, source: catalog.source },
|
|
2363
|
-
// Respect the provider's completion ceiling: a request asking for more
|
|
2364
|
-
// output than the model can produce fails the whole batch.
|
|
2365
|
-
...(entry.maxCompletionTokens !== undefined && { outputCap: entry.maxCompletionTokens }),
|
|
2366
|
-
});
|
|
2367
|
-
}
|
|
2368
|
-
const pricing = resolved.get(model).pricing;
|
|
2369
|
-
const outputCap = resolved.get(model).outputCap;
|
|
2370
|
-
const defaultEntry = resolved.get(model).entry;
|
|
2371
|
-
const limit = opts.maxCost ?? config.maxCost;
|
|
2372
|
-
// Incremental re-scouting (#142): diff against the previous run's HEAD
|
|
2373
|
-
// and scan only the modules whose files changed. Falls back to a full
|
|
2374
|
-
// scan when there is no prior run, the tree is dirty, or the diff fails.
|
|
2375
|
-
const sourceHead = await gitHead(cwd);
|
|
2376
|
-
const sourceDirty = await gitDirty(cwd);
|
|
2377
|
-
let baseHead = null;
|
|
2378
|
-
let changed = null;
|
|
2379
|
-
const incrementalOutcome = {
|
|
2380
|
-
requested: opts.incremental === true,
|
|
2381
|
-
applied: false,
|
|
2382
|
-
baseHead: null,
|
|
2383
|
-
};
|
|
2384
|
-
if (opts.incremental) {
|
|
2385
|
-
const state = await loadBroadsideState(broadsideDir);
|
|
2386
|
-
// The baseline is the most recent run that recorded a HEAD — a
|
|
2387
|
-
// submit-only run (never collected) is still a valid committed base.
|
|
2388
|
-
const previous = [...state.runs].reverse().find((r) => r.sourceHead);
|
|
2389
|
-
if (sourceDirty) {
|
|
2390
|
-
incrementalOutcome.reason = "dirty-worktree";
|
|
2391
|
-
}
|
|
2392
|
-
else if (!previous?.sourceHead) {
|
|
2393
|
-
incrementalOutcome.reason = "no-baseline";
|
|
2394
|
-
}
|
|
2395
|
-
else {
|
|
2396
|
-
baseHead = previous.sourceHead;
|
|
2397
|
-
changed = await changedFilesSince(cwd, baseHead);
|
|
2398
|
-
incrementalOutcome.baseHead = baseHead;
|
|
2399
|
-
if (changed)
|
|
2400
|
-
incrementalOutcome.applied = true;
|
|
2401
|
-
else
|
|
2402
|
-
incrementalOutcome.reason = "diff-failed";
|
|
2403
|
-
}
|
|
2404
|
-
}
|
|
2405
|
-
// Slice offline first so the estimate covers every request we would send.
|
|
2406
|
-
const slicesByLens = new Map();
|
|
2407
|
-
// Why a lens ended up with nothing to submit, for the report (see below).
|
|
2408
|
-
const skipReasons = new Map();
|
|
2409
|
-
let estimatedInputTokens = 0;
|
|
2410
|
-
let estimatedOutputTokens = 0;
|
|
2411
|
-
let estimatedTotalCost = 0;
|
|
2412
|
-
const perLensEstimate = [];
|
|
2413
|
-
// What the redaction pass did across every lens's slices, for the run
|
|
2414
|
-
// record and the report: a value in a file shared by two lenses counts
|
|
2415
|
-
// once per lens it was sent in, files once each.
|
|
2416
|
-
let redactedValues = info.redactedValues;
|
|
2417
|
-
const redactedFiles = new Set();
|
|
2418
|
-
for (const lensId of lensIds) {
|
|
2419
|
-
const lens = getLens(lensId);
|
|
2420
|
-
let slices = await gatherSlices(cwd, lens, info, { redact });
|
|
2421
|
-
for (const slice of slices) {
|
|
2422
|
-
redactedValues += slice.redactedValues ?? 0;
|
|
2423
|
-
for (const file of slice.redactedFiles ?? [])
|
|
2424
|
-
redactedFiles.add(file);
|
|
2425
|
-
}
|
|
2426
|
-
const matchedBeforeIncremental = slices.length;
|
|
2427
|
-
if (changed) {
|
|
2428
|
-
// Repo-info slices (empty files, e.g. architecture) always run;
|
|
2429
|
-
// file-backed slices run only when one of their files changed.
|
|
2430
|
-
slices = slices.filter((s) => s.files.length === 0 || s.files.some((f) => changed.has(f)));
|
|
2431
|
-
}
|
|
2432
|
-
slicesByLens.set(lensId, slices);
|
|
2433
|
-
if (slices.length === 0) {
|
|
2434
|
-
const globs = lens.globsFor(info).filter(Boolean);
|
|
2435
|
-
const fallbackGlobs = lens.fallbackGlobsFor?.(info).filter(Boolean) ?? [];
|
|
2436
|
-
skipReasons.set(lensId, globs.length === 0
|
|
2437
|
-
? "the lens has no file patterns for this language"
|
|
2438
|
-
: matchedBeforeIncremental > 0
|
|
2439
|
-
? "incremental: none of this lens's files changed since the previous run"
|
|
2440
|
-
: `no files matched ${globs.join(", ")}` +
|
|
2441
|
-
(fallbackGlobs.length > 0 ? ` or the fallback ${fallbackGlobs.join(", ")}` : "") +
|
|
2442
|
-
(lens.skipTestFiles ? " (test files excluded)" : ""));
|
|
2443
|
-
}
|
|
2444
|
-
const lensModel = modelForLens(lensId);
|
|
2445
|
-
const { pricing: lensPricing, outputCap: lensOutputCap } = resolved.get(lensModel);
|
|
2446
|
-
const maxTokens = lensOutputCap ? Math.min(lens.maxTokens, lensOutputCap) : lens.maxTokens;
|
|
2447
|
-
const estimate = estimateCost(lens, slices, lensPricing, maxTokens, info);
|
|
2448
|
-
estimatedInputTokens += estimate.inputTokens;
|
|
2449
|
-
estimatedOutputTokens += estimate.outputTokens;
|
|
2450
|
-
estimatedTotalCost += estimate.cost;
|
|
2451
|
-
perLensEstimate.push({
|
|
2452
|
-
lens,
|
|
2453
|
-
cost: estimate.cost,
|
|
2454
|
-
maxTokens,
|
|
2455
|
-
lensModel,
|
|
2456
|
-
lensPricing,
|
|
2457
|
-
...(lensOutputCap !== undefined && { lensOutputCap }),
|
|
2458
|
-
});
|
|
2459
|
-
}
|
|
2460
|
-
const exceedsLimit = limit > 0 && estimatedTotalCost > limit;
|
|
2461
|
-
if (opts.confirm) {
|
|
2462
|
-
const approved = await opts.confirm({
|
|
2463
|
-
model,
|
|
2464
|
-
pricing,
|
|
2465
|
-
lenses: perLensEstimate.map(({ lens, cost, maxTokens, lensModel, lensPricing }) => {
|
|
2466
|
-
const fallback = (slicesByLens.get(lens.id) ?? []).find((slice) => slice.fallback)?.fallback;
|
|
2467
|
-
return {
|
|
2468
|
-
lensId: lens.id,
|
|
2469
|
-
name: lens.name,
|
|
2470
|
-
slices: (slicesByLens.get(lens.id) ?? []).length,
|
|
2471
|
-
maxTokens,
|
|
2472
|
-
cost,
|
|
2473
|
-
model: lensModel,
|
|
2474
|
-
pricing: lensPricing,
|
|
2475
|
-
...(fallback && { fallback }),
|
|
2476
|
-
};
|
|
2477
|
-
}),
|
|
2478
|
-
mixedModels: perLensEstimate.some(({ lensModel }) => lensModel !== model),
|
|
2479
|
-
totalCost: estimatedTotalCost,
|
|
2480
|
-
inputTokens: estimatedInputTokens,
|
|
2481
|
-
outputTokens: estimatedOutputTokens,
|
|
2482
|
-
maxCost: limit,
|
|
2483
|
-
exceedsLimit,
|
|
2484
|
-
baseHead,
|
|
2485
|
-
sourceDirty,
|
|
2486
|
-
incremental: incrementalOutcome,
|
|
2487
|
-
...(outputCap !== undefined && { outputCap }),
|
|
2488
|
-
});
|
|
2489
|
-
if (!approved)
|
|
2490
|
-
throw new BroadsideCancelledError();
|
|
2491
|
-
}
|
|
2492
|
-
else if (exceedsLimit && !opts.force) {
|
|
2493
|
-
const breakdown = perLensEstimate
|
|
2494
|
-
.map(({ lens, cost, lensModel }) => ` ${lens.name}: ~$${cost.toFixed(4)}${lensModel === model ? "" : ` (${lensModel})`}`)
|
|
2495
|
-
.join("\n");
|
|
2496
|
-
throw new Error(`Estimated Broad-Side cost ~$${estimatedTotalCost.toFixed(4)} exceeds the run limit ` +
|
|
2497
|
-
`$${limit.toFixed(2)}. Nothing was submitted.\nBreakdown:\n${breakdown}\n` +
|
|
2498
|
-
`Pass force: true to submit anyway, or raise max_cost in .codecarto/broadside/config.yaml.`);
|
|
2499
|
-
}
|
|
2500
|
-
// Read before anything is posted: a state.json that cannot be read refuses
|
|
2501
|
-
// the run here (#233), while persistBroadsideRun below merges by run id.
|
|
2502
|
-
await loadBroadsideState(broadsideDir);
|
|
2503
|
-
const runId = new Date().toISOString().replace(/[:.]/g, "-");
|
|
2504
|
-
const run = {
|
|
2505
|
-
id: runId,
|
|
2506
|
-
createdAt: new Date().toISOString(),
|
|
2507
|
-
model,
|
|
2508
|
-
lenses: [...lensIds],
|
|
2509
|
-
status: "in-flight",
|
|
2510
|
-
outputDir: runId,
|
|
2511
|
-
batches: {},
|
|
2512
|
-
synthesis: { status: "pending" },
|
|
2513
|
-
triage: { status: "pending" },
|
|
2514
|
-
pricing,
|
|
2515
|
-
maxCost: limit > 0 ? limit : undefined,
|
|
2516
|
-
outputCap,
|
|
2517
|
-
sourceHead,
|
|
2518
|
-
sourceDirty,
|
|
2519
|
-
baseHead,
|
|
2520
|
-
snapshot: info.snapshot,
|
|
2521
|
-
language: info.language,
|
|
2522
|
-
redaction: {
|
|
2523
|
-
enabled: redact,
|
|
2524
|
-
values: redactedValues,
|
|
2525
|
-
files: redactedFiles.size,
|
|
2526
|
-
skippedFiles: info.secretFilesSkipped.length,
|
|
2527
|
-
},
|
|
2528
|
-
};
|
|
2529
|
-
await persistBroadsideRun(broadsideDir, run);
|
|
2530
|
-
const requestsByCustomId = {};
|
|
2531
|
-
const submissions = [];
|
|
2532
|
-
// Submit from the estimate rather than recomputing: the user approved that
|
|
2533
|
-
// breakdown, so the request that fires must be the one that was priced.
|
|
2534
|
-
for (const priced of perLensEstimate) {
|
|
2535
|
-
const { lens, maxTokens, lensModel, lensOutputCap } = priced;
|
|
2536
|
-
const lensId = lens.id;
|
|
2537
|
-
const slices = slicesByLens.get(lensId) ?? [];
|
|
2538
|
-
const requests = slices.map((sl, i) => buildBatchRequest(lens, info, sl, i, slices.length, lensModel, maxTokens, config.reasoning ?? undefined));
|
|
2539
|
-
for (const request of requests)
|
|
2540
|
-
requestsByCustomId[request.custom_id] = request;
|
|
2541
|
-
const fallback = slices.find((slice) => slice.fallback)?.fallback;
|
|
2542
|
-
const entry = {
|
|
2543
|
-
batchId: "",
|
|
2544
|
-
requests: requests.length,
|
|
2545
|
-
status: "submitting",
|
|
2546
|
-
submittedAt: new Date().toISOString(),
|
|
2547
|
-
estimatedCost: priced.cost,
|
|
2548
|
-
// A scan of the fallback scope is recorded as such (#319).
|
|
2549
|
-
...(fallback && { fallback }),
|
|
2550
|
-
// Recorded per lens so collect's truncation retry re-submits against
|
|
2551
|
-
// the model and ceiling this lens actually used, not the run default.
|
|
2552
|
-
...(lensModel !== model && { model: lensModel }),
|
|
2553
|
-
...(lensOutputCap !== undefined && { outputCap: lensOutputCap }),
|
|
2554
|
-
};
|
|
2555
|
-
run.batches[lensId] = entry;
|
|
2556
|
-
if (requests.length === 0) {
|
|
2557
|
-
// No files matched the lens's globs. That is a coverage gap to
|
|
2558
|
-
// report, not a batch to submit — the API rejects empty batches.
|
|
2559
|
-
// Name the globs: a JavaScript service whose server lives at
|
|
2560
|
-
// src/server.js gets no security review (that lens reads server/**,
|
|
2561
|
-
// **/auth*, **/middleware/**), and "skipped (0 request(s))" alone
|
|
2562
|
-
// read as an empty repository rather than a lens that looked in
|
|
2563
|
-
// the wrong place.
|
|
2564
|
-
entry.status = "skipped";
|
|
2565
|
-
const reason = skipReasons.get(lensId);
|
|
2566
|
-
if (reason)
|
|
2567
|
-
entry.reason = reason;
|
|
2568
|
-
continue;
|
|
2569
|
-
}
|
|
2570
|
-
submissions.push((async () => {
|
|
2571
|
-
// A network-level throw (DNS, abort, TLS) must not strand the
|
|
2572
|
-
// entry in "submitting" forever — allSettled would swallow the
|
|
2573
|
-
// rejection and collect would never see a terminal status.
|
|
2574
|
-
try {
|
|
2575
|
-
const { batchId, status, error } = await submitBatch(requests, apiKey, opts.fetcher, lensModel);
|
|
2576
|
-
entry.batchId = batchId;
|
|
2577
|
-
entry.status = status;
|
|
2578
|
-
if (error)
|
|
2579
|
-
entry.error = error;
|
|
2580
|
-
}
|
|
2581
|
-
catch (error) {
|
|
2582
|
-
entry.status = "rejected";
|
|
2583
|
-
entry.error = error instanceof Error ? error.message : String(error);
|
|
2584
|
-
}
|
|
2585
|
-
})());
|
|
2586
|
-
}
|
|
2587
|
-
await Promise.allSettled(submissions);
|
|
2588
|
-
// A run with no batch behind it has nothing in flight. Every lens was
|
|
2589
|
-
// skipped or refused, so no poll will ever complete it; leaving it
|
|
2590
|
-
// "in-flight" had status listing a refused run above the completed ones
|
|
2591
|
-
// with synthesis and triage "pending" forever.
|
|
2592
|
-
if (!Object.values(run.batches).some((entry) => entry.batchId))
|
|
2593
|
-
run.status = "failed";
|
|
2594
|
-
await persistBroadsideRun(broadsideDir, run);
|
|
2595
|
-
// What the provider just said about each model's batch endpoint outlives
|
|
2596
|
-
// the run: the `models` action reads it back (#141).
|
|
2597
|
-
await recordBatchEndpoints(broadsideDir, lensIds
|
|
2598
|
-
.map((lensId) => run.batches[lensId])
|
|
2599
|
-
.filter((entry) => Boolean(entry) && entry.status !== "skipped")
|
|
2600
|
-
.map((entry) => ({ model: entry.model ?? model, batchId: entry.batchId, error: entry.error })));
|
|
2601
|
-
// Persist the exact request bodies so collect can re-submit a truncated
|
|
2602
|
-
// slice (bumped output cap) without re-walking the repo (#133). The run
|
|
2603
|
-
// dir is created here rather than waiting for collect so a crash between
|
|
2604
|
-
// submit and collect still leaves the retry input on disk.
|
|
2605
|
-
const runDir = join(broadsideDir, runId);
|
|
2606
|
-
await mkdir(runDir, { recursive: true });
|
|
2607
|
-
await writeFile(join(runDir, "requests.json"), `${JSON.stringify(requestsByCustomId, null, "\t")}\n`, "utf8");
|
|
2608
|
-
return {
|
|
2609
|
-
runId,
|
|
2610
|
-
outputDir: join(".codecarto", BROADSIDE_DIR, runId),
|
|
2611
|
-
batches: run.batches,
|
|
2612
|
-
estimatedTotalCost,
|
|
2613
|
-
estimatedInputTokens,
|
|
2614
|
-
estimatedOutputTokens,
|
|
2615
|
-
pricing,
|
|
2616
|
-
maxCost: limit > 0 ? limit : undefined,
|
|
2617
|
-
// modelInfo describes the run's default model. Per-lens overrides are
|
|
2618
|
-
// recorded on their own batch entries.
|
|
2619
|
-
modelInfo: {
|
|
2620
|
-
contextLength: defaultEntry.contextLength,
|
|
2621
|
-
maxCompletionTokens: defaultEntry.maxCompletionTokens,
|
|
2622
|
-
supportsStructuredOutputs: defaultEntry.supportedParameters.length === 0
|
|
2623
|
-
? undefined
|
|
2624
|
-
: resolved.get(model).supportsStructuredOutputs,
|
|
2625
|
-
expirationDate: defaultEntry.expirationDate ?? null,
|
|
2626
|
-
},
|
|
2627
|
-
incremental: incrementalOutcome,
|
|
2628
|
-
repo: {
|
|
2629
|
-
language: info.language,
|
|
2630
|
-
sourceFiles: info.sourceFileCount,
|
|
2631
|
-
snapshot: info.snapshot,
|
|
2632
|
-
sourceHead,
|
|
2633
|
-
sourceDirty,
|
|
2634
|
-
},
|
|
2635
|
-
redaction: {
|
|
2636
|
-
enabled: redact,
|
|
2637
|
-
values: redactedValues,
|
|
2638
|
-
files: redactedFiles.size,
|
|
2639
|
-
skippedFiles: info.secretFilesSkipped,
|
|
2640
|
-
},
|
|
2641
|
-
};
|
|
2642
|
-
}
|
|
2643
|
-
function extractContent(result) {
|
|
2644
|
-
const response = result.response;
|
|
2645
|
-
if (!response?.body)
|
|
2646
|
-
return null;
|
|
2647
|
-
const body = response.body;
|
|
2648
|
-
const choices = body.choices;
|
|
2649
|
-
const message = choices?.[0]?.message;
|
|
2650
|
-
return typeof message?.content === "string" ? message.content : null;
|
|
2651
|
-
}
|
|
2652
|
-
/**
|
|
2653
|
-
* Parse lens content as JSON, tolerating the markdown code fences some models
|
|
2654
|
-
* wrap structured output in (the same tolerance OpenRouter's headless-agent
|
|
2655
|
-
* scaffold ships for --output-schema). Returns null when the content is not
|
|
2656
|
-
* JSON at all — which for a strict json_schema request means the output was
|
|
2657
|
-
* truncated at max_tokens, not that the model chose prose.
|
|
2658
|
-
*/
|
|
2659
|
-
export function parseLensJson(content) {
|
|
2660
|
-
const trimmed = content.trim();
|
|
2661
|
-
const fenced = /^```(?:json)?\s*\n?([\s\S]*?)\n?```\s*$/.exec(trimmed);
|
|
2662
|
-
const candidate = fenced ? fenced[1].trim() : trimmed;
|
|
2663
|
-
if (!candidate.startsWith("{") && !candidate.startsWith("["))
|
|
2664
|
-
return null;
|
|
2665
|
-
try {
|
|
2666
|
-
return JSON.parse(candidate);
|
|
2667
|
-
}
|
|
2668
|
-
catch {
|
|
2669
|
-
return null;
|
|
2670
|
-
}
|
|
2671
|
-
}
|
|
2672
|
-
export async function saveLensResults(runDir, lensId, batch) {
|
|
2673
|
-
const results = Array.isArray(batch.results) ? batch.results : [];
|
|
2674
|
-
const out = [];
|
|
2675
|
-
for (const result of results) {
|
|
2676
|
-
const customId = String(result.custom_id ?? "unknown");
|
|
2677
|
-
const content = extractContent(result);
|
|
2678
|
-
if (content === null) {
|
|
2679
|
-
if (result.error) {
|
|
2680
|
-
await writeFile(join(runDir, `${sanitizeId(customId)}.error.json`), `${JSON.stringify(result.error, null, "\t")}\n`, "utf8");
|
|
2681
|
-
}
|
|
2682
|
-
continue;
|
|
2683
|
-
}
|
|
2684
|
-
const parsed = parseLensJson(content);
|
|
2685
|
-
const truncated = parsed === null;
|
|
2686
|
-
if (parsed !== null) {
|
|
2687
|
-
await writeFile(join(runDir, `${sanitizeId(customId)}.json`), `${JSON.stringify(parsed, null, "\t")}\n`, "utf8");
|
|
2688
|
-
}
|
|
2689
|
-
else {
|
|
2690
|
-
// Save the raw bytes verbatim so nothing is lost, but name the
|
|
2691
|
-
// gap: an unparseable strict-schema response is a truncation.
|
|
2692
|
-
await writeFile(join(runDir, `${sanitizeId(customId)}.json`), `${content}\n`, "utf8");
|
|
2693
|
-
}
|
|
2694
|
-
await writeFile(join(runDir, `${sanitizeId(customId)}.md`), renderFindingsMarkdown(content), "utf8");
|
|
2695
|
-
out.push({
|
|
2696
|
-
lensId,
|
|
2697
|
-
customId,
|
|
2698
|
-
moduleName: String(customId).replace(/^[a-z]+-/, ""),
|
|
2699
|
-
content,
|
|
2700
|
-
raw: result,
|
|
2701
|
-
truncated,
|
|
2702
|
-
});
|
|
2703
|
-
}
|
|
2704
|
-
return out;
|
|
2705
|
-
}
|
|
2706
|
-
/**
|
|
2707
|
-
* Rebuild lens results from what a previous collect already wrote to disk.
|
|
2708
|
-
*
|
|
2709
|
-
* The post-passes are gated on having lens findings in hand, and a collect
|
|
2710
|
-
* only holds the ones *it* polled. When an earlier collect saved every lens
|
|
2711
|
-
* and then died before synthesis and triage ran — the batch window is long
|
|
2712
|
-
* and a poll can easily be interrupted — the next collect finds every lens
|
|
2713
|
-
* already terminal, skips them all, and would otherwise reach the post-pass
|
|
2714
|
-
* gate with nothing to hand it. Reading the saved results back is what makes
|
|
2715
|
-
* "a resumed collect can finish whichever is still pending" true.
|
|
2716
|
-
*/
|
|
2717
|
-
export async function loadSavedLensResults(runDir, lenses) {
|
|
2718
|
-
if (!(await pathExists(runDir)))
|
|
2719
|
-
return [];
|
|
2720
|
-
const reserved = new Set(["requests.json", "run-meta.json", "synthesis.json", "triage.json"]);
|
|
2721
|
-
const out = [];
|
|
2722
|
-
// Longest lens id first: no id is a prefix of another today, but ordering
|
|
2723
|
-
// keeps that from becoming a silent misattribution if one ever is.
|
|
2724
|
-
const ordered = [...lenses].sort((a, b) => b.length - a.length);
|
|
2725
|
-
for (const name of (await readdir(runDir)).sort()) {
|
|
2726
|
-
if (!name.endsWith(".json") || name.endsWith(".error.json") || reserved.has(name) || name.startsWith("raw-"))
|
|
2727
|
-
continue;
|
|
2728
|
-
const customId = name.slice(0, -".json".length);
|
|
2729
|
-
const lensId = ordered.find((id) => customId === id || customId.startsWith(`${id}-`));
|
|
2730
|
-
if (!lensId)
|
|
2731
|
-
continue;
|
|
2732
|
-
const content = await readFile(join(runDir, name), "utf8").catch(() => null);
|
|
2733
|
-
if (content === null)
|
|
2734
|
-
continue;
|
|
2735
|
-
out.push({
|
|
2736
|
-
lensId,
|
|
2737
|
-
customId,
|
|
2738
|
-
moduleName: customId.replace(/^[a-z]+-/, ""),
|
|
2739
|
-
content,
|
|
2740
|
-
raw: {},
|
|
2741
|
-
truncated: parseLensJson(content) === null,
|
|
2742
|
-
});
|
|
2743
|
-
}
|
|
2744
|
-
return out;
|
|
2745
|
-
}
|
|
2746
|
-
async function loadStoredRequests(runDir) {
|
|
2747
|
-
const path = join(runDir, "requests.json");
|
|
2748
|
-
if (!(await pathExists(path)))
|
|
2749
|
-
return {};
|
|
2750
|
-
try {
|
|
2751
|
-
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
2752
|
-
return parsed && typeof parsed === "object" ? parsed : {};
|
|
2753
|
-
}
|
|
2754
|
-
catch {
|
|
2755
|
-
return {};
|
|
2756
|
-
}
|
|
2757
|
-
}
|
|
2758
|
-
/**
|
|
2759
|
-
* The verdicts a verify pass left in the run directory, or null when none
|
|
2760
|
-
* has run (#338). A file that does not parse is treated as absent: the
|
|
2761
|
-
* post-passes then run from the findings alone, which is what they did
|
|
2762
|
-
* before verdicts existed, and `status` shows the pass carried no verdicts.
|
|
2763
|
-
*/
|
|
2764
|
-
export async function loadPostPassVerdicts(runDir) {
|
|
2765
|
-
const path = join(runDir, "verified.json");
|
|
2766
|
-
if (!(await pathExists(path)))
|
|
2767
|
-
return null;
|
|
2768
|
-
try {
|
|
2769
|
-
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
2770
|
-
const findings = Array.isArray(parsed.findings) ? parsed.findings : [];
|
|
2771
|
-
const verdicts = findings
|
|
2772
|
-
.filter((f) => typeof f.title === "string" && typeof f.verdict === "string")
|
|
2773
|
-
.map((f) => ({
|
|
2774
|
-
lensId: String(f.lensId ?? ""),
|
|
2775
|
-
customId: String(f.customId ?? ""),
|
|
2776
|
-
severity: String(f.severity ?? ""),
|
|
2777
|
-
title: String(f.title),
|
|
2778
|
-
location: String(f.location ?? ""),
|
|
2779
|
-
verdict: String(f.verdict),
|
|
2780
|
-
confidence: String(f.confidence ?? ""),
|
|
2781
|
-
evidence: Array.isArray(f.evidence)
|
|
2782
|
-
? f.evidence.map((e) => ({ file: String(e.file ?? ""), lines: String(e.lines ?? ""), note: String(e.note ?? "") }))
|
|
2783
|
-
: [],
|
|
2784
|
-
reasoning: String(f.reasoning ?? ""),
|
|
2785
|
-
}));
|
|
2786
|
-
return verdicts.length > 0 ? verdicts : null;
|
|
2787
|
-
}
|
|
2788
|
-
catch {
|
|
2789
|
-
return null;
|
|
2790
|
-
}
|
|
2791
|
-
}
|
|
2792
|
-
/**
|
|
2793
|
-
* The verdicts as a section of the post-pass user message: one line per
|
|
2794
|
-
* finding with the verdict, the evidence the verifier cited, and its
|
|
2795
|
-
* reasoning, so the pass can rank on them rather than on the batch model's
|
|
2796
|
-
* own severities (#338).
|
|
2797
|
-
*/
|
|
2798
|
-
export function renderPostPassVerdicts(verdicts) {
|
|
2799
|
-
const counts = new Map();
|
|
2800
|
-
for (const v of verdicts)
|
|
2801
|
-
counts.set(v.verdict, (counts.get(v.verdict) ?? 0) + 1);
|
|
2802
|
-
const tally = [...counts.entries()].map(([verdict, n]) => `${n} ${verdict}`).join(", ");
|
|
2803
|
-
const lines = [
|
|
2804
|
-
"",
|
|
2805
|
-
`## Verification verdicts (${verdicts.length} finding(s) read against the source by a read-only-tools pass: ${tally})`,
|
|
2806
|
-
"",
|
|
2807
|
-
"A verdict outranks the batch severity of the finding it names. `confirmed` means the verifier found a reachable " +
|
|
2808
|
-
"failure and named its trigger; `not-a-defect` means the claim is literally true of the code but nothing reaches the " +
|
|
2809
|
-
"failure it describes; `discarded` means the claim is wrong about the code; `unclear` means the code alone could not " +
|
|
2810
|
-
"settle it; `error` means the pass could not read it — treat that finding as unverified. Findings not listed here " +
|
|
2811
|
-
"were not read and stay unverified leads.",
|
|
2812
|
-
"",
|
|
2813
|
-
];
|
|
2814
|
-
for (const v of verdicts) {
|
|
2815
|
-
const evidence = v.evidence.map((e) => `${e.file}${e.lines ? `:${e.lines}` : ""}${e.note ? ` (${e.note})` : ""}`).join("; ");
|
|
2816
|
-
lines.push(`- [${v.verdict}${v.confidence ? `, ${v.confidence} confidence` : ""}] ${v.lensId}/${v.customId} — [${v.severity}] ${v.title}` +
|
|
2817
|
-
`${v.location ? ` @ ${v.location}` : ""}` +
|
|
2818
|
-
`${v.reasoning ? `\n Reasoning: ${v.reasoning.replace(/\s+/g, " ").trim()}` : ""}` +
|
|
2819
|
-
`${evidence ? `\n Evidence: ${evidence}` : ""}`);
|
|
2820
|
-
}
|
|
2821
|
-
lines.push("");
|
|
2822
|
-
return lines.join("\n");
|
|
2823
|
-
}
|
|
2824
|
-
const SYNTHESIS_VERDICT_INSTRUCTIONS = " A verification pass has read some of the findings against the source; its verdicts follow the reports. " +
|
|
2825
|
-
"Lead top_findings with the confirmed findings and begin each such summary with 'verified: confirmed — ' and the " +
|
|
2826
|
-
"trigger the verifier named; keep an unclear one with 'verified: unclear — '. A discarded or not-a-defect finding " +
|
|
2827
|
-
"does not appear in top_findings and is not counted in severity_summary. Say in the executive summary how many " +
|
|
2828
|
-
"findings were verified and how the verdicts split; findings the pass did not read remain unverified, and the " +
|
|
2829
|
-
"summary says so of them, not of the confirmed ones.";
|
|
2830
|
-
const TRIAGE_VERDICT_INSTRUCTIONS = " A verification pass has read some of the findings against the source; its verdicts follow the findings. " +
|
|
2831
|
-
"A confirmed finding ranks above every unverified finding of the same or lower severity: put the confirmed " +
|
|
2832
|
-
"findings at the top of the queue and begin each one's rationale with 'verified: confirmed — ' and the trigger " +
|
|
2833
|
-
"the verifier named. Keep an unclear finding in the queue with 'verified: unclear — ' in its rationale. Do not " +
|
|
2834
|
-
"queue a discarded or not-a-defect finding: list each in omitted, beginning with 'verified: discarded — ' or " +
|
|
2835
|
-
"'verified: not a defect — ' and the reason the pass gave. Findings the pass did not read stay unverified leads, " +
|
|
2836
|
-
"and the summary says how many verdicts the queue was built from.";
|
|
2837
|
-
function buildSynthesisRequest(findingsText, truncatedNote, model, verdicts = null) {
|
|
2838
|
-
return {
|
|
2839
|
-
custom_id: "synthesis",
|
|
2840
|
-
body: {
|
|
2841
|
-
model,
|
|
2842
|
-
messages: [
|
|
2843
|
-
{
|
|
2844
|
-
role: "system",
|
|
2845
|
-
content: "You are a technical editor synthesizing multiple analysis reports about a single " +
|
|
2846
|
-
"codebase into one coherent summary. The reports come from different lenses — " +
|
|
2847
|
-
"architecture, API surface, security review, defect scanning, convention extraction, " +
|
|
2848
|
-
"and porting assessment. Cross-reference findings across lenses: if a security issue " +
|
|
2849
|
-
"also appears as a defect, merge them. Produce a JSON object following the " +
|
|
2850
|
-
"synthesis_report schema. Prioritize the most actionable findings. " +
|
|
2851
|
-
"Be honest about gaps — if a lens found nothing, say 'no issues found' rather than " +
|
|
2852
|
-
"inventing problems. These are scouting signals from a batch model, not verified " +
|
|
2853
|
-
"claims; note that in the summary." +
|
|
2854
|
-
(verdicts ? SYNTHESIS_VERDICT_INSTRUCTIONS : ""),
|
|
2855
|
-
},
|
|
2856
|
-
{
|
|
2857
|
-
role: "user",
|
|
2858
|
-
content: "Synthesize these analysis reports into a single summary.\n\n" +
|
|
2859
|
-
findingsText +
|
|
2860
|
-
truncatedNote +
|
|
2861
|
-
(verdicts ? renderPostPassVerdicts(verdicts) : "") +
|
|
2862
|
-
"\nReturn the synthesis_report JSON schema.",
|
|
2863
|
-
},
|
|
2864
|
-
],
|
|
2865
|
-
response_format: { type: "json_schema", json_schema: SCHEMAS.synthesis },
|
|
2866
|
-
max_tokens: 12_000,
|
|
2867
|
-
},
|
|
2868
|
-
};
|
|
2869
|
-
}
|
|
2870
|
-
function buildTriageRequest(findingsText, truncatedNote, model, verdicts = null) {
|
|
2871
|
-
return {
|
|
2872
|
-
custom_id: "triage",
|
|
2873
|
-
body: {
|
|
2874
|
-
model,
|
|
2875
|
-
messages: [
|
|
2876
|
-
{
|
|
2877
|
-
role: "system",
|
|
2878
|
-
content: "You are a senior engineering lead turning unverified scouting findings into a " +
|
|
2879
|
-
"prioritized work order. Given the findings below, produce a JSON object following " +
|
|
2880
|
-
"the triage_report schema. Score every lead by impact and fix difficulty, assign a " +
|
|
2881
|
-
"priority (P0 urgent/safety-critical to P3 nice-to-have), give a rough effort " +
|
|
2882
|
-
"estimate, group the queue by module where sensible, and justify each call in the " +
|
|
2883
|
-
"rationale. Merge duplicate leads instead of listing them twice. Drop leads that are " +
|
|
2884
|
-
"too vague to act on and record each drop in omitted with the reason. These findings " +
|
|
2885
|
-
"are UNVERIFIED scouting signals from a cheap batch model: the queue is a starting " +
|
|
2886
|
-
"point for re-verification, not a commitment — say so in the summary, and never " +
|
|
2887
|
-
"inflate a severity you cannot see evidence for." +
|
|
2888
|
-
(verdicts ? TRIAGE_VERDICT_INSTRUCTIONS : ""),
|
|
2889
|
-
},
|
|
2890
|
-
{
|
|
2891
|
-
role: "user",
|
|
2892
|
-
content: "Triage these scouting findings into a prioritized work order.\n\n" +
|
|
2893
|
-
findingsText +
|
|
2894
|
-
truncatedNote +
|
|
2895
|
-
(verdicts ? renderPostPassVerdicts(verdicts) : "") +
|
|
2896
|
-
"\nReturn the triage_report JSON schema.",
|
|
2897
|
-
},
|
|
2898
|
-
],
|
|
2899
|
-
response_format: { type: "json_schema", json_schema: SCHEMAS.triage },
|
|
2900
|
-
max_tokens: 10_000,
|
|
2901
|
-
},
|
|
2902
|
-
};
|
|
2903
|
-
}
|
|
2904
|
-
function parseTriageItems(content) {
|
|
2905
|
-
try {
|
|
2906
|
-
const parsed = JSON.parse(content);
|
|
2907
|
-
const items = Array.isArray(parsed.items) ? parsed.items : [];
|
|
2908
|
-
return items
|
|
2909
|
-
.filter((item) => typeof item.title === "string")
|
|
2910
|
-
.map((item) => ({
|
|
2911
|
-
title: String(item.title),
|
|
2912
|
-
severity: String(item.severity ?? "unknown"),
|
|
2913
|
-
module: String(item.module ?? "unknown"),
|
|
2914
|
-
impact: (["high", "medium", "low"].includes(String(item.impact)) ? String(item.impact) : "medium"),
|
|
2915
|
-
difficulty: (["high", "medium", "low"].includes(String(item.difficulty)) ? String(item.difficulty) : "medium"),
|
|
2916
|
-
priority: String(item.priority ?? "?"),
|
|
2917
|
-
effort_estimate: String(item.effort_estimate ?? ""),
|
|
2918
|
-
rationale: String(item.rationale ?? ""),
|
|
2919
|
-
}));
|
|
2920
|
-
}
|
|
2921
|
-
catch {
|
|
2922
|
-
return [];
|
|
2923
|
-
}
|
|
2924
|
-
}
|
|
2925
|
-
export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
2926
|
-
const broadsideDir = broadsideDirFor(cwd);
|
|
2927
|
-
const state = await loadBroadsideState(broadsideDir);
|
|
2928
|
-
const run = opts.runId ? state.runs.find((candidate) => candidate.id === opts.runId) : state.runs[state.runs.length - 1];
|
|
2929
|
-
if (!run) {
|
|
2930
|
-
if (opts.runId) {
|
|
2931
|
-
const known = state.runs.map((candidate) => candidate.id);
|
|
2932
|
-
throw new Error(`No Broad-Side run with id ${opts.runId}. ` +
|
|
2933
|
-
(known.length > 0 ? `Recorded runs: ${known.join(", ")}.` : "No runs are recorded; call codecarto_broadside with action 'submit' first."));
|
|
2934
|
-
}
|
|
2935
|
-
throw new Error("No Broad-Side run recorded. Call codecarto_broadside with action 'submit' first.");
|
|
2936
|
-
}
|
|
2937
|
-
const runDir = join(broadsideDir, run.outputDir);
|
|
2938
|
-
await mkdir(runDir, { recursive: true });
|
|
2939
|
-
// The spending slots this collect has claimed (#322); only a claimed slot
|
|
2940
|
-
// is ever submitted from here. Every write-back merges with the file, so a
|
|
2941
|
-
// slot another collect has moved further along is never overwritten.
|
|
2942
|
-
const owned = new Set();
|
|
2943
|
-
const persist = () => persistBroadsideRunMerging(broadsideDir, run);
|
|
2944
|
-
const aborted = () => opts.signal?.aborted === true;
|
|
2945
|
-
const deadline = Date.now() + (opts.waitMs ?? BROADSIDE_DEFAULT_POLL_BUDGET_MS);
|
|
2946
|
-
let totalCost = 0;
|
|
2947
|
-
let resultCount = 0;
|
|
2948
|
-
let truncatedCount = 0;
|
|
2949
|
-
const lensOutcomes = {};
|
|
2950
|
-
const allLensResults = [];
|
|
2951
|
-
// Terminal entries are settled already; everything else polls in parallel
|
|
2952
|
-
// against one shared deadline (#136), then results save in lens order so
|
|
2953
|
-
// output layout stays deterministic.
|
|
2954
|
-
const inFlight = [];
|
|
2955
|
-
for (const lensId of run.lenses) {
|
|
2956
|
-
const entry = run.batches[lensId];
|
|
2957
|
-
if (!entry || !entry.batchId) {
|
|
2958
|
-
lensOutcomes[lensId] = { status: entry?.status ?? "failed", resultCount: 0 };
|
|
2959
|
-
continue;
|
|
2960
|
-
}
|
|
2961
|
-
if (BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status)) {
|
|
2962
|
-
totalCost += entry.cost ?? 0;
|
|
2963
|
-
resultCount += entry.resultCount ?? 0;
|
|
2964
|
-
lensOutcomes[lensId] = { status: entry.status, cost: entry.cost, resultCount: entry.resultCount };
|
|
2965
|
-
continue;
|
|
2966
|
-
}
|
|
2967
|
-
inFlight.push({ lensId, batchId: entry.batchId });
|
|
2968
|
-
}
|
|
2969
|
-
const polled = await pollBatchesConcurrently(inFlight, apiKey, {
|
|
2970
|
-
deadlineMs: Math.max(0, deadline - Date.now()),
|
|
2971
|
-
fetcher: opts.fetcher,
|
|
2972
|
-
pollIntervalMs: opts.pollIntervalMs,
|
|
2973
|
-
signal: opts.signal,
|
|
2974
|
-
onStatus: opts.onStatus,
|
|
2975
|
-
});
|
|
2976
|
-
for (const { lensId } of inFlight) {
|
|
2977
|
-
const entry = run.batches[lensId];
|
|
2978
|
-
if (!entry)
|
|
2979
|
-
continue;
|
|
2980
|
-
const batch = polled.get(entry.batchId) ?? { id: entry.batchId, status: "timeout" };
|
|
2981
|
-
const status = String(batch.status ?? "unknown");
|
|
2982
|
-
entry.status = status;
|
|
2983
|
-
if (status === "completed") {
|
|
2984
|
-
const usage = (batch.usage ?? {});
|
|
2985
|
-
const cost = typeof usage.cost === "number" ? usage.cost : undefined;
|
|
2986
|
-
entry.cost = cost;
|
|
2987
|
-
entry.completedAt = new Date().toISOString();
|
|
2988
|
-
const stored = await saveLensResults(runDir, lensId, batch);
|
|
2989
|
-
entry.resultCount = stored.length;
|
|
2990
|
-
const truncated = stored.filter((s) => s.truncated).length;
|
|
2991
|
-
allLensResults.push(...stored);
|
|
2992
|
-
resultCount += stored.length;
|
|
2993
|
-
truncatedCount += truncated;
|
|
2994
|
-
totalCost += cost ?? 0;
|
|
2995
|
-
await writeFile(join(runDir, `raw-${lensId}.json`), `${JSON.stringify(batch, null, "\t")}\n`, "utf8");
|
|
2996
|
-
// A batch can complete with every request failed — the account's
|
|
2997
|
-
// concurrent-job quota filling after acceptance does exactly this.
|
|
2998
|
-
// The per-request errors are on disk as `<id>.error.json`, but a
|
|
2999
|
-
// lens reporting "completed, 0 result(s)" with the reason buried
|
|
3000
|
-
// there read as an empty repository rather than a refused run.
|
|
3001
|
-
const results = Array.isArray(batch.results) ? batch.results : [];
|
|
3002
|
-
const failed = results.filter((r) => r.error && extractContent(r) === null);
|
|
3003
|
-
const allFailed = stored.length === 0 && failed.length > 0
|
|
3004
|
-
? `all ${failed.length} request(s) failed: ${explainBatchError(failed[0].error)}`
|
|
3005
|
-
: null;
|
|
3006
|
-
if (allFailed)
|
|
3007
|
-
entry.error = allFailed;
|
|
3008
|
-
lensOutcomes[lensId] = { status, cost: entry.cost, resultCount: entry.resultCount, truncated, ...(allFailed && { error: allFailed }) };
|
|
3009
|
-
}
|
|
3010
|
-
else {
|
|
3011
|
-
// Every non-completed outcome still has to reach the report.
|
|
3012
|
-
// `lensOutcomes` is what the caller renders, and this branch used to
|
|
3013
|
-
// require `batch.error` — but the commonest failure here is the
|
|
3014
|
-
// synthetic `{ status: "timeout" }` the poll returns when its budget
|
|
3015
|
-
// expires with the batch still in flight, and that carries no error.
|
|
3016
|
-
// A lens that never came back was therefore omitted entirely,
|
|
3017
|
-
// indistinguishable in the output from one that was never requested.
|
|
3018
|
-
if (batch.error)
|
|
3019
|
-
entry.error = batch.error;
|
|
3020
|
-
const error = explainBatchError(batch.error);
|
|
3021
|
-
lensOutcomes[lensId] = { status, cost: entry.cost, resultCount: entry.resultCount, ...(error && { error }) };
|
|
3022
|
-
}
|
|
3023
|
-
await persist();
|
|
3024
|
-
}
|
|
3025
|
-
// #133: re-submit truncated slices once with a bumped output cap and low
|
|
3026
|
-
// reasoning effort. Batch requests are pure, so re-running is always safe;
|
|
3027
|
-
// the aim is to recover coverage the first pass lost to a max_tokens
|
|
3028
|
-
// cutoff, not to loop forever. Low effort because the cutoff is usually
|
|
3029
|
-
// thinking, and a doubled budget doubled the thinking where a token cap
|
|
3030
|
-
// was ignored (see retryReasoningFor).
|
|
3031
|
-
//
|
|
3032
|
-
// All bumped requests for one model go out as ONE batch, and the batches
|
|
3033
|
-
// (one per model, since a batch carries a single model) are polled
|
|
3034
|
-
// together against the shared deadline. Each truncated slice used to be
|
|
3035
|
-
// submitted and polled to terminal before the next was submitted, so a
|
|
3036
|
-
// model that truncated 11 of 13 slices turned a five-minute collect into
|
|
3037
|
-
// eleven sequential round trips — the serialization #136 removed from the
|
|
3038
|
-
// lens pass, still present here (#206). Grouping also keeps the retry to
|
|
3039
|
-
// one job per model against OpenRouter's 16-concurrent-job quota.
|
|
3040
|
-
let retriedCount = 0;
|
|
3041
|
-
let retryElsewhere = false;
|
|
3042
|
-
// A collect that polled nothing — every lens already terminal — still owes
|
|
3043
|
-
// the retry if the collect that saved the results never got to it (it
|
|
3044
|
-
// died, or its client did: #322). Read the saved results back and let the
|
|
3045
|
-
// claim decide; a recovered slice re-parses clean, so this costs nothing
|
|
3046
|
-
// once the retry has run.
|
|
3047
|
-
if (opts.retryTruncated !== false && allLensResults.length === 0 && !aborted()) {
|
|
3048
|
-
const everyLensTerminal = run.lenses.every((lensId) => {
|
|
3049
|
-
const entry = run.batches[lensId];
|
|
3050
|
-
return entry && BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status);
|
|
3051
|
-
});
|
|
3052
|
-
if (everyLensTerminal) {
|
|
3053
|
-
const restored = await loadSavedLensResults(runDir, run.lenses);
|
|
3054
|
-
if (restored.some((s) => s.truncated)) {
|
|
3055
|
-
allLensResults.push(...restored);
|
|
3056
|
-
truncatedCount = restored.filter((s) => s.truncated).length;
|
|
3057
|
-
}
|
|
3058
|
-
}
|
|
3059
|
-
}
|
|
3060
|
-
if (opts.retryTruncated !== false && truncatedCount > 0 && !aborted()) {
|
|
3061
|
-
// Claim the pass before spending: a second collect on this run finds the
|
|
3062
|
-
// claim and leaves the retry to the first (#322). A retry another
|
|
3063
|
-
// collect has already settled is not run again — its truncation is
|
|
3064
|
-
// what it is.
|
|
3065
|
-
if (await claimRunSlot(broadsideDir, run, "retry"))
|
|
3066
|
-
owned.add("retry");
|
|
3067
|
-
else if (run.retry?.status === "submitted")
|
|
3068
|
-
retryElsewhere = true;
|
|
3069
|
-
}
|
|
3070
|
-
if (opts.retryTruncated !== false && truncatedCount > 0 && owned.has("retry")) {
|
|
3071
|
-
const requestsByCustomId = await loadStoredRequests(runDir);
|
|
3072
|
-
const byModel = new Map();
|
|
3073
|
-
for (const stored of allLensResults) {
|
|
3074
|
-
if (!stored.truncated)
|
|
3075
|
-
continue;
|
|
3076
|
-
const original = requestsByCustomId[stored.customId];
|
|
3077
|
-
if (!original)
|
|
3078
|
-
continue;
|
|
3079
|
-
const lensEntry = run.batches[stored.lensId];
|
|
3080
|
-
// A lens may have run on its own model (config `lens_models`), with its
|
|
3081
|
-
// own completion ceiling. Re-submitting against the run default would
|
|
3082
|
-
// change the model mid-run and could exceed that lens's real ceiling.
|
|
3083
|
-
const lensModel = lensEntry?.model ?? run.model;
|
|
3084
|
-
const lensCap = lensEntry?.outputCap ?? run.outputCap;
|
|
3085
|
-
const previousMax = original.body.max_tokens ?? getLens(stored.lensId).maxTokens;
|
|
3086
|
-
const bumpedMax = lensCap ? Math.min(previousMax * 2, lensCap) : previousMax * 2;
|
|
3087
|
-
if (bumpedMax <= previousMax)
|
|
3088
|
-
continue; // already at the ceiling
|
|
3089
|
-
const group = byModel.get(lensModel) ?? { requests: [], slices: new Map() };
|
|
3090
|
-
group.requests.push({
|
|
3091
|
-
...original,
|
|
3092
|
-
body: { ...original.body, max_tokens: bumpedMax, reasoning: retryReasoningFor(original.body.reasoning) },
|
|
3093
|
-
});
|
|
3094
|
-
group.slices.set(stored.customId, stored);
|
|
3095
|
-
byModel.set(lensModel, group);
|
|
3096
|
-
}
|
|
3097
|
-
// Submit every group, then poll whatever was accepted, together.
|
|
3098
|
-
const submitted = [];
|
|
3099
|
-
for (const [model, group] of byModel) {
|
|
3100
|
-
if (aborted())
|
|
3101
|
-
break;
|
|
3102
|
-
try {
|
|
3103
|
-
const { batchId, error } = await submitBatch(group.requests, apiKey, opts.fetcher, model);
|
|
3104
|
-
if (!error && batchId)
|
|
3105
|
-
submitted.push({ model, batchId });
|
|
3106
|
-
}
|
|
3107
|
-
catch {
|
|
3108
|
-
// A retry batch that fails to submit leaves its slices' original
|
|
3109
|
-
// truncated results in place — nothing is lost.
|
|
3110
|
-
}
|
|
3111
|
-
}
|
|
3112
|
-
// Record the ids under the claim so a later collect can see what was
|
|
3113
|
-
// paid for, even if this one never returns. No group at all means every
|
|
3114
|
-
// truncated slice was already at its model's ceiling: nothing to retry.
|
|
3115
|
-
run.retry = {
|
|
3116
|
-
...run.retry,
|
|
3117
|
-
batches: submitted,
|
|
3118
|
-
status: submitted.length > 0 ? "submitted" : byModel.size === 0 ? "completed" : "failed",
|
|
3119
|
-
};
|
|
3120
|
-
await persist();
|
|
3121
|
-
const polled = await pollBatchesConcurrently(submitted.map(({ model, batchId }) => ({ lensId: `retry:${model}`, batchId })), apiKey, {
|
|
3122
|
-
// Share the caller's deadline. Each of these polls used to start a
|
|
3123
|
-
// fresh 25-minute budget, so `wait_seconds` bounded only the lens
|
|
3124
|
-
// poll and a collect could run for the caller's budget plus fifty
|
|
3125
|
-
// minutes.
|
|
3126
|
-
deadlineMs: Math.max(0, deadline - Date.now()),
|
|
3127
|
-
fetcher: opts.fetcher,
|
|
3128
|
-
pollIntervalMs: opts.pollIntervalMs,
|
|
3129
|
-
signal: opts.signal,
|
|
3130
|
-
onStatus: opts.onStatus,
|
|
3131
|
-
});
|
|
3132
|
-
for (const { model, batchId } of submitted) {
|
|
3133
|
-
const batch = polled.get(batchId);
|
|
3134
|
-
if (!batch || batch.status !== "completed")
|
|
3135
|
-
continue;
|
|
3136
|
-
const group = byModel.get(model);
|
|
3137
|
-
const usage = (batch.usage ?? {});
|
|
3138
|
-
// Kept on the entry, not just added to this collect's running total:
|
|
3139
|
-
// a later collect on the run used to report a total without it.
|
|
3140
|
-
if (typeof usage.cost === "number")
|
|
3141
|
-
run.retry = { ...run.retry, cost: (run.retry?.cost ?? 0) + usage.cost };
|
|
3142
|
-
const results = Array.isArray(batch.results) ? batch.results : [];
|
|
3143
|
-
for (const result of results) {
|
|
3144
|
-
const stored = group.slices.get(String(result.custom_id ?? ""));
|
|
3145
|
-
if (!stored)
|
|
3146
|
-
continue;
|
|
3147
|
-
const content = extractContent(result);
|
|
3148
|
-
if (content === null)
|
|
3149
|
-
continue;
|
|
3150
|
-
const parsed = parseLensJson(content);
|
|
3151
|
-
if (parsed === null)
|
|
3152
|
-
continue; // still no good
|
|
3153
|
-
await writeFile(join(runDir, `${sanitizeId(stored.customId)}.json`), `${JSON.stringify(parsed, null, "\t")}\n`, "utf8");
|
|
3154
|
-
await writeFile(join(runDir, `${sanitizeId(stored.customId)}.md`), renderFindingsMarkdown(content), "utf8");
|
|
3155
|
-
stored.content = content;
|
|
3156
|
-
stored.truncated = false;
|
|
3157
|
-
retriedCount += 1;
|
|
3158
|
-
}
|
|
3159
|
-
}
|
|
3160
|
-
// Every retry batch reached a terminal status, or the poll ran out.
|
|
3161
|
-
if (submitted.length > 0 && submitted.every(({ batchId }) => polled.get(batchId)?.status === "completed")) {
|
|
3162
|
-
run.retry = { ...run.retry, status: "completed" };
|
|
3163
|
-
}
|
|
3164
|
-
truncatedCount = allLensResults.filter((s) => s.truncated).length;
|
|
3165
|
-
for (const [lensId, outcome] of Object.entries(lensOutcomes)) {
|
|
3166
|
-
if (outcome.truncated !== undefined) {
|
|
3167
|
-
outcome.truncated = allLensResults.filter((s) => s.lensId === lensId && s.truncated).length;
|
|
3168
|
-
}
|
|
3169
|
-
}
|
|
3170
|
-
await persist();
|
|
3171
|
-
}
|
|
3172
|
-
// Synthesis + triage: cross-lens post-passes, only after every lens batch
|
|
3173
|
-
// is terminal. Triage turns the leads into a prioritized work order.
|
|
3174
|
-
run.triage ??= { status: "pending" };
|
|
3175
|
-
let topFindings = [];
|
|
3176
|
-
let topTriageItems = [];
|
|
3177
|
-
const wantSynthesis = opts.includeSynthesis !== false;
|
|
3178
|
-
const wantTriage = opts.includeTriage !== false;
|
|
3179
|
-
// A regenerate resets the wanted, settled passes to pending on disk first —
|
|
3180
|
-
// the merging persist keeps whatever is further along on disk, so an
|
|
3181
|
-
// in-memory reset alone would be undone by the next persist (#338).
|
|
3182
|
-
let regenerated = [];
|
|
3183
|
-
if (opts.regeneratePostPasses) {
|
|
3184
|
-
if (!wantSynthesis && !wantTriage) {
|
|
3185
|
-
throw new Error("Nothing to regenerate: both post-passes are disabled for this collect.");
|
|
3186
|
-
}
|
|
3187
|
-
const lensesSettled = run.lenses.every((lensId) => {
|
|
3188
|
-
const entry = run.batches[lensId];
|
|
3189
|
-
return entry && BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status);
|
|
3190
|
-
});
|
|
3191
|
-
if (!lensesSettled) {
|
|
3192
|
-
throw new Error(`Cannot regenerate the post-passes of run ${run.id}: its lens batches are still running — collect them first.`);
|
|
3193
|
-
}
|
|
3194
|
-
regenerated = await resetRunPostPasses(broadsideDir, run, { synthesis: wantSynthesis, triage: wantTriage });
|
|
3195
|
-
}
|
|
3196
|
-
// A resumed collect polls nothing — every lens is already terminal — so the
|
|
3197
|
-
// findings the post-passes need have to come back off disk, or a run whose
|
|
3198
|
-
// first collect was interrupted could never produce its executive report
|
|
3199
|
-
// and work order, however many times it was re-run.
|
|
3200
|
-
const postPassUnfinished = (entry) => entry.status === "pending" || entry.status === "submitted";
|
|
3201
|
-
if ((wantSynthesis || wantTriage) && allLensResults.length === 0
|
|
3202
|
-
&& (postPassUnfinished(run.synthesis) || postPassUnfinished(run.triage))) {
|
|
3203
|
-
const restored = await loadSavedLensResults(runDir, run.lenses);
|
|
3204
|
-
if (restored.length > 0) {
|
|
3205
|
-
allLensResults.push(...restored);
|
|
3206
|
-
truncatedCount = restored.filter((s) => s.truncated).length;
|
|
3207
|
-
}
|
|
3208
|
-
}
|
|
3209
|
-
if ((wantSynthesis || wantTriage) && allLensResults.length > 0) {
|
|
3210
|
-
const allTerminal = run.lenses.every((lensId) => {
|
|
3211
|
-
const entry = run.batches[lensId];
|
|
3212
|
-
return entry && BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status);
|
|
3213
|
-
});
|
|
3214
|
-
if (allTerminal && (postPassUnfinished(run.synthesis) || postPassUnfinished(run.triage))) {
|
|
3215
|
-
const findingsText = allLensResults
|
|
3216
|
-
.map((r) => `## ${r.lensId} — ${r.customId}\n\n${r.content}\n`)
|
|
3217
|
-
.join("\n");
|
|
3218
|
-
// A verify pass that ran before this point leaves its verdicts in the
|
|
3219
|
-
// run directory; the post-passes rank on them when present (#338).
|
|
3220
|
-
const verdicts = await loadPostPassVerdicts(runDir);
|
|
3221
|
-
const truncatedNote = truncatedCount > 0
|
|
3222
|
-
? `\n\nNOTE: ${truncatedCount} lens result(s) were truncated at the output token limit and are ` +
|
|
3223
|
-
"not included above. Any gap they would have covered is unrepresented — do not treat " +
|
|
3224
|
-
"silence on a module as a clean bill.\n"
|
|
3225
|
-
: "";
|
|
3226
|
-
// Both post-passes consume the same findings; they run as two
|
|
3227
|
-
// batches (different response_format schemas cannot share one)
|
|
3228
|
-
// submitted together and polled in turn.
|
|
3229
|
-
// Claim each wanted, still-pending pass before building its request:
|
|
3230
|
-
// a second collect on this run adopts the first one's entry instead
|
|
3231
|
-
// of submitting its own (#322). An abort submits nothing further.
|
|
3232
|
-
const passes = [];
|
|
3233
|
-
for (const kind of ["synthesis", "triage"]) {
|
|
3234
|
-
const want = kind === "synthesis" ? wantSynthesis : wantTriage;
|
|
3235
|
-
if (!want || aborted())
|
|
3236
|
-
continue;
|
|
3237
|
-
if ((kind === "synthesis" ? run.synthesis : run.triage).status !== "pending")
|
|
3238
|
-
continue;
|
|
3239
|
-
if (!(await claimRunSlot(broadsideDir, run, kind)))
|
|
3240
|
-
continue;
|
|
3241
|
-
owned.add(kind);
|
|
3242
|
-
const entry = kind === "synthesis" ? run.synthesis : run.triage;
|
|
3243
|
-
if (verdicts)
|
|
3244
|
-
entry.verdicts = verdicts.length;
|
|
3245
|
-
else
|
|
3246
|
-
delete entry.verdicts;
|
|
3247
|
-
passes.push({
|
|
3248
|
-
kind,
|
|
3249
|
-
request: kind === "synthesis"
|
|
3250
|
-
? buildSynthesisRequest(findingsText, truncatedNote, run.model, verdicts)
|
|
3251
|
-
: buildTriageRequest(findingsText, truncatedNote, run.model, verdicts),
|
|
3252
|
-
entry,
|
|
3253
|
-
});
|
|
3254
|
-
}
|
|
3255
|
-
const submitted = new Map();
|
|
3256
|
-
// A pass can be left at "submitted" when an earlier collect returned
|
|
3257
|
-
// before its batch reached a terminal status — the batch still runs
|
|
3258
|
-
// and is still charged, so the result exists and is simply unclaimed.
|
|
3259
|
-
// Nothing above would ever look at it again: the pass list is built
|
|
3260
|
-
// from "pending" entries only. Poll those regardless of the want
|
|
3261
|
-
// flags, because the spend already happened and discarding a
|
|
3262
|
-
// finished result is worse than saving one the caller opted out of.
|
|
3263
|
-
for (const kind of ["synthesis", "triage"]) {
|
|
3264
|
-
const entry = kind === "synthesis" ? run.synthesis : run.triage;
|
|
3265
|
-
if (entry.status !== "submitted" || !entry.batchId)
|
|
3266
|
-
continue;
|
|
3267
|
-
if (submitted.has(entry.batchId))
|
|
3268
|
-
continue;
|
|
3269
|
-
submitted.set(entry.batchId, {
|
|
3270
|
-
batchId: entry.batchId,
|
|
3271
|
-
pass: { kind, request: undefined, entry },
|
|
3272
|
-
});
|
|
3273
|
-
}
|
|
3274
|
-
await Promise.allSettled(passes.map(async (pass) => {
|
|
3275
|
-
pass.entry.status = "submitted";
|
|
3276
|
-
try {
|
|
3277
|
-
const { batchId, error } = await submitBatch([pass.request], apiKey, opts.fetcher, run.model);
|
|
3278
|
-
if (error) {
|
|
3279
|
-
pass.entry.status = "failed";
|
|
3280
|
-
return;
|
|
3281
|
-
}
|
|
3282
|
-
pass.entry.batchId = batchId;
|
|
3283
|
-
submitted.set(batchId, { batchId, pass });
|
|
3284
|
-
}
|
|
3285
|
-
catch {
|
|
3286
|
-
pass.entry.status = "failed";
|
|
3287
|
-
}
|
|
3288
|
-
}));
|
|
3289
|
-
await persist();
|
|
3290
|
-
// Poll both passes together against the shared deadline. Polled in
|
|
3291
|
-
// turn, the first pass could spend the whole budget and leave the
|
|
3292
|
-
// second a single poll (0.22.1 live run: triage settled, synthesis
|
|
3293
|
-
// left running though it had been submitted at the same moment).
|
|
3294
|
-
// A pass whose poll runs out stays `submitted`, so the batch is
|
|
3295
|
-
// already paid for and a later collect claims its result.
|
|
3296
|
-
const polledPasses = await pollBatchesConcurrently([...submitted.values()].map(({ batchId, pass }) => ({ lensId: pass.kind, batchId })), apiKey, {
|
|
3297
|
-
deadlineMs: Math.max(0, deadline - Date.now()),
|
|
3298
|
-
fetcher: opts.fetcher,
|
|
3299
|
-
pollIntervalMs: opts.pollIntervalMs,
|
|
3300
|
-
signal: opts.signal,
|
|
3301
|
-
onStatus: opts.onStatus,
|
|
3302
|
-
});
|
|
3303
|
-
for (const { batchId, pass } of submitted.values()) {
|
|
3304
|
-
const batch = polledPasses.get(batchId) ?? { id: batchId, status: "timeout" };
|
|
3305
|
-
if (batch.status === "completed") {
|
|
3306
|
-
const usage = (batch.usage ?? {});
|
|
3307
|
-
const cost = typeof usage.cost === "number" ? usage.cost : undefined;
|
|
3308
|
-
pass.entry.status = "completed";
|
|
3309
|
-
pass.entry.cost = cost;
|
|
3310
|
-
const results = Array.isArray(batch.results) ? batch.results : [];
|
|
3311
|
-
const content = results.length > 0 ? extractContent(results[0]) : null;
|
|
3312
|
-
if (content !== null) {
|
|
3313
|
-
await writeFile(join(runDir, `${pass.kind}.json`), `${content}\n`, "utf8");
|
|
3314
|
-
await writeFile(join(runDir, `${pass.kind}.md`), renderFindingsMarkdown(content), "utf8");
|
|
3315
|
-
if (pass.kind === "synthesis") {
|
|
3316
|
-
topFindings = parseSynthesisTopFindings(content);
|
|
3317
|
-
}
|
|
3318
|
-
else {
|
|
3319
|
-
topTriageItems = parseTriageItems(content);
|
|
3320
|
-
}
|
|
3321
|
-
}
|
|
3322
|
-
}
|
|
3323
|
-
else if (BROADSIDE_DEAD_BATCH_STATUSES.includes(String(batch.status))) {
|
|
3324
|
-
// The batch will never produce a result, so retire the pass.
|
|
3325
|
-
// This used to require `batch.error`, leaving an expired or
|
|
3326
|
-
// cancelled batch parked at "submitted" forever — and since a
|
|
3327
|
-
// resumed collect re-polls anything still "submitted", it
|
|
3328
|
-
// would re-poll a dead batch on every future run.
|
|
3329
|
-
pass.entry.status = "failed";
|
|
3330
|
-
if (batch.error)
|
|
3331
|
-
pass.entry.error = batch.error instanceof Error ? batch.error.message : String(batch.error);
|
|
3332
|
-
}
|
|
3333
|
-
// A "timeout" is deliberately left at "submitted": the batch is
|
|
3334
|
-
// still running server-side and has already been paid for, so a
|
|
3335
|
-
// later collect should claim its result rather than discard it.
|
|
3336
|
-
await persist();
|
|
3337
|
-
}
|
|
3338
|
-
}
|
|
3339
|
-
}
|
|
3340
|
-
const terminal = run.lenses.every((lensId) => {
|
|
3341
|
-
const entry = run.batches[lensId];
|
|
3342
|
-
return entry && BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status);
|
|
3343
|
-
});
|
|
3344
|
-
run.status = terminal ? (resultCount > 0 ? "completed" : "failed") : "partial";
|
|
3345
|
-
// The run's total is the sum of what its entries record, not of what this
|
|
3346
|
-
// collect happened to poll: a repeat collect used to report — and persist
|
|
3347
|
-
// — a total without the post-passes and the retry an earlier collect had
|
|
3348
|
-
// settled, so the recorded cost of a run went down each time it was read.
|
|
3349
|
-
totalCost += (run.retry?.cost ?? 0) + (run.synthesis.cost ?? 0) + (run.triage.cost ?? 0) + (run.retiredCost ?? 0);
|
|
3350
|
-
run.totalCost = totalCost;
|
|
3351
|
-
await persist();
|
|
3352
|
-
await writeFile(join(runDir, "run-meta.json"), `${JSON.stringify({
|
|
3353
|
-
experimental: true,
|
|
3354
|
-
method: "Broad-Side (OpenRouter Batch API)",
|
|
3355
|
-
model: run.model,
|
|
3356
|
-
pricing: run.pricing,
|
|
3357
|
-
max_cost: run.maxCost,
|
|
3358
|
-
run_id: run.id,
|
|
3359
|
-
created_at: run.createdAt,
|
|
3360
|
-
status: run.status,
|
|
3361
|
-
total_cost: totalCost,
|
|
3362
|
-
result_count: resultCount,
|
|
3363
|
-
truncated_count: truncatedCount,
|
|
3364
|
-
retried_count: retriedCount,
|
|
3365
|
-
synthesis: run.synthesis,
|
|
3366
|
-
triage: run.triage,
|
|
3367
|
-
lenses: run.lenses,
|
|
3368
|
-
// Which lens ran on which model. Absent means the run default —
|
|
3369
|
-
// a reader comparing two runs needs to know a lens changed model.
|
|
3370
|
-
lens_models: Object.fromEntries(Object.entries(run.batches)
|
|
3371
|
-
.filter(([, batch]) => batch?.model)
|
|
3372
|
-
.map(([lensId, batch]) => [lensId, batch.model])),
|
|
3373
|
-
disclaimer: "Findings are unverified scouting signals from a batch model, not validated claims. " +
|
|
3374
|
-
"Re-verify every file:line lead with the interactive pipeline or by hand.",
|
|
3375
|
-
}, null, "\t")}\n`, "utf8");
|
|
3376
|
-
return {
|
|
3377
|
-
runId: run.id,
|
|
3378
|
-
status: run.status,
|
|
3379
|
-
totalCost,
|
|
3380
|
-
resultCount,
|
|
3381
|
-
truncatedCount,
|
|
3382
|
-
retriedCount,
|
|
3383
|
-
...(retryElsewhere && { retryElsewhere: true }),
|
|
3384
|
-
lensOutcomes,
|
|
3385
|
-
synthesis: run.synthesis,
|
|
3386
|
-
triage: run.triage,
|
|
3387
|
-
topFindings,
|
|
3388
|
-
topTriageItems,
|
|
3389
|
-
...(regenerated.length > 0 && { regenerated }),
|
|
3390
|
-
};
|
|
3391
|
-
}
|
|
3392
|
-
export async function runBroadsideStatus(cwd) {
|
|
3393
|
-
const broadsideDir = broadsideDirFor(cwd);
|
|
3394
|
-
const state = await loadBroadsideState(broadsideDir);
|
|
3395
|
-
return { state };
|
|
3396
|
-
}
|
|
3397
|
-
// ---------- rendering ----------
|
|
3398
|
-
export function renderFindingsMarkdown(content) {
|
|
3399
|
-
const parsed = parseLensJson(content);
|
|
3400
|
-
if (parsed === null)
|
|
3401
|
-
return content;
|
|
3402
|
-
return formatAsMarkdown(parsed);
|
|
3403
|
-
}
|
|
3404
|
-
function formatAsMarkdown(value, depth = 0) {
|
|
3405
|
-
const indent = "\t".repeat(depth);
|
|
3406
|
-
if (Array.isArray(value)) {
|
|
3407
|
-
const lines = [];
|
|
3408
|
-
for (let i = 0; i < value.length; i++) {
|
|
3409
|
-
const item = value[i];
|
|
3410
|
-
if (item && typeof item === "object") {
|
|
3411
|
-
const title = (item.title ?? item.name ?? item.module ?? item.area ?? item.platform ?? "");
|
|
3412
|
-
lines.push(`${indent}${i + 1}. ${title}`);
|
|
3413
|
-
lines.push(formatAsMarkdown(item, depth + 1));
|
|
3414
|
-
}
|
|
3415
|
-
else {
|
|
3416
|
-
lines.push(`${indent}- ${String(item)}`);
|
|
3417
|
-
}
|
|
3418
|
-
}
|
|
3419
|
-
return lines.join("\n");
|
|
3420
|
-
}
|
|
3421
|
-
if (value && typeof value === "object") {
|
|
3422
|
-
const lines = [];
|
|
3423
|
-
for (const [key, entryValue] of Object.entries(value)) {
|
|
3424
|
-
if (entryValue && typeof entryValue === "object") {
|
|
3425
|
-
lines.push(`${indent}**${key}**:`);
|
|
3426
|
-
lines.push(formatAsMarkdown(entryValue, depth + 1));
|
|
3427
|
-
}
|
|
3428
|
-
else {
|
|
3429
|
-
lines.push(`${indent}- **${key}**: ${String(entryValue)}`);
|
|
3430
|
-
}
|
|
3431
|
-
}
|
|
3432
|
-
return lines.join("\n");
|
|
3433
|
-
}
|
|
3434
|
-
return `${indent}${String(value)}`;
|
|
3435
|
-
}
|
|
3436
|
-
function parseSynthesisTopFindings(content) {
|
|
3437
|
-
try {
|
|
3438
|
-
const parsed = JSON.parse(content);
|
|
3439
|
-
const findings = Array.isArray(parsed.top_findings)
|
|
3440
|
-
? parsed.top_findings
|
|
3441
|
-
: [];
|
|
3442
|
-
return findings
|
|
3443
|
-
.filter((f) => typeof f.title === "string")
|
|
3444
|
-
.map((f) => ({
|
|
3445
|
-
title: String(f.title),
|
|
3446
|
-
severity: String(f.severity ?? "unknown"),
|
|
3447
|
-
sourceLens: String(f.source_lens ?? "unknown"),
|
|
3448
|
-
summary: String(f.summary ?? ""),
|
|
3449
|
-
}));
|
|
3450
|
-
}
|
|
3451
|
-
catch {
|
|
3452
|
-
return [];
|
|
3453
|
-
}
|
|
3454
|
-
}
|
|
3455
|
-
// ---------- formatting helpers for tool output ----------
|
|
3456
|
-
export function describeIncrementalFallback(reason) {
|
|
3457
|
-
switch (reason) {
|
|
3458
|
-
case "dirty-worktree":
|
|
3459
|
-
return "the working tree has uncommitted changes, so there is no committed state to diff against";
|
|
3460
|
-
case "no-baseline":
|
|
3461
|
-
return "no earlier run recorded a commit to diff against";
|
|
3462
|
-
case "diff-failed":
|
|
3463
|
-
return "the diff against the previous run's commit could not be read";
|
|
3464
|
-
default:
|
|
3465
|
-
return "no baseline was available";
|
|
3466
|
-
}
|
|
3467
|
-
}
|
|
3468
|
-
export function estimateSubmitText(result, lenses) {
|
|
3469
|
-
// Count the lenses that actually got a batch, not every lens considered. A
|
|
3470
|
-
// lens with nothing to scan is reported as `skipped (0 request(s))` two
|
|
3471
|
-
// lines below, so counting it here made the header contradict its own body:
|
|
3472
|
-
// a Rust CLI with no server surface reported "submitted 6 batch(es)" over a
|
|
3473
|
-
// list showing four batches and two skips.
|
|
3474
|
-
const entries = Object.values(result.batches ?? {});
|
|
3475
|
-
const submittedCount = entries.filter((entry) => entry.batchId).length;
|
|
3476
|
-
const withoutBatch = entries.length - submittedCount;
|
|
3477
|
-
const lines = [
|
|
3478
|
-
withoutBatch > 0
|
|
3479
|
-
? `Broad-Side submitted ${submittedCount} batch(es); ${withoutBatch} lens(es) produced none (see below).`
|
|
3480
|
-
: `Broad-Side submitted ${submittedCount} batch(es).`,
|
|
3481
|
-
];
|
|
3482
|
-
for (const lens of lenses) {
|
|
3483
|
-
const entry = result.batches[lens.id];
|
|
3484
|
-
if (!entry)
|
|
3485
|
-
continue;
|
|
3486
|
-
const status = entry.batchId ? `batch ${entry.batchId}` : entry.status;
|
|
3487
|
-
const override = entry.model ? ` on ${entry.model}` : "";
|
|
3488
|
-
// A rejected lens says why: the message is the only way to tell a
|
|
3489
|
-
// catalog id with no batch endpoint from a full job quota, and both
|
|
3490
|
-
// used to read as a bare "rejected". A skipped lens names the globs
|
|
3491
|
-
// that matched nothing.
|
|
3492
|
-
const reason = !entry.batchId && entry.error
|
|
3493
|
-
? ` — ${explainBatchError(entry.error)}`
|
|
3494
|
-
: !entry.batchId && entry.reason
|
|
3495
|
-
? ` — ${entry.reason}`
|
|
3496
|
-
: entry.fallback
|
|
3497
|
-
? ` — ${entry.fallback}`
|
|
3498
|
-
: "";
|
|
3499
|
-
lines.push(` ${lens.name}: ${status} (${entry.requests} request(s), ~$${entry.estimatedCost.toFixed(4)})${override}${reason}`);
|
|
3500
|
-
}
|
|
3501
|
-
if (result.repo) {
|
|
3502
|
-
const head = result.repo.sourceHead ? ` at ${result.repo.sourceHead.slice(0, 8)}${result.repo.sourceDirty ? " (dirty)" : ""}` : "";
|
|
3503
|
-
const source = result.repo.snapshot === "working-tree" ? `working tree${head}` : "directory walk (not a git repository)";
|
|
3504
|
-
lines.push(`Scanned as ${result.repo.language}: ${result.repo.sourceFiles} source file(s) from the ${source}.`);
|
|
3505
|
-
}
|
|
3506
|
-
if (result.redaction) {
|
|
3507
|
-
const line = result.redaction.enabled
|
|
3508
|
-
? describeRedactions(result.redaction.values, result.redaction.files, result.redaction.skippedFiles)
|
|
3509
|
-
: "Before upload: secret redaction is OFF (redact_secrets: false in config.yaml); files were sent as they are.";
|
|
3510
|
-
if (line)
|
|
3511
|
-
lines.push(line);
|
|
3512
|
-
}
|
|
3513
|
-
const incremental = result.incremental;
|
|
3514
|
-
if (incremental?.requested) {
|
|
3515
|
-
lines.push(incremental.applied
|
|
3516
|
-
? `Incremental: scanning only what changed since ${(incremental.baseHead ?? "").slice(0, 8)}.`
|
|
3517
|
-
: `Incremental: requested but NOT applied — ${describeIncrementalFallback(incremental.reason)}. Every module was scanned, at full cost.`);
|
|
3518
|
-
}
|
|
3519
|
-
lines.push(`Estimated total: ~$${result.estimatedTotalCost.toFixed(4)}`, `Pricing: $${result.pricing.inputPerM.toFixed(4)}/M in, $${result.pricing.outputPerM.toFixed(4)}/M out (${result.pricing.source})`);
|
|
3520
|
-
if (result.modelInfo.contextLength) {
|
|
3521
|
-
lines.push(`Model: ${result.modelInfo.contextLength.toLocaleString()} context, ${result.modelInfo.maxCompletionTokens?.toLocaleString() ?? "?"} max output`);
|
|
3522
|
-
}
|
|
3523
|
-
if (result.modelInfo.supportsStructuredOutputs === false) {
|
|
3524
|
-
lines.push("Warning: model does not advertise structured-output support; lens JSON may be unreliable.");
|
|
3525
|
-
}
|
|
3526
|
-
if (result.modelInfo.expirationDate) {
|
|
3527
|
-
lines.push(`Warning: this model is deprecated (expires ${result.modelInfo.expirationDate}).`);
|
|
3528
|
-
}
|
|
3529
|
-
if (result.maxCost) {
|
|
3530
|
-
lines.push(`Run limit: $${result.maxCost.toFixed(2)} (enforced on estimate; pass force to override)`);
|
|
3531
|
-
}
|
|
3532
|
-
lines.push(`Results will land in ${result.outputDir}/`, "Call codecarto_broadside with action 'collect' once batches finish, or pass wait_seconds on submit to block.", "Disclaimer: Broad-Side findings are unverified scouting signals from a batch model, not validated claims.");
|
|
3533
|
-
return lines.join("\n");
|
|
3534
|
-
}
|
|
3535
|
-
export function modelsText(entries, opts) {
|
|
3536
|
-
const endpoints = opts.endpoints ?? {};
|
|
3537
|
-
const lines = [
|
|
3538
|
-
`Batch models on OpenRouter (${entries.length}, cheapest first).`,
|
|
3539
|
-
// The catalog over-reports: it returns a `:batch` id for models whose
|
|
3540
|
-
// Batch API refuses the job, with nothing in the entry to tell them
|
|
3541
|
-
// apart (#141). Say so before the table, not after it.
|
|
3542
|
-
"Advisory: this is the catalog's list of :batch ids, not a list of working batch endpoints. Some ids are refused at submit " +
|
|
3543
|
-
"(\"does not have a :batch endpoint\"), at no cost. Rows tagged [no batch endpoint …] or [batch OK …] carry what this " +
|
|
3544
|
-
"repository's own submits found; an untagged row has not been tried here.",
|
|
3545
|
-
"",
|
|
3546
|
-
"id | $/M in | $/M out | ctx | max out | structured | coding idx",
|
|
3547
|
-
];
|
|
3548
|
-
for (const entry of entries) {
|
|
3549
|
-
const bench = opts.benchmarks?.byBaseSlug[baseSlug(entry.id)];
|
|
3550
|
-
const structured = entry.supportedParameters.length === 0
|
|
3551
|
-
? "?"
|
|
3552
|
-
: entry.supportedParameters.some((p) => ["structured_outputs", "json_schema", "response_format", "structuredoutputs"].includes(p.toLowerCase()))
|
|
3553
|
-
? "yes"
|
|
3554
|
-
: "no";
|
|
3555
|
-
const coding = bench?.codingIndex !== undefined ? bench.codingIndex.toFixed(1) : "-";
|
|
3556
|
-
const ctx = entry.contextLength
|
|
3557
|
-
? entry.contextLength >= 1_000_000
|
|
3558
|
-
? `${(entry.contextLength / 1_000_000).toFixed(1)}M`
|
|
3559
|
-
: `${(entry.contextLength / 1024).toFixed(0)}k`
|
|
3560
|
-
: "?";
|
|
3561
|
-
const out = entry.maxCompletionTokens ? `${(entry.maxCompletionTokens / 1024).toFixed(0)}k` : "?";
|
|
3562
|
-
const tag = entry.id === opts.defaultModel ? " (default)" : "";
|
|
3563
|
-
const exp = entry.expirationDate ? " [deprecated]" : "";
|
|
3564
|
-
const record = endpoints[entry.id];
|
|
3565
|
-
const seen = record
|
|
3566
|
-
? record.status === "rejected"
|
|
3567
|
-
? ` [no batch endpoint, refused ${record.at.slice(0, 10)}]`
|
|
3568
|
-
: ` [batch OK ${record.at.slice(0, 10)}]`
|
|
3569
|
-
: "";
|
|
3570
|
-
lines.push(`${entry.id}${tag}${exp}${seen} | ${entry.inputPerM.toFixed(3)} | ${entry.outputPerM.toFixed(3)} | ${ctx} | ${out} | ${structured} | ${coding}`);
|
|
3571
|
-
}
|
|
3572
|
-
if (opts.benchmarks?.meta.as_of) {
|
|
3573
|
-
lines.push("", `Benchmarks: Artificial Analysis coding index (as of ${String(opts.benchmarks.meta.as_of)}).`);
|
|
3574
|
-
}
|
|
3575
|
-
lines.push("", "Choose with the model parameter (--model= on Pi) for one run, lens_models (--lens-model=LENS:ID) per lens, or the model key in " +
|
|
3576
|
-
".codecarto/broadside/config.yaml for the repository. Higher coding index ≠ better scout: precision, context, structured-output " +
|
|
3577
|
-
"support, and whether the model spends its output budget reasoning (see reasoning: in config.yaml) matter most here. " +
|
|
3578
|
-
"A refused submit costs nothing, so probe an untried model on one lens first.");
|
|
3579
|
-
return lines.join("\n");
|
|
3580
|
-
}
|
|
3581
|
-
/** One line of a batch's error field, whatever shape the provider gave it. */
|
|
3582
|
-
function describeBatchError(error) {
|
|
3583
|
-
if (error === undefined || error === null || error === "")
|
|
3584
|
-
return null;
|
|
3585
|
-
if (typeof error === "string")
|
|
3586
|
-
return error.slice(0, 300);
|
|
3587
|
-
if (typeof error === "object") {
|
|
3588
|
-
const message = error.message;
|
|
3589
|
-
if (typeof message === "string" && message)
|
|
3590
|
-
return message.slice(0, 300);
|
|
3591
|
-
// OpenRouter wraps a submit refusal as `{ error: { message } }`.
|
|
3592
|
-
const nested = error.error;
|
|
3593
|
-
if (nested && typeof nested === "object") {
|
|
3594
|
-
const inner = nested.message;
|
|
3595
|
-
if (typeof inner === "string" && inner)
|
|
3596
|
-
return inner.slice(0, 300);
|
|
3597
|
-
}
|
|
3598
|
-
if (typeof nested === "string" && nested)
|
|
3599
|
-
return nested.slice(0, 300);
|
|
3600
|
-
try {
|
|
3601
|
-
return JSON.stringify(error).slice(0, 300);
|
|
3602
|
-
}
|
|
3603
|
-
catch {
|
|
3604
|
-
return String(error);
|
|
3605
|
-
}
|
|
3606
|
-
}
|
|
3607
|
-
return String(error);
|
|
3608
|
-
}
|
|
3609
|
-
/**
|
|
3610
|
-
* A provider refusal plus what to do about it, for the two refusals a batch
|
|
3611
|
-
* run meets in practice and cannot fix by itself (#141):
|
|
3612
|
-
*
|
|
3613
|
-
* - `Model '<id>' does not have a :batch endpoint.` — the catalog advertises a
|
|
3614
|
-
* `:batch` id that OpenRouter runs no batch endpoint for. Nothing in the
|
|
3615
|
-
* catalog distinguishes these; the `models` action marks ids this
|
|
3616
|
-
* repository has seen refused.
|
|
3617
|
-
* - `job-submission-count … in use: 16, quota: 16` — the per-account limit
|
|
3618
|
-
* on concurrent batch jobs. Broad-Side submits one job per lens, so a few
|
|
3619
|
-
* runs in flight on the same key fill it; the refusal costs nothing.
|
|
3620
|
-
*/
|
|
3621
|
-
export function explainBatchError(error) {
|
|
3622
|
-
const message = describeBatchError(error);
|
|
3623
|
-
if (!message)
|
|
3624
|
-
return null;
|
|
3625
|
-
if (NO_BATCH_ENDPOINT_RE.test(message)) {
|
|
3626
|
-
return `${message} — the catalog lists this id, but OpenRouter runs no batch endpoint for it. Nothing was charged; pick another model (the models action marks ids this repository has seen refused).`;
|
|
3627
|
-
}
|
|
3628
|
-
if (BATCH_QUOTA_RE.test(message)) {
|
|
3629
|
-
return `${message} — OpenRouter's per-account limit on concurrent batch jobs is full. Broad-Side submits one job per lens, so a few runs in flight on this key (in any repository) fill it. Nothing was charged; collect or wait out the runs in flight, then re-submit.`;
|
|
3630
|
-
}
|
|
3631
|
-
return message;
|
|
3632
|
-
}
|
|
3633
|
-
export function collectResultText(result) {
|
|
3634
|
-
const lines = [
|
|
3635
|
-
`Broad-Side run ${result.runId}: ${result.status}`,
|
|
3636
|
-
` Results: ${result.resultCount} | Total cost: $${result.totalCost.toFixed(6)}`,
|
|
3637
|
-
];
|
|
3638
|
-
for (const lensId of BROADSIDE_LENS_IDS) {
|
|
3639
|
-
const outcome = result.lensOutcomes[lensId];
|
|
3640
|
-
if (!outcome)
|
|
3641
|
-
continue;
|
|
3642
|
-
const truncation = outcome.truncated ? `, ${outcome.truncated} truncated` : "";
|
|
3643
|
-
lines.push(` ${lensId}: ${outcome.status}` +
|
|
3644
|
-
(outcome.cost !== undefined ? `, $${outcome.cost.toFixed(6)}` : "") +
|
|
3645
|
-
(outcome.resultCount !== undefined ? `, ${outcome.resultCount} result(s)` : "") +
|
|
3646
|
-
truncation +
|
|
3647
|
-
// The reason a lens did not complete, when the poll recorded one:
|
|
3648
|
-
// an auth failure or a dead network used to read as a slow batch.
|
|
3649
|
-
(outcome.error ? ` — ${outcome.error}` : ""));
|
|
3650
|
-
}
|
|
3651
|
-
if (result.retriedCount > 0) {
|
|
3652
|
-
lines.push(` ↻ ${result.retriedCount} truncated result(s) recovered by re-submission with a doubled output cap.`);
|
|
3653
|
-
}
|
|
3654
|
-
if (result.retryElsewhere) {
|
|
3655
|
-
lines.push(" ↻ The truncation retry is in flight in another collect on this run; collect again for its result.");
|
|
3656
|
-
}
|
|
3657
|
-
if (result.truncatedCount > 0) {
|
|
3658
|
-
lines.push(` ⚠ ${result.truncatedCount} result(s) still truncated after retry — their modules are unscouted, not clean.`);
|
|
3659
|
-
}
|
|
3660
|
-
// A pass still in flight or retired must appear: a run reported
|
|
3661
|
-
// "completed" with no synthesis line read as "no synthesis was run",
|
|
3662
|
-
// when the batch was running and a later collect would have claimed it
|
|
3663
|
-
// (0.22.1 live run — the collect's wait ran out during the pass).
|
|
3664
|
-
const passInFlight = (kind, entry) => {
|
|
3665
|
-
if (entry.status === "submitted") {
|
|
3666
|
-
lines.push(` ${kind}: ${entry.batchId ? "still running" : "in flight in another collect"} — collect again for its result.`);
|
|
3667
|
-
}
|
|
3668
|
-
else if (entry.status === "failed") {
|
|
3669
|
-
lines.push(` ${kind}: failed${entry.error ? ` — ${explainBatchError(entry.error)}` : ""}`);
|
|
3670
|
-
}
|
|
3671
|
-
};
|
|
3672
|
-
// Whether a pass was built from a verify pass's verdicts is part of what
|
|
3673
|
-
// it is: a work order that ranked on batch severities alone is the one
|
|
3674
|
-
// that put two dismissed casts above the confirmed finding (#338).
|
|
3675
|
-
const builtFrom = (entry) => entry.verdicts ? ` (built from ${entry.verdicts} verdict${entry.verdicts === 1 ? "" : "s"})` : " (no verdicts)";
|
|
3676
|
-
if (result.regenerated && result.regenerated.length > 0) {
|
|
3677
|
-
lines.push(` regenerated: ${result.regenerated.join(", ")}`);
|
|
3678
|
-
}
|
|
3679
|
-
if (result.synthesis.status === "completed") {
|
|
3680
|
-
lines.push(` synthesis: completed, $${(result.synthesis.cost ?? 0).toFixed(6)}${builtFrom(result.synthesis)}`);
|
|
3681
|
-
if (result.topFindings.length > 0) {
|
|
3682
|
-
lines.push("", result.synthesis.verdicts ? "Top findings (verdicts applied; unread ones are unverified leads):" : "Top findings (unverified leads):");
|
|
3683
|
-
for (const f of result.topFindings.slice(0, 10)) {
|
|
3684
|
-
lines.push(` [${f.severity}] ${f.title}`);
|
|
3685
|
-
}
|
|
3686
|
-
}
|
|
3687
|
-
}
|
|
3688
|
-
else {
|
|
3689
|
-
passInFlight("synthesis", result.synthesis);
|
|
3690
|
-
}
|
|
3691
|
-
if (result.triage.status === "completed") {
|
|
3692
|
-
lines.push(` triage: completed, $${(result.triage.cost ?? 0).toFixed(6)}${builtFrom(result.triage)}`);
|
|
3693
|
-
if (result.topTriageItems.length > 0) {
|
|
3694
|
-
lines.push("", result.triage.verdicts
|
|
3695
|
-
? "Triage — prioritized work order (confirmed findings first; re-verify the unread ones before acting):"
|
|
3696
|
-
: "Triage — prioritized work order (re-verify before acting):");
|
|
3697
|
-
for (const item of result.topTriageItems.slice(0, 10)) {
|
|
3698
|
-
lines.push(` ${item.priority} [${item.severity}/${item.module}] ${item.title}` +
|
|
3699
|
-
(item.effort_estimate ? ` (${item.effort_estimate})` : ""));
|
|
3700
|
-
}
|
|
3701
|
-
}
|
|
3702
|
-
}
|
|
3703
|
-
else {
|
|
3704
|
-
passInFlight("triage", result.triage);
|
|
3705
|
-
}
|
|
3706
|
-
if (result.status === "completed" && !result.synthesis.verdicts && !result.triage.verdicts
|
|
3707
|
-
&& (result.synthesis.status === "completed" || result.triage.status === "completed")) {
|
|
3708
|
-
lines.push("", "Run verify, then collect --regenerate, to rebuild the report and the work order from verdicts.");
|
|
3709
|
-
}
|
|
3710
|
-
lines.push("", "Disclaimer: Broad-Side findings are unverified scouting signals from a batch model, not validated claims.");
|
|
3711
|
-
return lines.join("\n");
|
|
3712
|
-
}
|
|
3713
|
-
/**
|
|
3714
|
-
* An `onStatus` callback that appends one line to `lines` per *change* of a
|
|
3715
|
-
* lens's polled status. Every poll used to append a line, so a four-minute
|
|
3716
|
-
* wait returned twenty-six identical "in_progress (0/1)" lines per lens
|
|
3717
|
-
* before the result (0.22.0 live run).
|
|
3718
|
-
*/
|
|
3719
|
-
export function statusLineWriter(lines) {
|
|
3720
|
-
const last = new Map();
|
|
3721
|
-
return (lensId, status, counts) => {
|
|
3722
|
-
const line = ` ${lensId}: ${status} (${counts.completed ?? 0}/${counts.total ?? "?"})`;
|
|
3723
|
-
if (last.get(lensId) === line)
|
|
3724
|
-
return;
|
|
3725
|
-
last.set(lensId, line);
|
|
3726
|
-
lines.push(line);
|
|
3727
|
-
};
|
|
3728
|
-
}
|
|
3729
|
-
export function statusText(state) {
|
|
3730
|
-
if (state.runs.length === 0) {
|
|
3731
|
-
return "No Broad-Side runs recorded. Call codecarto_broadside with action 'submit' first.";
|
|
3732
|
-
}
|
|
3733
|
-
const lines = [];
|
|
3734
|
-
for (const run of [...state.runs].reverse().slice(0, 3)) {
|
|
3735
|
-
lines.push(`Run ${run.id} — ${run.status}`);
|
|
3736
|
-
// Recorded since #248; a run from an older version has neither field.
|
|
3737
|
-
if (run.language || run.snapshot) {
|
|
3738
|
-
const head = run.sourceHead ? ` at ${run.sourceHead.slice(0, 8)}${run.sourceDirty ? " (dirty)" : ""}` : "";
|
|
3739
|
-
const source = run.snapshot === "walk" ? "directory walk" : run.snapshot ? `working tree${head}` : "unknown source";
|
|
3740
|
-
lines.push(` scanned as ${run.language ?? "unknown"} from the ${source}`);
|
|
3741
|
-
}
|
|
3742
|
-
for (const lensId of BROADSIDE_LENS_IDS) {
|
|
3743
|
-
const entry = run.batches[lensId];
|
|
3744
|
-
if (!entry)
|
|
3745
|
-
continue;
|
|
3746
|
-
lines.push(` ${lensId}: ${entry.status}${entry.batchId ? ` (${entry.batchId})` : ""}${entry.cost !== undefined ? `, $${entry.cost.toFixed(6)}` : ""}` +
|
|
3747
|
-
(entry.status === "skipped" && entry.reason ? ` — ${entry.reason}` : entry.fallback ? ` — ${entry.fallback}` : ""));
|
|
3748
|
-
}
|
|
3749
|
-
const builtFrom = (entry) => entry?.status === "completed" ? (entry.verdicts ? ` (built from ${entry.verdicts} verdict${entry.verdicts === 1 ? "" : "s"})` : " (no verdicts)") : "";
|
|
3750
|
-
lines.push(` synthesis: ${run.synthesis.status}${builtFrom(run.synthesis)}`);
|
|
3751
|
-
lines.push(` triage: ${run.triage?.status ?? "pending"}${builtFrom(run.triage)}`);
|
|
3752
|
-
if (run.verify) {
|
|
3753
|
-
lines.push(` verify: ${run.verify.status} — ${run.verify.confirmed} confirmed of ${run.verify.verified} read on ${run.verify.model}, $${run.verify.cost.toFixed(4)}`);
|
|
3754
|
-
}
|
|
3755
|
-
if (run.totalCost !== undefined)
|
|
3756
|
-
lines.push(` total cost: $${run.totalCost.toFixed(6)}`);
|
|
3757
|
-
}
|
|
3758
|
-
return lines.join("\n");
|
|
3759
|
-
}
|
|
34
|
+
//
|
|
35
|
+
// The implementation lives in core/broadside/ (#339), one module per concern,
|
|
36
|
+
// with an acyclic import graph at runtime that points one way through these
|
|
37
|
+
// layers (tests/module-graph.test.mjs pins it, #371):
|
|
38
|
+
// constants, types, schemas, repo
|
|
39
|
+
// → lenses, requests, results, state, client
|
|
40
|
+
// → models, verify
|
|
41
|
+
// → submit, render
|
|
42
|
+
// → collect
|
|
43
|
+
// This file is the barrel; `core/index.ts` re-exports it, so both surfaces
|
|
44
|
+
// and the tests import one module.
|
|
45
|
+
export * from "./broadside/constants.js";
|
|
46
|
+
export * from "./broadside/types.js";
|
|
47
|
+
export * from "./broadside/schemas.js";
|
|
48
|
+
export * from "./broadside/lenses.js";
|
|
49
|
+
export * from "./broadside/repo.js";
|
|
50
|
+
export * from "./broadside/requests.js";
|
|
51
|
+
export * from "./broadside/state.js";
|
|
52
|
+
export * from "./broadside/models.js";
|
|
53
|
+
export * from "./broadside/client.js";
|
|
54
|
+
export * from "./broadside/submit.js";
|
|
55
|
+
export * from "./broadside/results.js";
|
|
56
|
+
export * from "./broadside/verify.js";
|
|
57
|
+
export * from "./broadside/collect.js";
|
|
58
|
+
export * from "./broadside/render.js";
|