codecartographer-pi 0.24.1 → 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 (55) hide show
  1. package/.codecarto/broadside/SKILL.md +20 -1
  2. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  3. package/README.md +5 -4
  4. package/agent-skill/codecartographer/references/broadside.md +5 -1
  5. package/dist/core/broadside/client.d.ts +56 -0
  6. package/dist/core/broadside/client.js +200 -0
  7. package/dist/core/broadside/collect.d.ts +68 -0
  8. package/dist/core/broadside/collect.js +676 -0
  9. package/dist/core/broadside/constants.d.ts +51 -0
  10. package/dist/core/broadside/constants.js +74 -0
  11. package/dist/core/broadside/lenses.d.ts +31 -0
  12. package/dist/core/broadside/lenses.js +312 -0
  13. package/dist/core/broadside/models.d.ts +46 -0
  14. package/dist/core/broadside/models.js +321 -0
  15. package/dist/core/broadside/render.d.ts +20 -0
  16. package/dist/core/broadside/render.js +285 -0
  17. package/dist/core/broadside/repo.d.ts +58 -0
  18. package/dist/core/broadside/repo.js +592 -0
  19. package/dist/core/broadside/requests.d.ts +23 -0
  20. package/dist/core/broadside/requests.js +71 -0
  21. package/dist/core/broadside/results.d.ts +36 -0
  22. package/dist/core/broadside/results.js +163 -0
  23. package/dist/core/broadside/schemas.d.ts +2 -0
  24. package/dist/core/broadside/schemas.js +342 -0
  25. package/dist/core/broadside/state.d.ts +99 -0
  26. package/dist/core/broadside/state.js +384 -0
  27. package/dist/core/broadside/submit.d.ts +30 -0
  28. package/dist/core/broadside/submit.js +350 -0
  29. package/dist/core/broadside/types.d.ts +491 -0
  30. package/dist/core/broadside/types.js +107 -0
  31. package/dist/core/{broadside-verify.d.ts → broadside/verify.d.ts} +23 -2
  32. package/dist/core/{broadside-verify.js → broadside/verify.js} +43 -5
  33. package/dist/core/broadside.d.ts +14 -890
  34. package/dist/core/broadside.js +25 -3564
  35. package/dist/core/completion.js +91 -72
  36. package/dist/core/dashboard-writer.js +9 -1
  37. package/dist/core/index.d.ts +0 -1
  38. package/dist/core/index.js +0 -1
  39. package/dist/core/library.d.ts +24 -1
  40. package/dist/core/library.js +46 -15
  41. package/dist/core/orchestrator-config.js +22 -8
  42. package/dist/core/status.d.ts +42 -23
  43. package/dist/core/status.js +163 -137
  44. package/dist/core/workspace.d.ts +2 -0
  45. package/dist/core/workspace.js +49 -25
  46. package/dist/core/yaml.js +9 -3
  47. package/dist/extensions/codecarto/auto-runner.d.ts +7 -0
  48. package/dist/extensions/codecarto/auto-runner.js +54 -23
  49. package/dist/extensions/codecarto/broadside-flags.d.ts +3 -1
  50. package/dist/extensions/codecarto/broadside-flags.js +13 -0
  51. package/dist/extensions/codecarto/index.js +13 -7
  52. package/dist/extensions/codecarto/phase-compaction.js +6 -2
  53. package/dist/mcp-server/server.d.ts +1 -0
  54. package/dist/mcp-server/server.js +28 -5
  55. package/package.json +1 -1
