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.
Files changed (50) hide show
  1. package/.codecarto/broadside/SKILL.md +4 -1
  2. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  3. package/README.md +2 -2
  4. package/dist/core/broadside/client.d.ts +56 -0
  5. package/dist/core/broadside/client.js +200 -0
  6. package/dist/core/broadside/collect.d.ts +68 -0
  7. package/dist/core/broadside/collect.js +676 -0
  8. package/dist/core/broadside/constants.d.ts +51 -0
  9. package/dist/core/broadside/constants.js +74 -0
  10. package/dist/core/broadside/lenses.d.ts +31 -0
  11. package/dist/core/broadside/lenses.js +312 -0
  12. package/dist/core/broadside/models.d.ts +46 -0
  13. package/dist/core/broadside/models.js +321 -0
  14. package/dist/core/broadside/render.d.ts +20 -0
  15. package/dist/core/broadside/render.js +285 -0
  16. package/dist/core/broadside/repo.d.ts +58 -0
  17. package/dist/core/broadside/repo.js +592 -0
  18. package/dist/core/broadside/requests.d.ts +23 -0
  19. package/dist/core/broadside/requests.js +71 -0
  20. package/dist/core/broadside/results.d.ts +36 -0
  21. package/dist/core/broadside/results.js +163 -0
  22. package/dist/core/broadside/schemas.d.ts +2 -0
  23. package/dist/core/broadside/schemas.js +342 -0
  24. package/dist/core/broadside/state.d.ts +99 -0
  25. package/dist/core/broadside/state.js +384 -0
  26. package/dist/core/broadside/submit.d.ts +30 -0
  27. package/dist/core/broadside/submit.js +350 -0
  28. package/dist/core/broadside/types.d.ts +491 -0
  29. package/dist/core/broadside/types.js +107 -0
  30. package/dist/core/{broadside-verify.d.ts → broadside/verify.d.ts} +23 -2
  31. package/dist/core/{broadside-verify.js → broadside/verify.js} +43 -5
  32. package/dist/core/broadside.d.ts +14 -952
  33. package/dist/core/broadside.js +25 -3726
  34. package/dist/core/completion.js +91 -72
  35. package/dist/core/dashboard-writer.js +9 -1
  36. package/dist/core/index.d.ts +0 -1
  37. package/dist/core/index.js +0 -1
  38. package/dist/core/library.d.ts +24 -1
  39. package/dist/core/library.js +46 -15
  40. package/dist/core/orchestrator-config.js +22 -8
  41. package/dist/core/status.d.ts +42 -23
  42. package/dist/core/status.js +163 -137
  43. package/dist/core/workspace.d.ts +2 -0
  44. package/dist/core/workspace.js +49 -25
  45. package/dist/core/yaml.js +9 -3
  46. package/dist/extensions/codecarto/auto-runner.js +41 -23
  47. package/dist/extensions/codecarto/index.js +9 -4
  48. package/dist/extensions/codecarto/phase-compaction.js +6 -2
  49. package/dist/mcp-server/server.js +15 -4
  50. package/package.json +1 -1
@@ -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
- import { createHash } from "node:crypto";
35
- import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
36
- import { execFile } from "node:child_process";
37
- import { promisify } from "node:util";
38
- import { join, relative } from "node:path";
39
- import { atomicWriteFile, GIT_TIMEOUT_MS, pathExists, sleep } from "./utils.js";
40
- import { describeRedactions, isSecretFile, redactSecrets } from "./secrets.js";
41
- import { acquireLock } from "./status.js";
42
- import { loadYamlFile } from "./yaml.js";
43
- import { packagedWorkspaceDir } from "./workspace.js";
44
- const execFileAsync = promisify(execFile);
45
- // ---------- constants ----------
46
- export const BROADSIDE_MODEL = "google/gemini-3.7-flash:batch";
47
- export const BROADSIDE_BATCH_URL = "https://openrouter.ai/api/beta/batches";
48
- export const BROADSIDE_DIR = "broadside"; // relative to .codecarto/
49
- /** Name Broad-Side answers to on the skill surfaces. Not a post-pipeline skill — see readBroadsideSkill. */
50
- export const BROADSIDE_SKILL_NAME = "broadside";
51
- export const BROADSIDE_STATE_FILE = "state.json";
52
- export const BROADSIDE_CONFIG_FILE = "config.yaml";
53
- export const BROADSIDE_STATE_SCHEMA_VERSION = 1;
54
- // Per-token pricing in USD (OpenRouter, google/gemini-3.7-flash:batch).
55
- // OpenRouter's listed rates for the `:batch` variant, which already carry the
56
- // batch discount — the sync model is $0.75/$3.75. These were half these values
57
- // until a live run compared them against the catalog: the batch discount had
58
- // been applied a second time by hand, so every estimate for the default model
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";