@@ -31,3567 +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
- /** Read a `reasoning:` block from config.yaml, ignoring anything malformed. */
1722
- function parseReasoningConfig(raw) {
1723
- if (raw === false)
1724
- return { enabled: false };
1725
- if (raw === true)
1726
- return { enabled: true };
1727
- if (!raw || typeof raw !== "object")
1728
- return null;
1729
- const value = raw;
1730
- const out = {};
1731
- if (typeof value.enabled === "boolean")
1732
- out.enabled = value.enabled;
1733
- if (value.effort === "minimal" || value.effort === "low" || value.effort === "medium" || value.effort === "high")
1734
- out.effort = value.effort;
1735
- if (typeof value.max_tokens === "number" && value.max_tokens > 0)
1736
- out.max_tokens = value.max_tokens;
1737
- return Object.keys(out).length > 0 ? out : null;
1738
- }
1739
- export async function loadBroadsideConfig(broadsideDir) {
1740
- const configPath = join(broadsideDir, BROADSIDE_CONFIG_FILE);
1741
- let raw = {};
1742
- if (await pathExists(configPath)) {
1743
- let parsed;
1744
- try {
1745
- parsed = await loadYamlFile(configPath);
1746
- }
1747
- catch (error) {
1748
- throw new BroadsideConfigError(configPath, `could not be parsed (${error instanceof Error ? error.message : String(error)})`);
1749
- }
1750
- if (parsed !== null && parsed !== undefined) {
1751
- if (typeof parsed !== "object" || Array.isArray(parsed))
1752
- throw new BroadsideConfigError(configPath, "is not a YAML mapping");
1753
- raw = parsed;
1754
- }
1755
- // OpenRouter accepts `reasoning.effort` or `reasoning.max_tokens`, not
1756
- // both: a request carrying both is refused per request *after* the batch
1757
- // is accepted, so every lens fails at $0 with the reason in each
1758
- // result's error. Seen live on 0.22.0 with the two keys set together.
1759
- // Refuse here, where the file can be fixed, rather than submit a run
1760
- // that cannot produce a result.
1761
- const reasoning = raw.reasoning;
1762
- if (reasoning && typeof reasoning === "object" && !Array.isArray(reasoning)) {
1763
- const value = reasoning;
1764
- const hasEffort = typeof value.effort === "string";
1765
- const hasBudget = typeof value.max_tokens === "number" && value.max_tokens > 0;
1766
- if (hasEffort && hasBudget) {
1767
- 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');
1768
- }
1769
- }
1770
- }
1771
- return buildBroadsideConfig(raw);
1772
- }
1773
- /** The shipped defaults: what an absent config.yaml means. */
1774
- export function defaultBroadsideConfig() {
1775
- return buildBroadsideConfig({});
1776
- }
1777
- function buildBroadsideConfig(raw) {
1778
- const lenses = Array.isArray(raw.default_lenses)
1779
- ? (raw.default_lenses.filter((l) => BROADSIDE_LENS_IDS.includes(l)))
1780
- : [];
1781
- const rawPricing = (raw.pricing ?? {});
1782
- const inputOverride = typeof rawPricing.input_per_m === "number" ? rawPricing.input_per_m : undefined;
1783
- const outputOverride = typeof rawPricing.output_per_m === "number" ? rawPricing.output_per_m : undefined;
1784
- // A malformed value falls back to the shipped default rather than failing
1785
- // the run: config.yaml is hand-edited, and a typo in a poll budget must not
1786
- // cost a user their batches.
1787
- const flag = (key, fallback) => typeof raw[key] === "boolean" ? raw[key] : fallback;
1788
- // An override for an unknown lens id is dropped rather than carried: it can
1789
- // only be a typo, and a silently-ignored key that looks applied is worse
1790
- // than one that never appears.
1791
- const lensModels = {};
1792
- const rawLensModels = (raw.lens_models ?? {});
1793
- for (const lensId of BROADSIDE_LENS_IDS) {
1794
- const value = rawLensModels[lensId];
1795
- if (typeof value === "string" && value.trim())
1796
- lensModels[lensId] = value.trim();
1797
- }
1798
- return {
1799
- model: typeof raw.model === "string" && raw.model.trim() ? raw.model.trim() : BROADSIDE_MODEL,
1800
- apiKey: typeof raw.api_key === "string" ? raw.api_key.trim() : "",
1801
- defaultLenses: lenses.length > 0 ? lenses : [...BROADSIDE_LENS_IDS],
1802
- // Absent: the shipped default. An explicit 0 is "no limit", spelled out
1803
- // on purpose; a negative or non-numeric value is not a limit at all.
1804
- maxCost: typeof raw.max_cost === "number" && raw.max_cost >= 0 ? raw.max_cost : BROADSIDE_DEFAULT_MAX_COST,
1805
- pricing: inputOverride !== undefined && outputOverride !== undefined
1806
- ? { inputPerM: inputOverride, outputPerM: outputOverride }
1807
- : null,
1808
- lensModels,
1809
- // An escape hatch, not a knob to reach for: a model whose reasoning is
1810
- // worth paying for needs its lens maxTokens raised to cover both the
1811
- // thinking and the answer, or the JSON truncates exactly as before.
1812
- reasoning: parseReasoningConfig(raw.reasoning),
1813
- incremental: flag("incremental", false),
1814
- retryTruncated: flag("retry_truncated", true),
1815
- includeSynthesis: flag("include_synthesis", true),
1816
- includeTriage: flag("include_triage", true),
1817
- waitSeconds: typeof raw.wait_seconds === "number" && raw.wait_seconds > 0 ? raw.wait_seconds : 0,
1818
- redactSecrets: flag("redact_secrets", true),
1819
- };
1820
- }
1821
- // ---------- model catalog, pricing, benchmarks ----------
1822
- /** The catalog cache schema this build writes; a file from another is not read. */
1823
- export const BROADSIDE_CATALOG_CACHE_SCHEMA = 3;
1824
- async function readCatalogCache(broadsideDir) {
1825
- const cachePath = join(broadsideDir, BROADSIDE_CATALOG_CACHE_FILE);
1826
- if (!(await pathExists(cachePath)))
1827
- return null;
1828
- try {
1829
- const parsed = JSON.parse(await readFile(cachePath, "utf8"));
1830
- if (!parsed || typeof parsed !== "object" || !parsed.models || typeof parsed.models !== "object")
1831
- return null;
1832
- if (parsed.schema_version !== BROADSIDE_CATALOG_CACHE_SCHEMA && parsed.schema_version !== 2)
1833
- return null;
1834
- return parsed;
1835
- }
1836
- catch {
1837
- return null;
1838
- }
1839
- }
1840
- /** When a cached entry was fetched: its own stamp, or the file's for a schema-2 cache. */
1841
- function catalogEntryFetchedAt(cache, model) {
1842
- const stamp = cache.models[model]?.fetched_at ?? cache.fetched_at;
1843
- return new Date(stamp).getTime();
1844
- }
1845
- async function writeCatalogCache(broadsideDir, cache) {
1846
- await mkdir(broadsideDir, { recursive: true });
1847
- await writeFile(join(broadsideDir, BROADSIDE_CATALOG_CACHE_FILE), `${JSON.stringify(cache, null, "\t")}\n`, "utf8");
1848
- }
1849
- const BROADSIDE_ENDPOINTS_SCHEMA = 1;
1850
- export async function readBatchEndpoints(broadsideDir) {
1851
- const path = join(broadsideDir, BROADSIDE_ENDPOINTS_FILE);
1852
- if (!(await pathExists(path)))
1853
- return {};
1854
- try {
1855
- const parsed = JSON.parse(await readFile(path, "utf8"));
1856
- if (!parsed || typeof parsed !== "object" || parsed.schema_version !== BROADSIDE_ENDPOINTS_SCHEMA)
1857
- return {};
1858
- if (!parsed.models || typeof parsed.models !== "object")
1859
- return {};
1860
- const out = {};
1861
- for (const [model, record] of Object.entries(parsed.models)) {
1862
- if (!record || typeof record !== "object")
1863
- continue;
1864
- if (record.status !== "accepted" && record.status !== "rejected")
1865
- continue;
1866
- if (typeof record.at !== "string")
1867
- continue;
1868
- out[model] = { status: record.status, at: record.at, ...(typeof record.error === "string" && { error: record.error }) };
1869
- }
1870
- return out;
1871
- }
1872
- catch {
1873
- // An unreadable memory is an empty one: it only annotates a listing.
1874
- return {};
1875
- }
1876
- }
1877
- /**
1878
- * The refusal OpenRouter returns for a catalog id that has no batch endpoint
1879
- * behind it. Matched loosely: the message is the only signal there is.
1880
- */
1881
- const NO_BATCH_ENDPOINT_RE = /does not have a :batch endpoint/i;
1882
- /** The refusal for a full per-account concurrent batch-job quota. */
1883
- const BATCH_QUOTA_RE = /job-submission-count/i;
1884
- /**
1885
- * Remember what a submit learned about each model it posted to. An accepted
1886
- * job proves the endpoint exists; a "does not have a :batch endpoint"
1887
- * refusal proves it does not. Any other rejection (quota, malformed request,
1888
- * auth) says nothing about the endpoint and leaves the record alone.
1889
- */
1890
- export async function recordBatchEndpoints(broadsideDir, outcomes) {
1891
- const at = new Date().toISOString();
1892
- const updates = {};
1893
- for (const { model, batchId, error } of outcomes) {
1894
- if (batchId) {
1895
- updates[model] = { status: "accepted", at };
1896
- continue;
1897
- }
1898
- const message = describeBatchError(error);
1899
- if (message && NO_BATCH_ENDPOINT_RE.test(message)) {
1900
- updates[model] = { status: "rejected", at, error: message };
1901
- }
1902
- }
1903
- if (Object.keys(updates).length === 0)
1904
- return;
1905
- const models = { ...(await readBatchEndpoints(broadsideDir)), ...updates };
1906
- await mkdir(broadsideDir, { recursive: true });
1907
- const file = { schema_version: BROADSIDE_ENDPOINTS_SCHEMA, models };
1908
- await atomicWriteFile(join(broadsideDir, BROADSIDE_ENDPOINTS_FILE), `${JSON.stringify(file, null, "\t")}\n`);
1909
- }
1910
- function parseCatalogEntry(raw) {
1911
- const id = String(raw.id ?? "");
1912
- if (!id)
1913
- return null;
1914
- const p = (raw.pricing ?? {});
1915
- const input = typeof p.prompt === "string" ? Number(p.prompt) : NaN;
1916
- const output = typeof p.completion === "string" ? Number(p.completion) : NaN;
1917
- if (!Number.isFinite(input) || !Number.isFinite(output))
1918
- return null;
1919
- const cached = typeof p.cached_input === "string" ? Number(p.cached_input) : NaN;
1920
- const topProvider = (raw.top_provider ?? {});
1921
- const contextLength = typeof raw.context_length === "number" ? raw.context_length : undefined;
1922
- const maxCompletion = typeof topProvider.max_completion_tokens === "number" ? topProvider.max_completion_tokens : undefined;
1923
- return {
1924
- id,
1925
- name: String(raw.name ?? id),
1926
- inputPerM: input * 1_000_000,
1927
- outputPerM: output * 1_000_000,
1928
- cachedInputPerM: Number.isFinite(cached) ? cached * 1_000_000 : undefined,
1929
- contextLength,
1930
- maxCompletionTokens: maxCompletion,
1931
- supportedParameters: Array.isArray(raw.supported_parameters)
1932
- ? raw.supported_parameters.map((entry) => String(entry))
1933
- : [],
1934
- expirationDate: typeof raw.expiration_date === "string" ? raw.expiration_date : null,
1935
- };
1936
- }
1937
- export function builtInCatalogEntry(model) {
1938
- // The default model's rates are compile-time constants; its capabilities
1939
- // are asserted from the shipped configuration (1M context, 64K output,
1940
- // structured outputs used by every lens).
1941
- if (model !== BROADSIDE_MODEL)
1942
- return null;
1943
- return {
1944
- id: BROADSIDE_MODEL,
1945
- name: "Google: Gemini 3.7 Flash (batch)",
1946
- inputPerM: BROADSIDE_INPUT_PRICE_PER_M,
1947
- outputPerM: BROADSIDE_OUTPUT_PRICE_PER_M,
1948
- contextLength: 1_048_576,
1949
- maxCompletionTokens: 65_536,
1950
- supportedParameters: ["tools", "structured_outputs", "json_schema", "response_format"],
1951
- expirationDate: null,
1952
- };
1953
- }
1954
- export function builtInPricing(model) {
1955
- const entry = builtInCatalogEntry(model);
1956
- if (!entry)
1957
- return null;
1958
- return { inputPerM: entry.inputPerM, outputPerM: entry.outputPerM, source: "built-in" };
1959
- }
1960
- export async function resolveCatalogEntry(broadsideDir, config, model, apiKey, fetcher = fetch) {
1961
- // Manual overrides always win for pricing — the user is asserting a rate,
1962
- // and a config assertion is cheaper to respect than to second-guess.
1963
- // Capabilities stay unknown in that case: nothing is refused, nothing
1964
- // is clamped, and the submit text says the pricing came from config.
1965
- if (config.pricing) {
1966
- return {
1967
- model,
1968
- source: "config",
1969
- entry: {
1970
- id: model,
1971
- name: model,
1972
- inputPerM: config.pricing.inputPerM,
1973
- outputPerM: config.pricing.outputPerM,
1974
- supportedParameters: [],
1975
- },
1976
- };
1977
- }
1978
- // On-disk cache first, then the live catalog — for the default model too.
1979
- // Hardcoded rates used to short-circuit here, which meant a stale constant
1980
- // could never self-correct even though the catalog was already being
1981
- // fetched for every other model. The authoritative source wins; the
1982
- // constants below are what we fall back to when the network is unavailable.
1983
- const cache = await readCatalogCache(broadsideDir);
1984
- const cached = cache?.models[model];
1985
- if (cache && cached && Date.now() - catalogEntryFetchedAt(cache, model) < BROADSIDE_CATALOG_CACHE_TTL_MS) {
1986
- return { model, source: "cache", entry: cached };
1987
- }
1988
- // What went wrong when the live lookup produced nothing, for the error
1989
- // below: a 401 and a dead network used to read the same — "could not
1990
- // resolve per-token pricing" — or, for the default model, nothing at all.
1991
- let live = null;
1992
- let catalogFailure = null;
1993
- try {
1994
- const resp = await fetcher(BROADSIDE_MODELS_URL, {
1995
- method: "GET",
1996
- headers: { Authorization: `Bearer ${apiKey}` },
1997
- signal: AbortSignal.timeout(30_000),
1998
- });
1999
- if (resp.status === 401 || resp.status === 403) {
2000
- throw new BroadsideAuthError(resp.status, await responseDetail(resp));
2001
- }
2002
- if (resp.ok === false) {
2003
- catalogFailure = `the model catalog request failed (HTTP ${resp.status}${await responseDetail(resp).then((d) => (d ? `: ${d}` : ""))})`;
2004
- }
2005
- else {
2006
- const data = (await resp.json());
2007
- const hit = (data.data ?? []).find((m) => String(m.id) === model);
2008
- if (hit)
2009
- live = parseCatalogEntry(hit);
2010
- else
2011
- catalogFailure = `the model catalog has no entry for "${model}"`;
2012
- }
2013
- }
2014
- catch (error) {
2015
- if (error instanceof BroadsideAuthError)
2016
- throw error;
2017
- live = null;
2018
- catalogFailure = `the model catalog could not be fetched (${error instanceof Error ? error.message : String(error)})`;
2019
- }
2020
- if (live) {
2021
- const now = new Date().toISOString();
2022
- const updated = {
2023
- schema_version: BROADSIDE_CATALOG_CACHE_SCHEMA,
2024
- fetched_at: now,
2025
- // Other entries keep their own stamps (a schema-2 file's entries
2026
- // inherit the file's, once, on this upgrade); only this model is fresh.
2027
- models: Object.fromEntries(Object.entries(cache?.models ?? {}).map(([id, entry]) => [id, { ...entry, fetched_at: entry.fetched_at ?? cache.fetched_at }])),
2028
- };
2029
- updated.models[model] = { ...live, fetched_at: now };
2030
- await writeCatalogCache(broadsideDir, updated);
2031
- return { model, source: "live", entry: live };
2032
- }
2033
- // Offline fallback: the default model's rates and capabilities are known at
2034
- // compile time, so a network failure does not have to stop a run.
2035
- const builtIn = builtInCatalogEntry(model);
2036
- if (builtIn)
2037
- return { model, source: "built-in", entry: builtIn };
2038
- throw new Error(`Could not resolve per-token pricing for batch model "${model}": ${catalogFailure ?? "no catalog entry"}. ` +
2039
- "Set pricing.input_per_m and pricing.output_per_m in .codecarto/broadside/config.yaml " +
2040
- "(USD per million tokens), or check the model id against https://openrouter.ai/models?variant=batch.");
2041
- }
2042
- /** A short, safe excerpt of an error response body for a message. */
2043
- async function responseDetail(resp) {
2044
- try {
2045
- if (typeof resp.text === "function") {
2046
- const text = (await resp.text()).trim();
2047
- try {
2048
- const parsed = JSON.parse(text);
2049
- const message = typeof parsed?.error === "string" ? parsed.error : parsed?.error?.message;
2050
- if (typeof message === "string" && message)
2051
- return message.slice(0, 200);
2052
- }
2053
- catch {
2054
- // not JSON; fall through to the raw excerpt
2055
- }
2056
- return text.replace(/\s+/g, " ").slice(0, 200);
2057
- }
2058
- if (typeof resp.json === "function") {
2059
- const parsed = (await resp.json());
2060
- const message = typeof parsed?.error === "string" ? parsed.error : parsed?.error?.message;
2061
- return typeof message === "string" ? message.slice(0, 200) : "";
2062
- }
2063
- }
2064
- catch {
2065
- // an unreadable body adds nothing to the message
2066
- }
2067
- return "";
2068
- }
2069
- export async function resolveModelPricing(broadsideDir, config, model, apiKey, fetcher = fetch) {
2070
- const { source, entry } = await resolveCatalogEntry(broadsideDir, config, model, apiKey, fetcher);
2071
- if (!entry)
2072
- throw new Error(`No pricing resolved for ${model}.`);
2073
- return { inputPerM: entry.inputPerM, outputPerM: entry.outputPerM, source };
2074
- }
2075
- /** Base slug with the OpenRouter variant suffix (e.g. `:batch`) stripped. */
2076
- function baseSlug(modelId) {
2077
- const idx = modelId.indexOf(":");
2078
- return idx >= 0 ? modelId.slice(0, idx) : modelId;
2079
- }
2080
- export async function fetchCodingBenchmarks(apiKey, fetcher = fetch) {
2081
- try {
2082
- const resp = await fetcher(`${BROADSIDE_BENCHMARKS_URL}?source=artificial-analysis&task_type=coding`, {
2083
- method: "GET",
2084
- headers: { Authorization: `Bearer ${apiKey}` },
2085
- signal: AbortSignal.timeout(30_000),
2086
- });
2087
- const data = (await resp.json());
2088
- const byBaseSlug = {};
2089
- for (const row of data.data ?? []) {
2090
- const slug = baseSlug(String(row.model_permaslug ?? ""));
2091
- if (!slug)
2092
- continue;
2093
- const toIndex = (v) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
2094
- byBaseSlug[slug] = {
2095
- codingIndex: toIndex(row.coding_index),
2096
- intelligenceIndex: toIndex(row.intelligence_index),
2097
- };
2098
- }
2099
- return { byBaseSlug, meta: data.meta ?? {} };
2100
- }
2101
- catch {
2102
- return null;
2103
- }
2104
- }
2105
- export async function listBatchModels(broadsideDir, config, apiKey, opts = {}) {
2106
- const fetcher = opts.fetcher ?? fetch;
2107
- const resp = await fetcher(BROADSIDE_MODELS_URL, {
2108
- method: "GET",
2109
- headers: { Authorization: `Bearer ${apiKey}` },
2110
- signal: AbortSignal.timeout(30_000),
2111
- });
2112
- const data = (await resp.json());
2113
- const entries = [];
2114
- const seen = new Set();
2115
- for (const raw of data.data ?? []) {
2116
- const entry = parseCatalogEntry(raw);
2117
- if (!entry || seen.has(entry.id))
2118
- continue;
2119
- seen.add(entry.id);
2120
- if (!entry.id.endsWith(":batch"))
2121
- continue;
2122
- entries.push(entry);
2123
- }
2124
- entries.sort((a, b) => a.inputPerM + a.outputPerM - (b.inputPerM + b.outputPerM));
2125
- // Persist the catalog so the next submit's pricing resolution hits cache.
2126
- const fetchedAt = new Date().toISOString();
2127
- const cache = { schema_version: BROADSIDE_CATALOG_CACHE_SCHEMA, fetched_at: fetchedAt, models: {} };
2128
- for (const entry of entries)
2129
- cache.models[entry.id] = { ...entry, fetched_at: fetchedAt };
2130
- await writeCatalogCache(broadsideDir, cache);
2131
- const benchmarks = opts.includeBenchmarks ? await fetchCodingBenchmarks(apiKey, fetcher) : null;
2132
- const endpoints = await readBatchEndpoints(broadsideDir);
2133
- return { entries, source: "live", benchmarks, defaultModel: config.model, endpoints };
2134
- }
2135
- export async function submitBatch(batchRequests, apiKey, fetcher = fetch, model = BROADSIDE_MODEL) {
2136
- // The OpenRouter batch endpoint stream-parses the body and requires
2137
- // `endpoint` and `model` to serialize before `requests` — key order matters.
2138
- const payload = {
2139
- endpoint: "/v1/chat/completions",
2140
- model,
2141
- requests: batchRequests,
2142
- };
2143
- const resp = await fetcher(BROADSIDE_BATCH_URL, {
2144
- method: "POST",
2145
- headers: {
2146
- Authorization: `Bearer ${apiKey}`,
2147
- "Content-Type": "application/json",
2148
- },
2149
- body: JSON.stringify(payload),
2150
- signal: AbortSignal.timeout(30_000),
2151
- });
2152
- const data = (await resp.json());
2153
- if (resp.status !== 202) {
2154
- return { batchId: "", status: "rejected", error: data };
2155
- }
2156
- return { batchId: String(data.id), status: String(data.status) };
2157
- }
2158
- export async function fetchBatch(batchId, apiKey, fetcher = fetch) {
2159
- const resp = await fetcher(`${BROADSIDE_BATCH_URL}/${batchId}`, {
2160
- method: "GET",
2161
- headers: { Authorization: `Bearer ${apiKey}` },
2162
- signal: AbortSignal.timeout(30_000),
2163
- });
2164
- let data;
2165
- try {
2166
- data = (await resp.json());
2167
- }
2168
- catch (error) {
2169
- // A gateway error page is not JSON. It used to throw out of here and
2170
- // be retried as if the network were down; keep the status instead.
2171
- data = { error: `non-JSON response (${error instanceof Error ? error.message : String(error)})` };
2172
- }
2173
- if (!data || typeof data !== "object")
2174
- data = { error: "empty response" };
2175
- // Surface the HTTP status so the poller can bail fast on auth expiry
2176
- // instead of retrying a dead key for the whole budget.
2177
- data.http_status = resp.status;
2178
- return data;
2179
- }
2180
- /**
2181
- * Batch statuses that will never produce a result.
2182
- *
2183
- * Deliberately excludes the synthetic `timeout` this module returns when a poll
2184
- * budget expires: that batch is still running server-side and has already been
2185
- * charged, so callers must come back for it rather than retire it.
2186
- */
2187
- export const BROADSIDE_DEAD_BATCH_STATUSES = ["failed", "expired", "cancelled", "auth-failed"];
2188
- /**
2189
- * Batch entry statuses collect never polls again: the dead ones above, plus
2190
- * `completed`, plus the two a submit assigns without a batch (`skipped`: no
2191
- * matching files; `rejected`: the provider refused it). The 0.19.1 changelog
2192
- * called the dead set "a named constant rather than two hand-maintained
2193
- * lists"; this set was still three literal copies (self-audit sem 5.8).
2194
- */
2195
- export const BROADSIDE_TERMINAL_ENTRY_STATUSES = ["completed", ...BROADSIDE_DEAD_BATCH_STATUSES, "skipped", "rejected"];
2196
- export async function pollBatchUntilTerminal(batchId, apiKey, opts = {}) {
2197
- const deadline = Date.now() + (opts.deadlineMs ?? BROADSIDE_DEFAULT_POLL_BUDGET_MS);
2198
- const intervalMs = opts.pollIntervalMs ?? BROADSIDE_POLL_INTERVAL_MS;
2199
- const fetcher = opts.fetcher ?? fetch;
2200
- // A poll that runs out of budget without one good response is not a slow
2201
- // batch. The last thing that went wrong rides on the timeout so the report
2202
- // can tell a dead network or a failing gateway from a batch still running.
2203
- let lastError = null;
2204
- let sawBatch = false;
2205
- const timedOut = () => ({
2206
- id: batchId,
2207
- status: "timeout",
2208
- ...(lastError && !sawBatch && { error: `no successful poll response; last error: ${lastError}` }),
2209
- ...(lastError && sawBatch && { last_error: lastError }),
2210
- });
2211
- for (;;) {
2212
- if (opts.signal?.aborted)
2213
- return { ...timedOut(), aborted: true };
2214
- let batch;
2215
- try {
2216
- batch = await fetchBatch(batchId, apiKey, fetcher);
2217
- }
2218
- catch (error) {
2219
- lastError = `fetch failed (${error instanceof Error ? error.message : String(error)})`;
2220
- if (Date.now() >= deadline)
2221
- return timedOut();
2222
- await sleep(intervalMs);
2223
- continue;
2224
- }
2225
- const httpStatus = Number(batch.http_status ?? 200);
2226
- if (httpStatus === 401 || httpStatus === 403) {
2227
- return { id: batchId, status: "auth-failed", error: batch.error ?? batch };
2228
- }
2229
- if (httpStatus >= 400) {
2230
- // A gateway or server error: retry within the budget, remembered.
2231
- const detail = typeof batch.error === "string" ? batch.error : JSON.stringify(batch.error ?? "");
2232
- lastError = `HTTP ${httpStatus}${detail ? ` (${detail.slice(0, 200)})` : ""}`;
2233
- if (Date.now() >= deadline)
2234
- return timedOut();
2235
- await sleep(intervalMs);
2236
- continue;
2237
- }
2238
- sawBatch = true;
2239
- const status = String(batch.status ?? "unknown");
2240
- const counts = (batch.request_counts ?? {});
2241
- opts.onStatus?.(status, counts);
2242
- if (status === "completed" || BROADSIDE_DEAD_BATCH_STATUSES.includes(status))
2243
- return batch;
2244
- if (Date.now() >= deadline)
2245
- return timedOut();
2246
- await sleepUnlessAborted(intervalMs, opts.signal);
2247
- }
2248
- }
2249
- /** Sleep, but wake at once when the signal fires so an abort is not a poll interval late. */
2250
- function sleepUnlessAborted(ms, signal) {
2251
- if (!signal)
2252
- return sleep(ms);
2253
- if (signal.aborted)
2254
- return Promise.resolve();
2255
- return new Promise((resolve) => {
2256
- const timer = setTimeout(() => {
2257
- signal.removeEventListener("abort", onAbort);
2258
- resolve();
2259
- }, ms);
2260
- const onAbort = () => {
2261
- clearTimeout(timer);
2262
- resolve();
2263
- };
2264
- signal.addEventListener("abort", onAbort, { once: true });
2265
- });
2266
- }
2267
- /**
2268
- * Poll several batch ids in parallel against one shared deadline. Collect
2269
- * previously polled one lens at a time, so a slow first lens serialized the
2270
- * wall clock for lenses that had already finished server-side (#136). The
2271
- * onStatus callback identifies the lens so progress output stays readable
2272
- * even while the polls interleave.
2273
- */
2274
- export async function pollBatchesConcurrently(entries, apiKey, opts = {}) {
2275
- const results = new Map();
2276
- const deadlineMs = opts.deadlineMs ?? BROADSIDE_DEFAULT_POLL_BUDGET_MS;
2277
- await Promise.all(entries.map(async ({ lensId, batchId }) => {
2278
- const batch = await pollBatchUntilTerminal(batchId, apiKey, {
2279
- deadlineMs,
2280
- fetcher: opts.fetcher,
2281
- pollIntervalMs: opts.pollIntervalMs,
2282
- signal: opts.signal,
2283
- onStatus: (status, counts) => opts.onStatus?.(lensId, status, counts),
2284
- });
2285
- results.set(batchId, batch);
2286
- }));
2287
- return results;
2288
- }
2289
- // ---------- run orchestration ----------
2290
- export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
2291
- const lensIds = opts.lenses ?? BROADSIDE_LENS_IDS;
2292
- const broadsideDir = broadsideDirFor(cwd);
2293
- const model = opts.model ?? BROADSIDE_MODEL;
2294
- // Resolve a catalog entry per distinct model before anything is submitted:
2295
- // the guardrail must know real per-token rates, and every lens requires
2296
- // structured-output support that not all batch models offer. Lenses may run
2297
- // on different models (config `lens_models`), so each one is pre-flighted.
2298
- const config = await loadBroadsideConfig(broadsideDir);
2299
- const redact = config.redactSecrets;
2300
- const info = await collectRepoInfo(cwd, { redact });
2301
- // Before pricing, before the network, before any state write: a run on a
2302
- // language the lenses cannot scan used to submit empty batches and pay for
2303
- // them (#250).
2304
- if (info.language === "unknown") {
2305
- throw new Error(`Broad-Side could not tell what language this repository is: no ${MANIFEST_CANDIDATES.map(([candidate]) => candidate).join(", ")} ` +
2306
- `and no source files in a language the lenses can scan (${BROADSIDE_LANGUAGES.join(", ")}). Nothing was submitted.`);
2307
- }
2308
- if (info.sourceFileCount === 0) {
2309
- throw new Error(`Broad-Side found no ${info.language} source files to scan (detected from ${info.manifest?.path ?? "the file counts"}; ` +
2310
- `the lenses look for ${info.sourceExts.join(", ")}). Nothing was submitted.`);
2311
- }
2312
- const lensModels = { ...config.lensModels, ...opts.lensModels };
2313
- const modelForLens = (lensId) => lensModels[lensId] ?? model;
2314
- const resolved = new Map();
2315
- for (const candidate of new Set([model, ...lensIds.map(modelForLens)])) {
2316
- const catalog = await resolveCatalogEntry(broadsideDir, config, candidate, apiKey, opts.fetcher);
2317
- const entry = catalog.entry;
2318
- const supportsStructuredOutputs = entry.supportedParameters.length === 0 ||
2319
- entry.supportedParameters.some((p) => ["structured_outputs", "json_schema", "response_format", "structuredoutputs"].includes(p.toLowerCase()));
2320
- if (!supportsStructuredOutputs) {
2321
- throw new Error(`Batch model "${candidate}" does not advertise structured-output support ` +
2322
- `(supported_parameters: ${entry.supportedParameters.join(", ") || "unknown"}), but every ` +
2323
- "Broad-Side lens requires json_schema response_format. Choose another batch model " +
2324
- "(codecarto_broadside action 'models') or pass a pricing override only if you know it works.");
2325
- }
2326
- resolved.set(candidate, {
2327
- entry,
2328
- supportsStructuredOutputs,
2329
- pricing: { inputPerM: entry.inputPerM, outputPerM: entry.outputPerM, source: catalog.source },
2330
- // Respect the provider's completion ceiling: a request asking for more
2331
- // output than the model can produce fails the whole batch.
2332
- ...(entry.maxCompletionTokens !== undefined && { outputCap: entry.maxCompletionTokens }),
2333
- });
2334
- }
2335
- const pricing = resolved.get(model).pricing;
2336
- const outputCap = resolved.get(model).outputCap;
2337
- const defaultEntry = resolved.get(model).entry;
2338
- const limit = opts.maxCost ?? config.maxCost;
2339
- // Incremental re-scouting (#142): diff against the previous run's HEAD
2340
- // and scan only the modules whose files changed. Falls back to a full
2341
- // scan when there is no prior run, the tree is dirty, or the diff fails.
2342
- const sourceHead = await gitHead(cwd);
2343
- const sourceDirty = await gitDirty(cwd);
2344
- let baseHead = null;
2345
- let changed = null;
2346
- const incrementalOutcome = {
2347
- requested: opts.incremental === true,
2348
- applied: false,
2349
- baseHead: null,
2350
- };
2351
- if (opts.incremental) {
2352
- const state = await loadBroadsideState(broadsideDir);
2353
- // The baseline is the most recent run that recorded a HEAD — a
2354
- // submit-only run (never collected) is still a valid committed base.
2355
- const previous = [...state.runs].reverse().find((r) => r.sourceHead);
2356
- if (sourceDirty) {
2357
- incrementalOutcome.reason = "dirty-worktree";
2358
- }
2359
- else if (!previous?.sourceHead) {
2360
- incrementalOutcome.reason = "no-baseline";
2361
- }
2362
- else {
2363
- baseHead = previous.sourceHead;
2364
- changed = await changedFilesSince(cwd, baseHead);
2365
- incrementalOutcome.baseHead = baseHead;
2366
- if (changed)
2367
- incrementalOutcome.applied = true;
2368
- else
2369
- incrementalOutcome.reason = "diff-failed";
2370
- }
2371
- }
2372
- // Slice offline first so the estimate covers every request we would send.
2373
- const slicesByLens = new Map();
2374
- // Why a lens ended up with nothing to submit, for the report (see below).
2375
- const skipReasons = new Map();
2376
- let estimatedInputTokens = 0;
2377
- let estimatedOutputTokens = 0;
2378
- let estimatedTotalCost = 0;
2379
- const perLensEstimate = [];
2380
- // What the redaction pass did across every lens's slices, for the run
2381
- // record and the report: a value in a file shared by two lenses counts
2382
- // once per lens it was sent in, files once each.
2383
- let redactedValues = info.redactedValues;
2384
- const redactedFiles = new Set();
2385
- for (const lensId of lensIds) {
2386
- const lens = getLens(lensId);
2387
- let slices = await gatherSlices(cwd, lens, info, { redact });
2388
- for (const slice of slices) {
2389
- redactedValues += slice.redactedValues ?? 0;
2390
- for (const file of slice.redactedFiles ?? [])
2391
- redactedFiles.add(file);
2392
- }
2393
- const matchedBeforeIncremental = slices.length;
2394
- if (changed) {
2395
- // Repo-info slices (empty files, e.g. architecture) always run;
2396
- // file-backed slices run only when one of their files changed.
2397
- slices = slices.filter((s) => s.files.length === 0 || s.files.some((f) => changed.has(f)));
2398
- }
2399
- slicesByLens.set(lensId, slices);
2400
- if (slices.length === 0) {
2401
- const globs = lens.globsFor(info).filter(Boolean);
2402
- const fallbackGlobs = lens.fallbackGlobsFor?.(info).filter(Boolean) ?? [];
2403
- skipReasons.set(lensId, globs.length === 0
2404
- ? "the lens has no file patterns for this language"
2405
- : matchedBeforeIncremental > 0
2406
- ? "incremental: none of this lens's files changed since the previous run"
2407
- : `no files matched ${globs.join(", ")}` +
2408
- (fallbackGlobs.length > 0 ? ` or the fallback ${fallbackGlobs.join(", ")}` : "") +
2409
- (lens.skipTestFiles ? " (test files excluded)" : ""));
2410
- }
2411
- const lensModel = modelForLens(lensId);
2412
- const { pricing: lensPricing, outputCap: lensOutputCap } = resolved.get(lensModel);
2413
- const maxTokens = lensOutputCap ? Math.min(lens.maxTokens, lensOutputCap) : lens.maxTokens;
2414
- const estimate = estimateCost(lens, slices, lensPricing, maxTokens, info);
2415
- estimatedInputTokens += estimate.inputTokens;
2416
- estimatedOutputTokens += estimate.outputTokens;
2417
- estimatedTotalCost += estimate.cost;
2418
- perLensEstimate.push({
2419
- lens,
2420
- cost: estimate.cost,
2421
- maxTokens,
2422
- lensModel,
2423
- lensPricing,
2424
- ...(lensOutputCap !== undefined && { lensOutputCap }),
2425
- });
2426
- }
2427
- const exceedsLimit = limit > 0 && estimatedTotalCost > limit;
2428
- if (opts.confirm) {
2429
- const approved = await opts.confirm({
2430
- model,
2431
- pricing,
2432
- lenses: perLensEstimate.map(({ lens, cost, maxTokens, lensModel, lensPricing }) => {
2433
- const fallback = (slicesByLens.get(lens.id) ?? []).find((slice) => slice.fallback)?.fallback;
2434
- return {
2435
- lensId: lens.id,
2436
- name: lens.name,
2437
- slices: (slicesByLens.get(lens.id) ?? []).length,
2438
- maxTokens,
2439
- cost,
2440
- model: lensModel,
2441
- pricing: lensPricing,
2442
- ...(fallback && { fallback }),
2443
- };
2444
- }),
2445
- mixedModels: perLensEstimate.some(({ lensModel }) => lensModel !== model),
2446
- totalCost: estimatedTotalCost,
2447
- inputTokens: estimatedInputTokens,
2448
- outputTokens: estimatedOutputTokens,
2449
- maxCost: limit,
2450
- exceedsLimit,
2451
- baseHead,
2452
- sourceDirty,
2453
- incremental: incrementalOutcome,
2454
- ...(outputCap !== undefined && { outputCap }),
2455
- });
2456
- if (!approved)
2457
- throw new BroadsideCancelledError();
2458
- }
2459
- else if (exceedsLimit && !opts.force) {
2460
- const breakdown = perLensEstimate
2461
- .map(({ lens, cost, lensModel }) => ` ${lens.name}: ~$${cost.toFixed(4)}${lensModel === model ? "" : ` (${lensModel})`}`)
2462
- .join("\n");
2463
- throw new Error(`Estimated Broad-Side cost ~$${estimatedTotalCost.toFixed(4)} exceeds the run limit ` +
2464
- `$${limit.toFixed(2)}. Nothing was submitted.\nBreakdown:\n${breakdown}\n` +
2465
- `Pass force: true to submit anyway, or raise max_cost in .codecarto/broadside/config.yaml.`);
2466
- }
2467
- // Read before anything is posted: a state.json that cannot be read refuses
2468
- // the run here (#233), while persistBroadsideRun below merges by run id.
2469
- await loadBroadsideState(broadsideDir);
2470
- const runId = new Date().toISOString().replace(/[:.]/g, "-");
2471
- const run = {
2472
- id: runId,
2473
- createdAt: new Date().toISOString(),
2474
- model,
2475
- lenses: [...lensIds],
2476
- status: "in-flight",
2477
- outputDir: runId,
2478
- batches: {},
2479
- synthesis: { status: "pending" },
2480
- triage: { status: "pending" },
2481
- pricing,
2482
- maxCost: limit > 0 ? limit : undefined,
2483
- outputCap,
2484
- sourceHead,
2485
- sourceDirty,
2486
- baseHead,
2487
- snapshot: info.snapshot,
2488
- language: info.language,
2489
- redaction: {
2490
- enabled: redact,
2491
- values: redactedValues,
2492
- files: redactedFiles.size,
2493
- skippedFiles: info.secretFilesSkipped.length,
2494
- },
2495
- };
2496
- await persistBroadsideRun(broadsideDir, run);
2497
- const requestsByCustomId = {};
2498
- const submissions = [];
2499
- // Submit from the estimate rather than recomputing: the user approved that
2500
- // breakdown, so the request that fires must be the one that was priced.
2501
- for (const priced of perLensEstimate) {
2502
- const { lens, maxTokens, lensModel, lensOutputCap } = priced;
2503
- const lensId = lens.id;
2504
- const slices = slicesByLens.get(lensId) ?? [];
2505
- const requests = slices.map((sl, i) => buildBatchRequest(lens, info, sl, i, slices.length, lensModel, maxTokens, config.reasoning ?? undefined));
2506
- for (const request of requests)
2507
- requestsByCustomId[request.custom_id] = request;
2508
- const fallback = slices.find((slice) => slice.fallback)?.fallback;
2509
- const entry = {
2510
- batchId: "",
2511
- requests: requests.length,
2512
- status: "submitting",
2513
- submittedAt: new Date().toISOString(),
2514
- estimatedCost: priced.cost,
2515
- // A scan of the fallback scope is recorded as such (#319).
2516
- ...(fallback && { fallback }),
2517
- // Recorded per lens so collect's truncation retry re-submits against
2518
- // the model and ceiling this lens actually used, not the run default.
2519
- ...(lensModel !== model && { model: lensModel }),
2520
- ...(lensOutputCap !== undefined && { outputCap: lensOutputCap }),
2521
- };
2522
- run.batches[lensId] = entry;
2523
- if (requests.length === 0) {
2524
- // No files matched the lens's globs. That is a coverage gap to
2525
- // report, not a batch to submit — the API rejects empty batches.
2526
- // Name the globs: a JavaScript service whose server lives at
2527
- // src/server.js gets no security review (that lens reads server/**,
2528
- // **/auth*, **/middleware/**), and "skipped (0 request(s))" alone
2529
- // read as an empty repository rather than a lens that looked in
2530
- // the wrong place.
2531
- entry.status = "skipped";
2532
- const reason = skipReasons.get(lensId);
2533
- if (reason)
2534
- entry.reason = reason;
2535
- continue;
2536
- }
2537
- submissions.push((async () => {
2538
- // A network-level throw (DNS, abort, TLS) must not strand the
2539
- // entry in "submitting" forever — allSettled would swallow the
2540
- // rejection and collect would never see a terminal status.
2541
- try {
2542
- const { batchId, status, error } = await submitBatch(requests, apiKey, opts.fetcher, lensModel);
2543
- entry.batchId = batchId;
2544
- entry.status = status;
2545
- if (error)
2546
- entry.error = error;
2547
- }
2548
- catch (error) {
2549
- entry.status = "rejected";
2550
- entry.error = error instanceof Error ? error.message : String(error);
2551
- }
2552
- })());
2553
- }
2554
- await Promise.allSettled(submissions);
2555
- // A run with no batch behind it has nothing in flight. Every lens was
2556
- // skipped or refused, so no poll will ever complete it; leaving it
2557
- // "in-flight" had status listing a refused run above the completed ones
2558
- // with synthesis and triage "pending" forever.
2559
- if (!Object.values(run.batches).some((entry) => entry.batchId))
2560
- run.status = "failed";
2561
- await persistBroadsideRun(broadsideDir, run);
2562
- // What the provider just said about each model's batch endpoint outlives
2563
- // the run: the `models` action reads it back (#141).
2564
- await recordBatchEndpoints(broadsideDir, lensIds
2565
- .map((lensId) => run.batches[lensId])
2566
- .filter((entry) => Boolean(entry) && entry.status !== "skipped")
2567
- .map((entry) => ({ model: entry.model ?? model, batchId: entry.batchId, error: entry.error })));
2568
- // Persist the exact request bodies so collect can re-submit a truncated
2569
- // slice (bumped output cap) without re-walking the repo (#133). The run
2570
- // dir is created here rather than waiting for collect so a crash between
2571
- // submit and collect still leaves the retry input on disk.
2572
- const runDir = join(broadsideDir, runId);
2573
- await mkdir(runDir, { recursive: true });
2574
- await writeFile(join(runDir, "requests.json"), `${JSON.stringify(requestsByCustomId, null, "\t")}\n`, "utf8");
2575
- return {
2576
- runId,
2577
- outputDir: join(".codecarto", BROADSIDE_DIR, runId),
2578
- batches: run.batches,
2579
- estimatedTotalCost,
2580
- estimatedInputTokens,
2581
- estimatedOutputTokens,
2582
- pricing,
2583
- maxCost: limit > 0 ? limit : undefined,
2584
- // modelInfo describes the run's default model. Per-lens overrides are
2585
- // recorded on their own batch entries.
2586
- modelInfo: {
2587
- contextLength: defaultEntry.contextLength,
2588
- maxCompletionTokens: defaultEntry.maxCompletionTokens,
2589
- supportsStructuredOutputs: defaultEntry.supportedParameters.length === 0
2590
- ? undefined
2591
- : resolved.get(model).supportsStructuredOutputs,
2592
- expirationDate: defaultEntry.expirationDate ?? null,
2593
- },
2594
- incremental: incrementalOutcome,
2595
- repo: {
2596
- language: info.language,
2597
- sourceFiles: info.sourceFileCount,
2598
- snapshot: info.snapshot,
2599
- sourceHead,
2600
- sourceDirty,
2601
- },
2602
- redaction: {
2603
- enabled: redact,
2604
- values: redactedValues,
2605
- files: redactedFiles.size,
2606
- skippedFiles: info.secretFilesSkipped,
2607
- },
2608
- };
2609
- }
2610
- function extractContent(result) {
2611
- const response = result.response;
2612
- if (!response?.body)
2613
- return null;
2614
- const body = response.body;
2615
- const choices = body.choices;
2616
- const message = choices?.[0]?.message;
2617
- return typeof message?.content === "string" ? message.content : null;
2618
- }
2619
- /**
2620
- * Parse lens content as JSON, tolerating the markdown code fences some models
2621
- * wrap structured output in (the same tolerance OpenRouter's headless-agent
2622
- * scaffold ships for --output-schema). Returns null when the content is not
2623
- * JSON at all — which for a strict json_schema request means the output was
2624
- * truncated at max_tokens, not that the model chose prose.
2625
- */
2626
- export function parseLensJson(content) {
2627
- const trimmed = content.trim();
2628
- const fenced = /^```(?:json)?\s*\n?([\s\S]*?)\n?```\s*$/.exec(trimmed);
2629
- const candidate = fenced ? fenced[1].trim() : trimmed;
2630
- if (!candidate.startsWith("{") && !candidate.startsWith("["))
2631
- return null;
2632
- try {
2633
- return JSON.parse(candidate);
2634
- }
2635
- catch {
2636
- return null;
2637
- }
2638
- }
2639
- export async function saveLensResults(runDir, lensId, batch) {
2640
- const results = Array.isArray(batch.results) ? batch.results : [];
2641
- const out = [];
2642
- for (const result of results) {
2643
- const customId = String(result.custom_id ?? "unknown");
2644
- const content = extractContent(result);
2645
- if (content === null) {
2646
- if (result.error) {
2647
- await writeFile(join(runDir, `${sanitizeId(customId)}.error.json`), `${JSON.stringify(result.error, null, "\t")}\n`, "utf8");
2648
- }
2649
- continue;
2650
- }
2651
- const parsed = parseLensJson(content);
2652
- const truncated = parsed === null;
2653
- if (parsed !== null) {
2654
- await writeFile(join(runDir, `${sanitizeId(customId)}.json`), `${JSON.stringify(parsed, null, "\t")}\n`, "utf8");
2655
- }
2656
- else {
2657
- // Save the raw bytes verbatim so nothing is lost, but name the
2658
- // gap: an unparseable strict-schema response is a truncation.
2659
- await writeFile(join(runDir, `${sanitizeId(customId)}.json`), `${content}\n`, "utf8");
2660
- }
2661
- await writeFile(join(runDir, `${sanitizeId(customId)}.md`), renderFindingsMarkdown(content), "utf8");
2662
- out.push({
2663
- lensId,
2664
- customId,
2665
- moduleName: String(customId).replace(/^[a-z]+-/, ""),
2666
- content,
2667
- raw: result,
2668
- truncated,
2669
- });
2670
- }
2671
- return out;
2672
- }
2673
- /**
2674
- * Rebuild lens results from what a previous collect already wrote to disk.
2675
- *
2676
- * The post-passes are gated on having lens findings in hand, and a collect
2677
- * only holds the ones *it* polled. When an earlier collect saved every lens
2678
- * and then died before synthesis and triage ran — the batch window is long
2679
- * and a poll can easily be interrupted — the next collect finds every lens
2680
- * already terminal, skips them all, and would otherwise reach the post-pass
2681
- * gate with nothing to hand it. Reading the saved results back is what makes
2682
- * "a resumed collect can finish whichever is still pending" true.
2683
- */
2684
- export async function loadSavedLensResults(runDir, lenses) {
2685
- if (!(await pathExists(runDir)))
2686
- return [];
2687
- const reserved = new Set(["requests.json", "run-meta.json", "synthesis.json", "triage.json"]);
2688
- const out = [];
2689
- // Longest lens id first: no id is a prefix of another today, but ordering
2690
- // keeps that from becoming a silent misattribution if one ever is.
2691
- const ordered = [...lenses].sort((a, b) => b.length - a.length);
2692
- for (const name of (await readdir(runDir)).sort()) {
2693
- if (!name.endsWith(".json") || name.endsWith(".error.json") || reserved.has(name) || name.startsWith("raw-"))
2694
- continue;
2695
- const customId = name.slice(0, -".json".length);
2696
- const lensId = ordered.find((id) => customId === id || customId.startsWith(`${id}-`));
2697
- if (!lensId)
2698
- continue;
2699
- const content = await readFile(join(runDir, name), "utf8").catch(() => null);
2700
- if (content === null)
2701
- continue;
2702
- out.push({
2703
- lensId,
2704
- customId,
2705
- moduleName: customId.replace(/^[a-z]+-/, ""),
2706
- content,
2707
- raw: {},
2708
- truncated: parseLensJson(content) === null,
2709
- });
2710
- }
2711
- return out;
2712
- }
2713
- async function loadStoredRequests(runDir) {
2714
- const path = join(runDir, "requests.json");
2715
- if (!(await pathExists(path)))
2716
- return {};
2717
- try {
2718
- const parsed = JSON.parse(await readFile(path, "utf8"));
2719
- return parsed && typeof parsed === "object" ? parsed : {};
2720
- }
2721
- catch {
2722
- return {};
2723
- }
2724
- }
2725
- // ---------- post-lens passes: synthesis + triage ----------
2726
- function buildSynthesisRequest(findingsText, truncatedNote, model) {
2727
- return {
2728
- custom_id: "synthesis",
2729
- body: {
2730
- model,
2731
- messages: [
2732
- {
2733
- role: "system",
2734
- content: "You are a technical editor synthesizing multiple analysis reports about a single " +
2735
- "codebase into one coherent summary. The reports come from different lenses — " +
2736
- "architecture, API surface, security review, defect scanning, convention extraction, " +
2737
- "and porting assessment. Cross-reference findings across lenses: if a security issue " +
2738
- "also appears as a defect, merge them. Produce a JSON object following the " +
2739
- "synthesis_report schema. Prioritize the most actionable findings. " +
2740
- "Be honest about gaps — if a lens found nothing, say 'no issues found' rather than " +
2741
- "inventing problems. These are scouting signals from a batch model, not verified " +
2742
- "claims; note that in the summary.",
2743
- },
2744
- {
2745
- role: "user",
2746
- content: "Synthesize these analysis reports into a single summary.\n\n" +
2747
- findingsText +
2748
- truncatedNote +
2749
- "\nReturn the synthesis_report JSON schema.",
2750
- },
2751
- ],
2752
- response_format: { type: "json_schema", json_schema: SCHEMAS.synthesis },
2753
- max_tokens: 12_000,
2754
- },
2755
- };
2756
- }
2757
- function buildTriageRequest(findingsText, truncatedNote, model) {
2758
- return {
2759
- custom_id: "triage",
2760
- body: {
2761
- model,
2762
- messages: [
2763
- {
2764
- role: "system",
2765
- content: "You are a senior engineering lead turning unverified scouting findings into a " +
2766
- "prioritized work order. Given the findings below, produce a JSON object following " +
2767
- "the triage_report schema. Score every lead by impact and fix difficulty, assign a " +
2768
- "priority (P0 urgent/safety-critical to P3 nice-to-have), give a rough effort " +
2769
- "estimate, group the queue by module where sensible, and justify each call in the " +
2770
- "rationale. Merge duplicate leads instead of listing them twice. Drop leads that are " +
2771
- "too vague to act on and record each drop in omitted with the reason. These findings " +
2772
- "are UNVERIFIED scouting signals from a cheap batch model: the queue is a starting " +
2773
- "point for re-verification, not a commitment — say so in the summary, and never " +
2774
- "inflate a severity you cannot see evidence for.",
2775
- },
2776
- {
2777
- role: "user",
2778
- content: "Triage these scouting findings into a prioritized work order.\n\n" +
2779
- findingsText +
2780
- truncatedNote +
2781
- "\nReturn the triage_report JSON schema.",
2782
- },
2783
- ],
2784
- response_format: { type: "json_schema", json_schema: SCHEMAS.triage },
2785
- max_tokens: 10_000,
2786
- },
2787
- };
2788
- }
2789
- function parseTriageItems(content) {
2790
- try {
2791
- const parsed = JSON.parse(content);
2792
- const items = Array.isArray(parsed.items) ? parsed.items : [];
2793
- return items
2794
- .filter((item) => typeof item.title === "string")
2795
- .map((item) => ({
2796
- title: String(item.title),
2797
- severity: String(item.severity ?? "unknown"),
2798
- module: String(item.module ?? "unknown"),
2799
- impact: (["high", "medium", "low"].includes(String(item.impact)) ? String(item.impact) : "medium"),
2800
- difficulty: (["high", "medium", "low"].includes(String(item.difficulty)) ? String(item.difficulty) : "medium"),
2801
- priority: String(item.priority ?? "?"),
2802
- effort_estimate: String(item.effort_estimate ?? ""),
2803
- rationale: String(item.rationale ?? ""),
2804
- }));
2805
- }
2806
- catch {
2807
- return [];
2808
- }
2809
- }
2810
- export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2811
- const broadsideDir = broadsideDirFor(cwd);
2812
- const state = await loadBroadsideState(broadsideDir);
2813
- const run = opts.runId ? state.runs.find((candidate) => candidate.id === opts.runId) : state.runs[state.runs.length - 1];
2814
- if (!run) {
2815
- if (opts.runId) {
2816
- const known = state.runs.map((candidate) => candidate.id);
2817
- throw new Error(`No Broad-Side run with id ${opts.runId}. ` +
2818
- (known.length > 0 ? `Recorded runs: ${known.join(", ")}.` : "No runs are recorded; call codecarto_broadside with action 'submit' first."));
2819
- }
2820
- throw new Error("No Broad-Side run recorded. Call codecarto_broadside with action 'submit' first.");
2821
- }
2822
- const runDir = join(broadsideDir, run.outputDir);
2823
- await mkdir(runDir, { recursive: true });
2824
- // The spending slots this collect has claimed (#322); only a claimed slot
2825
- // is ever submitted from here. Every write-back merges with the file, so a
2826
- // slot another collect has moved further along is never overwritten.
2827
- const owned = new Set();
2828
- const persist = () => persistBroadsideRunMerging(broadsideDir, run);
2829
- const aborted = () => opts.signal?.aborted === true;
2830
- const deadline = Date.now() + (opts.waitMs ?? BROADSIDE_DEFAULT_POLL_BUDGET_MS);
2831
- let totalCost = 0;
2832
- let resultCount = 0;
2833
- let truncatedCount = 0;
2834
- const lensOutcomes = {};
2835
- const allLensResults = [];
2836
- // Terminal entries are settled already; everything else polls in parallel
2837
- // against one shared deadline (#136), then results save in lens order so
2838
- // output layout stays deterministic.
2839
- const inFlight = [];
2840
- for (const lensId of run.lenses) {
2841
- const entry = run.batches[lensId];
2842
- if (!entry || !entry.batchId) {
2843
- lensOutcomes[lensId] = { status: entry?.status ?? "failed", resultCount: 0 };
2844
- continue;
2845
- }
2846
- if (BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status)) {
2847
- totalCost += entry.cost ?? 0;
2848
- resultCount += entry.resultCount ?? 0;
2849
- lensOutcomes[lensId] = { status: entry.status, cost: entry.cost, resultCount: entry.resultCount };
2850
- continue;
2851
- }
2852
- inFlight.push({ lensId, batchId: entry.batchId });
2853
- }
2854
- const polled = await pollBatchesConcurrently(inFlight, apiKey, {
2855
- deadlineMs: Math.max(0, deadline - Date.now()),
2856
- fetcher: opts.fetcher,
2857
- pollIntervalMs: opts.pollIntervalMs,
2858
- signal: opts.signal,
2859
- onStatus: opts.onStatus,
2860
- });
2861
- for (const { lensId } of inFlight) {
2862
- const entry = run.batches[lensId];
2863
- if (!entry)
2864
- continue;
2865
- const batch = polled.get(entry.batchId) ?? { id: entry.batchId, status: "timeout" };
2866
- const status = String(batch.status ?? "unknown");
2867
- entry.status = status;
2868
- if (status === "completed") {
2869
- const usage = (batch.usage ?? {});
2870
- const cost = typeof usage.cost === "number" ? usage.cost : undefined;
2871
- entry.cost = cost;
2872
- entry.completedAt = new Date().toISOString();
2873
- const stored = await saveLensResults(runDir, lensId, batch);
2874
- entry.resultCount = stored.length;
2875
- const truncated = stored.filter((s) => s.truncated).length;
2876
- allLensResults.push(...stored);
2877
- resultCount += stored.length;
2878
- truncatedCount += truncated;
2879
- totalCost += cost ?? 0;
2880
- await writeFile(join(runDir, `raw-${lensId}.json`), `${JSON.stringify(batch, null, "\t")}\n`, "utf8");
2881
- // A batch can complete with every request failed — the account's
2882
- // concurrent-job quota filling after acceptance does exactly this.
2883
- // The per-request errors are on disk as `<id>.error.json`, but a
2884
- // lens reporting "completed, 0 result(s)" with the reason buried
2885
- // there read as an empty repository rather than a refused run.
2886
- const results = Array.isArray(batch.results) ? batch.results : [];
2887
- const failed = results.filter((r) => r.error && extractContent(r) === null);
2888
- const allFailed = stored.length === 0 && failed.length > 0
2889
- ? `all ${failed.length} request(s) failed: ${explainBatchError(failed[0].error)}`
2890
- : null;
2891
- if (allFailed)
2892
- entry.error = allFailed;
2893
- lensOutcomes[lensId] = { status, cost: entry.cost, resultCount: entry.resultCount, truncated, ...(allFailed && { error: allFailed }) };
2894
- }
2895
- else {
2896
- // Every non-completed outcome still has to reach the report.
2897
- // `lensOutcomes` is what the caller renders, and this branch used to
2898
- // require `batch.error` — but the commonest failure here is the
2899
- // synthetic `{ status: "timeout" }` the poll returns when its budget
2900
- // expires with the batch still in flight, and that carries no error.
2901
- // A lens that never came back was therefore omitted entirely,
2902
- // indistinguishable in the output from one that was never requested.
2903
- if (batch.error)
2904
- entry.error = batch.error;
2905
- const error = explainBatchError(batch.error);
2906
- lensOutcomes[lensId] = { status, cost: entry.cost, resultCount: entry.resultCount, ...(error && { error }) };
2907
- }
2908
- await persist();
2909
- }
2910
- // #133: re-submit truncated slices once with a bumped output cap and low
2911
- // reasoning effort. Batch requests are pure, so re-running is always safe;
2912
- // the aim is to recover coverage the first pass lost to a max_tokens
2913
- // cutoff, not to loop forever. Low effort because the cutoff is usually
2914
- // thinking, and a doubled budget doubled the thinking where a token cap
2915
- // was ignored (see retryReasoningFor).
2916
- //
2917
- // All bumped requests for one model go out as ONE batch, and the batches
2918
- // (one per model, since a batch carries a single model) are polled
2919
- // together against the shared deadline. Each truncated slice used to be
2920
- // submitted and polled to terminal before the next was submitted, so a
2921
- // model that truncated 11 of 13 slices turned a five-minute collect into
2922
- // eleven sequential round trips — the serialization #136 removed from the
2923
- // lens pass, still present here (#206). Grouping also keeps the retry to
2924
- // one job per model against OpenRouter's 16-concurrent-job quota.
2925
- let retriedCount = 0;
2926
- let retryElsewhere = false;
2927
- // A collect that polled nothing — every lens already terminal — still owes
2928
- // the retry if the collect that saved the results never got to it (it
2929
- // died, or its client did: #322). Read the saved results back and let the
2930
- // claim decide; a recovered slice re-parses clean, so this costs nothing
2931
- // once the retry has run.
2932
- if (opts.retryTruncated !== false && allLensResults.length === 0 && !aborted()) {
2933
- const everyLensTerminal = run.lenses.every((lensId) => {
2934
- const entry = run.batches[lensId];
2935
- return entry && BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status);
2936
- });
2937
- if (everyLensTerminal) {
2938
- const restored = await loadSavedLensResults(runDir, run.lenses);
2939
- if (restored.some((s) => s.truncated)) {
2940
- allLensResults.push(...restored);
2941
- truncatedCount = restored.filter((s) => s.truncated).length;
2942
- }
2943
- }
2944
- }
2945
- if (opts.retryTruncated !== false && truncatedCount > 0 && !aborted()) {
2946
- // Claim the pass before spending: a second collect on this run finds the
2947
- // claim and leaves the retry to the first (#322). A retry another
2948
- // collect has already settled is not run again — its truncation is
2949
- // what it is.
2950
- if (await claimRunSlot(broadsideDir, run, "retry"))
2951
- owned.add("retry");
2952
- else if (run.retry?.status === "submitted")
2953
- retryElsewhere = true;
2954
- }
2955
- if (opts.retryTruncated !== false && truncatedCount > 0 && owned.has("retry")) {
2956
- const requestsByCustomId = await loadStoredRequests(runDir);
2957
- const byModel = new Map();
2958
- for (const stored of allLensResults) {
2959
- if (!stored.truncated)
2960
- continue;
2961
- const original = requestsByCustomId[stored.customId];
2962
- if (!original)
2963
- continue;
2964
- const lensEntry = run.batches[stored.lensId];
2965
- // A lens may have run on its own model (config `lens_models`), with its
2966
- // own completion ceiling. Re-submitting against the run default would
2967
- // change the model mid-run and could exceed that lens's real ceiling.
2968
- const lensModel = lensEntry?.model ?? run.model;
2969
- const lensCap = lensEntry?.outputCap ?? run.outputCap;
2970
- const previousMax = original.body.max_tokens ?? getLens(stored.lensId).maxTokens;
2971
- const bumpedMax = lensCap ? Math.min(previousMax * 2, lensCap) : previousMax * 2;
2972
- if (bumpedMax <= previousMax)
2973
- continue; // already at the ceiling
2974
- const group = byModel.get(lensModel) ?? { requests: [], slices: new Map() };
2975
- group.requests.push({
2976
- ...original,
2977
- body: { ...original.body, max_tokens: bumpedMax, reasoning: retryReasoningFor(original.body.reasoning) },
2978
- });
2979
- group.slices.set(stored.customId, stored);
2980
- byModel.set(lensModel, group);
2981
- }
2982
- // Submit every group, then poll whatever was accepted, together.
2983
- const submitted = [];
2984
- for (const [model, group] of byModel) {
2985
- if (aborted())
2986
- break;
2987
- try {
2988
- const { batchId, error } = await submitBatch(group.requests, apiKey, opts.fetcher, model);
2989
- if (!error && batchId)
2990
- submitted.push({ model, batchId });
2991
- }
2992
- catch {
2993
- // A retry batch that fails to submit leaves its slices' original
2994
- // truncated results in place — nothing is lost.
2995
- }
2996
- }
2997
- // Record the ids under the claim so a later collect can see what was
2998
- // paid for, even if this one never returns. No group at all means every
2999
- // truncated slice was already at its model's ceiling: nothing to retry.
3000
- run.retry = {
3001
- ...run.retry,
3002
- batches: submitted,
3003
- status: submitted.length > 0 ? "submitted" : byModel.size === 0 ? "completed" : "failed",
3004
- };
3005
- await persist();
3006
- const polled = await pollBatchesConcurrently(submitted.map(({ model, batchId }) => ({ lensId: `retry:${model}`, batchId })), apiKey, {
3007
- // Share the caller's deadline. Each of these polls used to start a
3008
- // fresh 25-minute budget, so `wait_seconds` bounded only the lens
3009
- // poll and a collect could run for the caller's budget plus fifty
3010
- // minutes.
3011
- deadlineMs: Math.max(0, deadline - Date.now()),
3012
- fetcher: opts.fetcher,
3013
- pollIntervalMs: opts.pollIntervalMs,
3014
- signal: opts.signal,
3015
- onStatus: opts.onStatus,
3016
- });
3017
- for (const { model, batchId } of submitted) {
3018
- const batch = polled.get(batchId);
3019
- if (!batch || batch.status !== "completed")
3020
- continue;
3021
- const group = byModel.get(model);
3022
- const usage = (batch.usage ?? {});
3023
- totalCost += typeof usage.cost === "number" ? usage.cost : 0;
3024
- const results = Array.isArray(batch.results) ? batch.results : [];
3025
- for (const result of results) {
3026
- const stored = group.slices.get(String(result.custom_id ?? ""));
3027
- if (!stored)
3028
- continue;
3029
- const content = extractContent(result);
3030
- if (content === null)
3031
- continue;
3032
- const parsed = parseLensJson(content);
3033
- if (parsed === null)
3034
- continue; // still no good
3035
- await writeFile(join(runDir, `${sanitizeId(stored.customId)}.json`), `${JSON.stringify(parsed, null, "\t")}\n`, "utf8");
3036
- await writeFile(join(runDir, `${sanitizeId(stored.customId)}.md`), renderFindingsMarkdown(content), "utf8");
3037
- stored.content = content;
3038
- stored.truncated = false;
3039
- retriedCount += 1;
3040
- }
3041
- }
3042
- // Every retry batch reached a terminal status, or the poll ran out.
3043
- if (submitted.length > 0 && submitted.every(({ batchId }) => polled.get(batchId)?.status === "completed")) {
3044
- run.retry = { ...run.retry, status: "completed" };
3045
- }
3046
- truncatedCount = allLensResults.filter((s) => s.truncated).length;
3047
- for (const [lensId, outcome] of Object.entries(lensOutcomes)) {
3048
- if (outcome.truncated !== undefined) {
3049
- outcome.truncated = allLensResults.filter((s) => s.lensId === lensId && s.truncated).length;
3050
- }
3051
- }
3052
- await persist();
3053
- }
3054
- // Synthesis + triage: cross-lens post-passes, only after every lens batch
3055
- // is terminal. Triage turns the leads into a prioritized work order.
3056
- run.triage ??= { status: "pending" };
3057
- let topFindings = [];
3058
- let topTriageItems = [];
3059
- const wantSynthesis = opts.includeSynthesis !== false;
3060
- const wantTriage = opts.includeTriage !== false;
3061
- // A resumed collect polls nothing — every lens is already terminal — so the
3062
- // findings the post-passes need have to come back off disk, or a run whose
3063
- // first collect was interrupted could never produce its executive report
3064
- // and work order, however many times it was re-run.
3065
- const postPassUnfinished = (entry) => entry.status === "pending" || entry.status === "submitted";
3066
- if ((wantSynthesis || wantTriage) && allLensResults.length === 0
3067
- && (postPassUnfinished(run.synthesis) || postPassUnfinished(run.triage))) {
3068
- const restored = await loadSavedLensResults(runDir, run.lenses);
3069
- if (restored.length > 0) {
3070
- allLensResults.push(...restored);
3071
- truncatedCount = restored.filter((s) => s.truncated).length;
3072
- }
3073
- }
3074
- if ((wantSynthesis || wantTriage) && allLensResults.length > 0) {
3075
- const allTerminal = run.lenses.every((lensId) => {
3076
- const entry = run.batches[lensId];
3077
- return entry && BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status);
3078
- });
3079
- if (allTerminal && (postPassUnfinished(run.synthesis) || postPassUnfinished(run.triage))) {
3080
- const findingsText = allLensResults
3081
- .map((r) => `## ${r.lensId} — ${r.customId}\n\n${r.content}\n`)
3082
- .join("\n");
3083
- const truncatedNote = truncatedCount > 0
3084
- ? `\n\nNOTE: ${truncatedCount} lens result(s) were truncated at the output token limit and are ` +
3085
- "not included above. Any gap they would have covered is unrepresented — do not treat " +
3086
- "silence on a module as a clean bill.\n"
3087
- : "";
3088
- // Both post-passes consume the same findings; they run as two
3089
- // batches (different response_format schemas cannot share one)
3090
- // submitted together and polled in turn.
3091
- // Claim each wanted, still-pending pass before building its request:
3092
- // a second collect on this run adopts the first one's entry instead
3093
- // of submitting its own (#322). An abort submits nothing further.
3094
- const passes = [];
3095
- for (const kind of ["synthesis", "triage"]) {
3096
- const want = kind === "synthesis" ? wantSynthesis : wantTriage;
3097
- if (!want || aborted())
3098
- continue;
3099
- if ((kind === "synthesis" ? run.synthesis : run.triage).status !== "pending")
3100
- continue;
3101
- if (!(await claimRunSlot(broadsideDir, run, kind)))
3102
- continue;
3103
- owned.add(kind);
3104
- passes.push({
3105
- kind,
3106
- request: kind === "synthesis"
3107
- ? buildSynthesisRequest(findingsText, truncatedNote, run.model)
3108
- : buildTriageRequest(findingsText, truncatedNote, run.model),
3109
- entry: kind === "synthesis" ? run.synthesis : run.triage,
3110
- });
3111
- }
3112
- const submitted = new Map();
3113
- // A pass can be left at "submitted" when an earlier collect returned
3114
- // before its batch reached a terminal status — the batch still runs
3115
- // and is still charged, so the result exists and is simply unclaimed.
3116
- // Nothing above would ever look at it again: the pass list is built
3117
- // from "pending" entries only. Poll those regardless of the want
3118
- // flags, because the spend already happened and discarding a
3119
- // finished result is worse than saving one the caller opted out of.
3120
- for (const kind of ["synthesis", "triage"]) {
3121
- const entry = kind === "synthesis" ? run.synthesis : run.triage;
3122
- if (entry.status !== "submitted" || !entry.batchId)
3123
- continue;
3124
- if (submitted.has(entry.batchId))
3125
- continue;
3126
- submitted.set(entry.batchId, {
3127
- batchId: entry.batchId,
3128
- pass: { kind, request: undefined, entry },
3129
- });
3130
- }
3131
- await Promise.allSettled(passes.map(async (pass) => {
3132
- pass.entry.status = "submitted";
3133
- try {
3134
- const { batchId, error } = await submitBatch([pass.request], apiKey, opts.fetcher, run.model);
3135
- if (error) {
3136
- pass.entry.status = "failed";
3137
- return;
3138
- }
3139
- pass.entry.batchId = batchId;
3140
- submitted.set(batchId, { batchId, pass });
3141
- }
3142
- catch {
3143
- pass.entry.status = "failed";
3144
- }
3145
- }));
3146
- await persist();
3147
- // Poll both passes together against the shared deadline. Polled in
3148
- // turn, the first pass could spend the whole budget and leave the
3149
- // second a single poll (0.22.1 live run: triage settled, synthesis
3150
- // left running though it had been submitted at the same moment).
3151
- // A pass whose poll runs out stays `submitted`, so the batch is
3152
- // already paid for and a later collect claims its result.
3153
- const polledPasses = await pollBatchesConcurrently([...submitted.values()].map(({ batchId, pass }) => ({ lensId: pass.kind, batchId })), apiKey, {
3154
- deadlineMs: Math.max(0, deadline - Date.now()),
3155
- fetcher: opts.fetcher,
3156
- pollIntervalMs: opts.pollIntervalMs,
3157
- signal: opts.signal,
3158
- onStatus: opts.onStatus,
3159
- });
3160
- for (const { batchId, pass } of submitted.values()) {
3161
- const batch = polledPasses.get(batchId) ?? { id: batchId, status: "timeout" };
3162
- if (batch.status === "completed") {
3163
- const usage = (batch.usage ?? {});
3164
- const cost = typeof usage.cost === "number" ? usage.cost : undefined;
3165
- pass.entry.status = "completed";
3166
- pass.entry.cost = cost;
3167
- totalCost += cost ?? 0;
3168
- const results = Array.isArray(batch.results) ? batch.results : [];
3169
- const content = results.length > 0 ? extractContent(results[0]) : null;
3170
- if (content !== null) {
3171
- await writeFile(join(runDir, `${pass.kind}.json`), `${content}\n`, "utf8");
3172
- await writeFile(join(runDir, `${pass.kind}.md`), renderFindingsMarkdown(content), "utf8");
3173
- if (pass.kind === "synthesis") {
3174
- topFindings = parseSynthesisTopFindings(content);
3175
- }
3176
- else {
3177
- topTriageItems = parseTriageItems(content);
3178
- }
3179
- }
3180
- }
3181
- else if (BROADSIDE_DEAD_BATCH_STATUSES.includes(String(batch.status))) {
3182
- // The batch will never produce a result, so retire the pass.
3183
- // This used to require `batch.error`, leaving an expired or
3184
- // cancelled batch parked at "submitted" forever — and since a
3185
- // resumed collect re-polls anything still "submitted", it
3186
- // would re-poll a dead batch on every future run.
3187
- pass.entry.status = "failed";
3188
- if (batch.error)
3189
- pass.entry.error = batch.error instanceof Error ? batch.error.message : String(batch.error);
3190
- }
3191
- // A "timeout" is deliberately left at "submitted": the batch is
3192
- // still running server-side and has already been paid for, so a
3193
- // later collect should claim its result rather than discard it.
3194
- await persist();
3195
- }
3196
- }
3197
- }
3198
- const terminal = run.lenses.every((lensId) => {
3199
- const entry = run.batches[lensId];
3200
- return entry && BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status);
3201
- });
3202
- run.status = terminal ? (resultCount > 0 ? "completed" : "failed") : "partial";
3203
- run.totalCost = totalCost;
3204
- await persist();
3205
- await writeFile(join(runDir, "run-meta.json"), `${JSON.stringify({
3206
- experimental: true,
3207
- method: "Broad-Side (OpenRouter Batch API)",
3208
- model: run.model,
3209
- pricing: run.pricing,
3210
- max_cost: run.maxCost,
3211
- run_id: run.id,
3212
- created_at: run.createdAt,
3213
- status: run.status,
3214
- total_cost: totalCost,
3215
- result_count: resultCount,
3216
- truncated_count: truncatedCount,
3217
- retried_count: retriedCount,
3218
- synthesis: run.synthesis,
3219
- triage: run.triage,
3220
- lenses: run.lenses,
3221
- // Which lens ran on which model. Absent means the run default —
3222
- // a reader comparing two runs needs to know a lens changed model.
3223
- lens_models: Object.fromEntries(Object.entries(run.batches)
3224
- .filter(([, batch]) => batch?.model)
3225
- .map(([lensId, batch]) => [lensId, batch.model])),
3226
- disclaimer: "Findings are unverified scouting signals from a batch model, not validated claims. " +
3227
- "Re-verify every file:line lead with the interactive pipeline or by hand.",
3228
- }, null, "\t")}\n`, "utf8");
3229
- return {
3230
- runId: run.id,
3231
- status: run.status,
3232
- totalCost,
3233
- resultCount,
3234
- truncatedCount,
3235
- retriedCount,
3236
- ...(retryElsewhere && { retryElsewhere: true }),
3237
- lensOutcomes,
3238
- synthesis: run.synthesis,
3239
- triage: run.triage,
3240
- topFindings,
3241
- topTriageItems,
3242
- };
3243
- }
3244
- export async function runBroadsideStatus(cwd) {
3245
- const broadsideDir = broadsideDirFor(cwd);
3246
- const state = await loadBroadsideState(broadsideDir);
3247
- return { state };
3248
- }
3249
- // ---------- rendering ----------
3250
- export function renderFindingsMarkdown(content) {
3251
- const parsed = parseLensJson(content);
3252
- if (parsed === null)
3253
- return content;
3254
- return formatAsMarkdown(parsed);
3255
- }
3256
- function formatAsMarkdown(value, depth = 0) {
3257
- const indent = "\t".repeat(depth);
3258
- if (Array.isArray(value)) {
3259
- const lines = [];
3260
- for (let i = 0; i < value.length; i++) {
3261
- const item = value[i];
3262
- if (item && typeof item === "object") {
3263
- const title = (item.title ?? item.name ?? item.module ?? item.area ?? item.platform ?? "");
3264
- lines.push(`${indent}${i + 1}. ${title}`);
3265
- lines.push(formatAsMarkdown(item, depth + 1));
3266
- }
3267
- else {
3268
- lines.push(`${indent}- ${String(item)}`);
3269
- }
3270
- }
3271
- return lines.join("\n");
3272
- }
3273
- if (value && typeof value === "object") {
3274
- const lines = [];
3275
- for (const [key, entryValue] of Object.entries(value)) {
3276
- if (entryValue && typeof entryValue === "object") {
3277
- lines.push(`${indent}**${key}**:`);
3278
- lines.push(formatAsMarkdown(entryValue, depth + 1));
3279
- }
3280
- else {
3281
- lines.push(`${indent}- **${key}**: ${String(entryValue)}`);
3282
- }
3283
- }
3284
- return lines.join("\n");
3285
- }
3286
- return `${indent}${String(value)}`;
3287
- }
3288
- function parseSynthesisTopFindings(content) {
3289
- try {
3290
- const parsed = JSON.parse(content);
3291
- const findings = Array.isArray(parsed.top_findings)
3292
- ? parsed.top_findings
3293
- : [];
3294
- return findings
3295
- .filter((f) => typeof f.title === "string")
3296
- .map((f) => ({
3297
- title: String(f.title),
3298
- severity: String(f.severity ?? "unknown"),
3299
- sourceLens: String(f.source_lens ?? "unknown"),
3300
- summary: String(f.summary ?? ""),
3301
- }));
3302
- }
3303
- catch {
3304
- return [];
3305
- }
3306
- }
3307
- // ---------- formatting helpers for tool output ----------
3308
- export function describeIncrementalFallback(reason) {
3309
- switch (reason) {
3310
- case "dirty-worktree":
3311
- return "the working tree has uncommitted changes, so there is no committed state to diff against";
3312
- case "no-baseline":
3313
- return "no earlier run recorded a commit to diff against";
3314
- case "diff-failed":
3315
- return "the diff against the previous run's commit could not be read";
3316
- default:
3317
- return "no baseline was available";
3318
- }
3319
- }
3320
- export function estimateSubmitText(result, lenses) {
3321
- // Count the lenses that actually got a batch, not every lens considered. A
3322
- // lens with nothing to scan is reported as `skipped (0 request(s))` two
3323
- // lines below, so counting it here made the header contradict its own body:
3324
- // a Rust CLI with no server surface reported "submitted 6 batch(es)" over a
3325
- // list showing four batches and two skips.
3326
- const entries = Object.values(result.batches ?? {});
3327
- const submittedCount = entries.filter((entry) => entry.batchId).length;
3328
- const withoutBatch = entries.length - submittedCount;
3329
- const lines = [
3330
- withoutBatch > 0
3331
- ? `Broad-Side submitted ${submittedCount} batch(es); ${withoutBatch} lens(es) produced none (see below).`
3332
- : `Broad-Side submitted ${submittedCount} batch(es).`,
3333
- ];
3334
- for (const lens of lenses) {
3335
- const entry = result.batches[lens.id];
3336
- if (!entry)
3337
- continue;
3338
- const status = entry.batchId ? `batch ${entry.batchId}` : entry.status;
3339
- const override = entry.model ? ` on ${entry.model}` : "";
3340
- // A rejected lens says why: the message is the only way to tell a
3341
- // catalog id with no batch endpoint from a full job quota, and both
3342
- // used to read as a bare "rejected". A skipped lens names the globs
3343
- // that matched nothing.
3344
- const reason = !entry.batchId && entry.error
3345
- ? ` — ${explainBatchError(entry.error)}`
3346
- : !entry.batchId && entry.reason
3347
- ? ` — ${entry.reason}`
3348
- : entry.fallback
3349
- ? ` — ${entry.fallback}`
3350
- : "";
3351
- lines.push(` ${lens.name}: ${status} (${entry.requests} request(s), ~$${entry.estimatedCost.toFixed(4)})${override}${reason}`);
3352
- }
3353
- if (result.repo) {
3354
- const head = result.repo.sourceHead ? ` at ${result.repo.sourceHead.slice(0, 8)}${result.repo.sourceDirty ? " (dirty)" : ""}` : "";
3355
- const source = result.repo.snapshot === "working-tree" ? `working tree${head}` : "directory walk (not a git repository)";
3356
- lines.push(`Scanned as ${result.repo.language}: ${result.repo.sourceFiles} source file(s) from the ${source}.`);
3357
- }
3358
- if (result.redaction) {
3359
- const line = result.redaction.enabled
3360
- ? describeRedactions(result.redaction.values, result.redaction.files, result.redaction.skippedFiles)
3361
- : "Before upload: secret redaction is OFF (redact_secrets: false in config.yaml); files were sent as they are.";
3362
- if (line)
3363
- lines.push(line);
3364
- }
3365
- const incremental = result.incremental;
3366
- if (incremental?.requested) {
3367
- lines.push(incremental.applied
3368
- ? `Incremental: scanning only what changed since ${(incremental.baseHead ?? "").slice(0, 8)}.`
3369
- : `Incremental: requested but NOT applied — ${describeIncrementalFallback(incremental.reason)}. Every module was scanned, at full cost.`);
3370
- }
3371
- 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})`);
3372
- if (result.modelInfo.contextLength) {
3373
- lines.push(`Model: ${result.modelInfo.contextLength.toLocaleString()} context, ${result.modelInfo.maxCompletionTokens?.toLocaleString() ?? "?"} max output`);
3374
- }
3375
- if (result.modelInfo.supportsStructuredOutputs === false) {
3376
- lines.push("Warning: model does not advertise structured-output support; lens JSON may be unreliable.");
3377
- }
3378
- if (result.modelInfo.expirationDate) {
3379
- lines.push(`Warning: this model is deprecated (expires ${result.modelInfo.expirationDate}).`);
3380
- }
3381
- if (result.maxCost) {
3382
- lines.push(`Run limit: $${result.maxCost.toFixed(2)} (enforced on estimate; pass force to override)`);
3383
- }
3384
- 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.");
3385
- return lines.join("\n");
3386
- }
3387
- export function modelsText(entries, opts) {
3388
- const endpoints = opts.endpoints ?? {};
3389
- const lines = [
3390
- `Batch models on OpenRouter (${entries.length}, cheapest first).`,
3391
- // The catalog over-reports: it returns a `:batch` id for models whose
3392
- // Batch API refuses the job, with nothing in the entry to tell them
3393
- // apart (#141). Say so before the table, not after it.
3394
- "Advisory: this is the catalog's list of :batch ids, not a list of working batch endpoints. Some ids are refused at submit " +
3395
- "(\"does not have a :batch endpoint\"), at no cost. Rows tagged [no batch endpoint …] or [batch OK …] carry what this " +
3396
- "repository's own submits found; an untagged row has not been tried here.",
3397
- "",
3398
- "id | $/M in | $/M out | ctx | max out | structured | coding idx",
3399
- ];
3400
- for (const entry of entries) {
3401
- const bench = opts.benchmarks?.byBaseSlug[baseSlug(entry.id)];
3402
- const structured = entry.supportedParameters.length === 0
3403
- ? "?"
3404
- : entry.supportedParameters.some((p) => ["structured_outputs", "json_schema", "response_format", "structuredoutputs"].includes(p.toLowerCase()))
3405
- ? "yes"
3406
- : "no";
3407
- const coding = bench?.codingIndex !== undefined ? bench.codingIndex.toFixed(1) : "-";
3408
- const ctx = entry.contextLength
3409
- ? entry.contextLength >= 1_000_000
3410
- ? `${(entry.contextLength / 1_000_000).toFixed(1)}M`
3411
- : `${(entry.contextLength / 1024).toFixed(0)}k`
3412
- : "?";
3413
- const out = entry.maxCompletionTokens ? `${(entry.maxCompletionTokens / 1024).toFixed(0)}k` : "?";
3414
- const tag = entry.id === opts.defaultModel ? " (default)" : "";
3415
- const exp = entry.expirationDate ? " [deprecated]" : "";
3416
- const record = endpoints[entry.id];
3417
- const seen = record
3418
- ? record.status === "rejected"
3419
- ? ` [no batch endpoint, refused ${record.at.slice(0, 10)}]`
3420
- : ` [batch OK ${record.at.slice(0, 10)}]`
3421
- : "";
3422
- lines.push(`${entry.id}${tag}${exp}${seen} | ${entry.inputPerM.toFixed(3)} | ${entry.outputPerM.toFixed(3)} | ${ctx} | ${out} | ${structured} | ${coding}`);
3423
- }
3424
- if (opts.benchmarks?.meta.as_of) {
3425
- lines.push("", `Benchmarks: Artificial Analysis coding index (as of ${String(opts.benchmarks.meta.as_of)}).`);
3426
- }
3427
- 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 " +
3428
- ".codecarto/broadside/config.yaml for the repository. Higher coding index ≠ better scout: precision, context, structured-output " +
3429
- "support, and whether the model spends its output budget reasoning (see reasoning: in config.yaml) matter most here. " +
3430
- "A refused submit costs nothing, so probe an untried model on one lens first.");
3431
- return lines.join("\n");
3432
- }
3433
- /** One line of a batch's error field, whatever shape the provider gave it. */
3434
- function describeBatchError(error) {
3435
- if (error === undefined || error === null || error === "")
3436
- return null;
3437
- if (typeof error === "string")
3438
- return error.slice(0, 300);
3439
- if (typeof error === "object") {
3440
- const message = error.message;
3441
- if (typeof message === "string" && message)
3442
- return message.slice(0, 300);
3443
- // OpenRouter wraps a submit refusal as `{ error: { message } }`.
3444
- const nested = error.error;
3445
- if (nested && typeof nested === "object") {
3446
- const inner = nested.message;
3447
- if (typeof inner === "string" && inner)
3448
- return inner.slice(0, 300);
3449
- }
3450
- if (typeof nested === "string" && nested)
3451
- return nested.slice(0, 300);
3452
- try {
3453
- return JSON.stringify(error).slice(0, 300);
3454
- }
3455
- catch {
3456
- return String(error);
3457
- }
3458
- }
3459
- return String(error);
3460
- }
3461
- /**
3462
- * A provider refusal plus what to do about it, for the two refusals a batch
3463
- * run meets in practice and cannot fix by itself (#141):
3464
- *
3465
- * - `Model '<id>' does not have a :batch endpoint.` — the catalog advertises a
3466
- * `:batch` id that OpenRouter runs no batch endpoint for. Nothing in the
3467
- * catalog distinguishes these; the `models` action marks ids this
3468
- * repository has seen refused.
3469
- * - `job-submission-count … in use: 16, quota: 16` — the per-account limit
3470
- * on concurrent batch jobs. Broad-Side submits one job per lens, so a few
3471
- * runs in flight on the same key fill it; the refusal costs nothing.
3472
- */
3473
- export function explainBatchError(error) {
3474
- const message = describeBatchError(error);
3475
- if (!message)
3476
- return null;
3477
- if (NO_BATCH_ENDPOINT_RE.test(message)) {
3478
- 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).`;
3479
- }
3480
- if (BATCH_QUOTA_RE.test(message)) {
3481
- 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.`;
3482
- }
3483
- return message;
3484
- }
3485
- export function collectResultText(result) {
3486
- const lines = [
3487
- `Broad-Side run ${result.runId}: ${result.status}`,
3488
- ` Results: ${result.resultCount} | Total cost: $${result.totalCost.toFixed(6)}`,
3489
- ];
3490
- for (const lensId of BROADSIDE_LENS_IDS) {
3491
- const outcome = result.lensOutcomes[lensId];
3492
- if (!outcome)
3493
- continue;
3494
- const truncation = outcome.truncated ? `, ${outcome.truncated} truncated` : "";
3495
- lines.push(` ${lensId}: ${outcome.status}` +
3496
- (outcome.cost !== undefined ? `, $${outcome.cost.toFixed(6)}` : "") +
3497
- (outcome.resultCount !== undefined ? `, ${outcome.resultCount} result(s)` : "") +
3498
- truncation +
3499
- // The reason a lens did not complete, when the poll recorded one:
3500
- // an auth failure or a dead network used to read as a slow batch.
3501
- (outcome.error ? ` — ${outcome.error}` : ""));
3502
- }
3503
- if (result.retriedCount > 0) {
3504
- lines.push(` ↻ ${result.retriedCount} truncated result(s) recovered by re-submission with a doubled output cap.`);
3505
- }
3506
- if (result.retryElsewhere) {
3507
- lines.push(" ↻ The truncation retry is in flight in another collect on this run; collect again for its result.");
3508
- }
3509
- if (result.truncatedCount > 0) {
3510
- lines.push(` ⚠ ${result.truncatedCount} result(s) still truncated after retry — their modules are unscouted, not clean.`);
3511
- }
3512
- // A pass still in flight or retired must appear: a run reported
3513
- // "completed" with no synthesis line read as "no synthesis was run",
3514
- // when the batch was running and a later collect would have claimed it
3515
- // (0.22.1 live run — the collect's wait ran out during the pass).
3516
- const passInFlight = (kind, entry) => {
3517
- if (entry.status === "submitted") {
3518
- lines.push(` ${kind}: ${entry.batchId ? "still running" : "in flight in another collect"} — collect again for its result.`);
3519
- }
3520
- else if (entry.status === "failed") {
3521
- lines.push(` ${kind}: failed${entry.error ? ` — ${explainBatchError(entry.error)}` : ""}`);
3522
- }
3523
- };
3524
- if (result.synthesis.status === "completed") {
3525
- lines.push(` synthesis: completed, $${(result.synthesis.cost ?? 0).toFixed(6)}`);
3526
- if (result.topFindings.length > 0) {
3527
- lines.push("", "Top findings (unverified leads):");
3528
- for (const f of result.topFindings.slice(0, 10)) {
3529
- lines.push(` [${f.severity}] ${f.title}`);
3530
- }
3531
- }
3532
- }
3533
- else {
3534
- passInFlight("synthesis", result.synthesis);
3535
- }
3536
- if (result.triage.status === "completed") {
3537
- lines.push(` triage: completed, $${(result.triage.cost ?? 0).toFixed(6)}`);
3538
- if (result.topTriageItems.length > 0) {
3539
- lines.push("", "Triage — prioritized work order (re-verify before acting):");
3540
- for (const item of result.topTriageItems.slice(0, 10)) {
3541
- lines.push(` ${item.priority} [${item.severity}/${item.module}] ${item.title}` +
3542
- (item.effort_estimate ? ` (${item.effort_estimate})` : ""));
3543
- }
3544
- }
3545
- }
3546
- else {
3547
- passInFlight("triage", result.triage);
3548
- }
3549
- lines.push("", "Disclaimer: Broad-Side findings are unverified scouting signals from a batch model, not validated claims.");
3550
- return lines.join("\n");
3551
- }
3552
- /**
3553
- * An `onStatus` callback that appends one line to `lines` per *change* of a
3554
- * lens's polled status. Every poll used to append a line, so a four-minute
3555
- * wait returned twenty-six identical "in_progress (0/1)" lines per lens
3556
- * before the result (0.22.0 live run).
3557
- */
3558
- export function statusLineWriter(lines) {
3559
- const last = new Map();
3560
- return (lensId, status, counts) => {
3561
- const line = ` ${lensId}: ${status} (${counts.completed ?? 0}/${counts.total ?? "?"})`;
3562
- if (last.get(lensId) === line)
3563
- return;
3564
- last.set(lensId, line);
3565
- lines.push(line);
3566
- };
3567
- }
3568
- export function statusText(state) {
3569
- if (state.runs.length === 0) {
3570
- return "No Broad-Side runs recorded. Call codecarto_broadside with action 'submit' first.";
3571
- }
3572
- const lines = [];
3573
- for (const run of [...state.runs].reverse().slice(0, 3)) {
3574
- lines.push(`Run ${run.id} — ${run.status}`);
3575
- // Recorded since #248; a run from an older version has neither field.
3576
- if (run.language || run.snapshot) {
3577
- const head = run.sourceHead ? ` at ${run.sourceHead.slice(0, 8)}${run.sourceDirty ? " (dirty)" : ""}` : "";
3578
- const source = run.snapshot === "walk" ? "directory walk" : run.snapshot ? `working tree${head}` : "unknown source";
3579
- lines.push(` scanned as ${run.language ?? "unknown"} from the ${source}`);
3580
- }
3581
- for (const lensId of BROADSIDE_LENS_IDS) {
3582
- const entry = run.batches[lensId];
3583
- if (!entry)
3584
- continue;
3585
- lines.push(` ${lensId}: ${entry.status}${entry.batchId ? ` (${entry.batchId})` : ""}${entry.cost !== undefined ? `, $${entry.cost.toFixed(6)}` : ""}` +
3586
- (entry.status === "skipped" && entry.reason ? ` — ${entry.reason}` : entry.fallback ? ` — ${entry.fallback}` : ""));
3587
- }
3588
- lines.push(` synthesis: ${run.synthesis.status}`);
3589
- lines.push(` triage: ${run.triage?.status ?? "pending"}`);
3590
- if (run.verify) {
3591
- lines.push(` verify: ${run.verify.status} — ${run.verify.confirmed} confirmed of ${run.verify.verified} read on ${run.verify.model}, $${run.verify.cost.toFixed(4)}`);
3592
- }
3593
- if (run.totalCost !== undefined)
3594
- lines.push(` total cost: $${run.totalCost.toFixed(6)}`);
3595
- }
3596
- return lines.join("\n");
3597
- }
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";