codecartographer-pi 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codecarto/GUIDE.md +15 -2
- package/.codecarto/README.md +3 -0
- package/.codecarto/broadside/SKILL.md +143 -0
- package/.codecarto/broadside/config.yaml +104 -0
- package/.codecarto/findings/broadside-scout/README.md +20 -0
- package/.codecarto/findings/broadside-scout/SKILL.md +101 -0
- package/.codecarto/skills/spec-delta-application/SKILL.md +3 -1
- package/.codecarto/templates/backlog-project.md +51 -0
- package/.codecarto/templates/broadside-scout-brief.md +97 -0
- package/.codecarto/{THREAD_LOG.md → templates/thread-log.md} +2 -5
- package/.codecarto/workflow/pipeline-scout-first.yaml +271 -0
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/README.md +47 -2
- package/agent-skill/codecartographer/SKILL.md +3 -1
- package/agent-skill/codecartographer/references/broadside.md +115 -0
- package/agent-skill/codecartographer/references/library.md +1 -1
- package/agent-skill/codecartographer/references/pipeline-selection.md +14 -0
- package/dist/core/broadside.d.ts +421 -0
- package/dist/core/broadside.js +2349 -0
- package/dist/core/completion.js +20 -4
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/library.d.ts +22 -0
- package/dist/core/library.js +101 -1
- package/dist/core/orchestrator-config.js +5 -2
- package/dist/core/pipeline.js +1 -0
- package/dist/core/status.js +9 -1
- package/dist/core/utils.js +7 -1
- package/dist/core/workspace.d.ts +17 -0
- package/dist/core/workspace.js +68 -2
- package/dist/extensions/codecarto/agent-runner.js +6 -0
- package/dist/extensions/codecarto/broadside-flags.d.ts +21 -0
- package/dist/extensions/codecarto/broadside-flags.js +116 -0
- package/dist/extensions/codecarto/index.js +232 -4
- package/dist/mcp-server/server.d.ts +22 -0
- package/dist/mcp-server/server.js +218 -11
- package/package.json +10 -1
- package/.codecarto/BACKLOG.md +0 -184
- package/.codecarto/CHANGELOG-2026-05-02-feedback-pass.md +0 -118
- package/.codecarto/closeouts/2026-05-02-framework-feedback-pass.md +0 -111
|
@@ -0,0 +1,2349 @@
|
|
|
1
|
+
// Broad-Side: cheap batch reconnaissance over the OpenRouter Batch API.
|
|
2
|
+
//
|
|
3
|
+
// Broad-Side fires every analysis lens at a repository at once. Each lens is a
|
|
4
|
+
// single-turn prompt with a structured-output JSON schema, submitted as an
|
|
5
|
+
// asynchronous batch job (Google Gemini's batch endpoint, ~50% of sync pricing)
|
|
6
|
+
// and polled to completion. Results land in `.codecarto/broadside/<run>/` as
|
|
7
|
+
// JSON plus rendered markdown, and an optional synthesis pass cross-references
|
|
8
|
+
// every lens into one executive report.
|
|
9
|
+
//
|
|
10
|
+
// This is deliberately NOT the interactive CodeCartographer pipeline. The batch
|
|
11
|
+
// API is text-in/text-out: no filesystem access, no multi-turn exploration, no
|
|
12
|
+
// runtime verification. Broad-Side findings are unverified scouting signals —
|
|
13
|
+
// file:line leads that a real analysis (or a human) must confirm. That division
|
|
14
|
+
// of labor is the point: a ~$0.50 unattended sweep that tells the expensive
|
|
15
|
+
// interactive run where to look.
|
|
16
|
+
//
|
|
17
|
+
// Field shapes for the model catalog and benchmarks endpoints follow the
|
|
18
|
+
// official OpenRouter skills (OpenRouterTeam/skills: openrouter-models,
|
|
19
|
+
// openrouter-benchmarks).
|
|
20
|
+
//
|
|
21
|
+
// RESUBMISSION INVARIANT: batch requests are pure functions of their input —
|
|
22
|
+
// no tools, no filesystem, no side effects — so resubmitting a failed or
|
|
23
|
+
// truncated slice is always safe. This is the retry rule OpenRouter's own
|
|
24
|
+
// headless-agent scaffold states the hard way (retry only before tool calls,
|
|
25
|
+
// because replaying a mutating tool would double-execute it); here the rule is
|
|
26
|
+
// satisfied by construction. If Broad-Side ever gains server tools
|
|
27
|
+
// (openrouter:web_search etc.), this invariant becomes load-bearing and the
|
|
28
|
+
// resubmit path must gate on whether any tool executed.
|
|
29
|
+
//
|
|
30
|
+
// Broad-Side requires runtime code, so the feature itself lives on the
|
|
31
|
+
// executable surfaces (Pi and MCP), not the pure template. What the template does
|
|
32
|
+
// carry is the reading guide for its output — `.codecarto/broadside/SKILL.md`,
|
|
33
|
+
// served by codecarto_skill under the name `broadside` (see readBroadsideSkill).
|
|
34
|
+
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
35
|
+
import { execFile } from "node:child_process";
|
|
36
|
+
import { promisify } from "node:util";
|
|
37
|
+
import { join } from "node:path";
|
|
38
|
+
import { pathExists, sleep } from "./utils.js";
|
|
39
|
+
import { loadYamlFile } from "./yaml.js";
|
|
40
|
+
import { packagedWorkspaceDir } from "./workspace.js";
|
|
41
|
+
const execFileAsync = promisify(execFile);
|
|
42
|
+
// ---------- constants ----------
|
|
43
|
+
export const BROADSIDE_MODEL = "google/gemini-3.7-flash:batch";
|
|
44
|
+
export const BROADSIDE_BATCH_URL = "https://openrouter.ai/api/beta/batches";
|
|
45
|
+
export const BROADSIDE_DIR = "broadside"; // relative to .codecarto/
|
|
46
|
+
/** Name Broad-Side answers to on the skill surfaces. Not a post-pipeline skill — see readBroadsideSkill. */
|
|
47
|
+
export const BROADSIDE_SKILL_NAME = "broadside";
|
|
48
|
+
export const BROADSIDE_STATE_FILE = "state.json";
|
|
49
|
+
export const BROADSIDE_CONFIG_FILE = "config.yaml";
|
|
50
|
+
export const BROADSIDE_STATE_SCHEMA_VERSION = 1;
|
|
51
|
+
// Per-token pricing in USD (OpenRouter, google/gemini-3.7-flash:batch).
|
|
52
|
+
export const BROADSIDE_INPUT_PRICE_PER_M = 0.1875;
|
|
53
|
+
export const BROADSIDE_OUTPUT_PRICE_PER_M = 0.9375;
|
|
54
|
+
// OpenRouter's public model catalog; pricing, context, and capabilities live
|
|
55
|
+
// per model id. The benchmarks endpoint adds coding/intelligence indices.
|
|
56
|
+
export const BROADSIDE_MODELS_URL = "https://openrouter.ai/api/v1/models";
|
|
57
|
+
export const BROADSIDE_BENCHMARKS_URL = "https://openrouter.ai/api/v1/benchmarks";
|
|
58
|
+
export const BROADSIDE_CATALOG_CACHE_FILE = "model-catalog.json";
|
|
59
|
+
export const BROADSIDE_CATALOG_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
60
|
+
export const BROADSIDE_LENS_IDS = [
|
|
61
|
+
"architecture",
|
|
62
|
+
"api",
|
|
63
|
+
"security",
|
|
64
|
+
"defect",
|
|
65
|
+
"conventions",
|
|
66
|
+
"porting",
|
|
67
|
+
];
|
|
68
|
+
export const BROADSIDE_POLL_INTERVAL_MS = 15_000;
|
|
69
|
+
export const BROADSIDE_DEFAULT_POLL_BUDGET_MS = 25 * 60 * 1000;
|
|
70
|
+
/** Thrown when a confirm hook declines a run. Nothing was submitted. */
|
|
71
|
+
export class BroadsideCancelledError extends Error {
|
|
72
|
+
constructor(message = "Broad-Side submission cancelled. Nothing was submitted.") {
|
|
73
|
+
super(message);
|
|
74
|
+
this.name = "BroadsideCancelledError";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// ---------- JSON schemas (one per lens, plus synthesis) ----------
|
|
78
|
+
const SCHEMAS = {
|
|
79
|
+
architecture: {
|
|
80
|
+
name: "architecture_report",
|
|
81
|
+
strict: true,
|
|
82
|
+
schema: {
|
|
83
|
+
type: "object",
|
|
84
|
+
properties: {
|
|
85
|
+
tech_stack: {
|
|
86
|
+
type: "object",
|
|
87
|
+
properties: {
|
|
88
|
+
language: { type: "string" },
|
|
89
|
+
version: { type: "string" },
|
|
90
|
+
build_system: { type: "string" },
|
|
91
|
+
key_dependencies: { type: "array", items: { type: "string" } },
|
|
92
|
+
},
|
|
93
|
+
required: ["language", "build_system"],
|
|
94
|
+
additionalProperties: false,
|
|
95
|
+
},
|
|
96
|
+
module_architecture: {
|
|
97
|
+
type: "array",
|
|
98
|
+
items: {
|
|
99
|
+
type: "object",
|
|
100
|
+
properties: {
|
|
101
|
+
name: { type: "string" },
|
|
102
|
+
role: { type: "string" },
|
|
103
|
+
file_count: { type: "integer" },
|
|
104
|
+
depends_on: { type: "array", items: { type: "string" } },
|
|
105
|
+
},
|
|
106
|
+
required: ["name", "role"],
|
|
107
|
+
additionalProperties: false,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
data_flow: { type: "string" },
|
|
111
|
+
entry_points: { type: "array", items: { type: "string" } },
|
|
112
|
+
notable_patterns: { type: "array", items: { type: "string" } },
|
|
113
|
+
},
|
|
114
|
+
required: ["tech_stack", "module_architecture", "data_flow", "entry_points"],
|
|
115
|
+
additionalProperties: false,
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
api_surface: {
|
|
119
|
+
name: "api_surface_report",
|
|
120
|
+
strict: true,
|
|
121
|
+
schema: {
|
|
122
|
+
type: "object",
|
|
123
|
+
properties: {
|
|
124
|
+
endpoints: {
|
|
125
|
+
type: "array",
|
|
126
|
+
items: {
|
|
127
|
+
type: "object",
|
|
128
|
+
properties: {
|
|
129
|
+
method: { type: "string" },
|
|
130
|
+
path: { type: "string" },
|
|
131
|
+
handler: { type: "string" },
|
|
132
|
+
auth_required: { type: "boolean" },
|
|
133
|
+
description: { type: "string" },
|
|
134
|
+
},
|
|
135
|
+
required: ["method", "path", "handler", "auth_required"],
|
|
136
|
+
additionalProperties: false,
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
data_types: {
|
|
140
|
+
type: "array",
|
|
141
|
+
items: {
|
|
142
|
+
type: "object",
|
|
143
|
+
properties: {
|
|
144
|
+
name: { type: "string" },
|
|
145
|
+
kind: { type: "string" },
|
|
146
|
+
fields_summary: { type: "string" },
|
|
147
|
+
},
|
|
148
|
+
required: ["name", "kind"],
|
|
149
|
+
additionalProperties: false,
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
authentication_flow: { type: "string" },
|
|
153
|
+
error_handling: { type: "string" },
|
|
154
|
+
},
|
|
155
|
+
required: ["endpoints"],
|
|
156
|
+
additionalProperties: false,
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
security: {
|
|
160
|
+
name: "security_review_report",
|
|
161
|
+
strict: true,
|
|
162
|
+
schema: {
|
|
163
|
+
type: "object",
|
|
164
|
+
properties: {
|
|
165
|
+
findings: {
|
|
166
|
+
type: "array",
|
|
167
|
+
items: {
|
|
168
|
+
type: "object",
|
|
169
|
+
properties: {
|
|
170
|
+
severity: { type: "string", enum: ["critical", "high", "medium", "low"] },
|
|
171
|
+
category: { type: "string" },
|
|
172
|
+
title: { type: "string" },
|
|
173
|
+
location: { type: "string" },
|
|
174
|
+
description: { type: "string" },
|
|
175
|
+
},
|
|
176
|
+
required: ["severity", "title", "description"],
|
|
177
|
+
additionalProperties: false,
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
overall_assessment: { type: "string" },
|
|
181
|
+
coverage_note: { type: "string" },
|
|
182
|
+
},
|
|
183
|
+
required: ["findings", "overall_assessment"],
|
|
184
|
+
additionalProperties: false,
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
defect_mechanical: {
|
|
188
|
+
name: "defect_scan_report",
|
|
189
|
+
strict: true,
|
|
190
|
+
schema: {
|
|
191
|
+
type: "object",
|
|
192
|
+
properties: {
|
|
193
|
+
module: { type: "string" },
|
|
194
|
+
findings: {
|
|
195
|
+
type: "array",
|
|
196
|
+
items: {
|
|
197
|
+
type: "object",
|
|
198
|
+
properties: {
|
|
199
|
+
severity: { type: "string", enum: ["high", "medium", "low"] },
|
|
200
|
+
pattern: { type: "string" },
|
|
201
|
+
title: { type: "string" },
|
|
202
|
+
location: { type: "string" },
|
|
203
|
+
description: { type: "string" },
|
|
204
|
+
suggestion: { type: "string" },
|
|
205
|
+
},
|
|
206
|
+
required: ["severity", "pattern", "title", "description"],
|
|
207
|
+
additionalProperties: false,
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
patterns_checked: { type: "array", items: { type: "string" } },
|
|
211
|
+
files_scanned: { type: "integer" },
|
|
212
|
+
overall_notes: { type: "string" },
|
|
213
|
+
},
|
|
214
|
+
required: ["module", "findings", "patterns_checked", "files_scanned"],
|
|
215
|
+
additionalProperties: false,
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
conventions: {
|
|
219
|
+
name: "conventions_report",
|
|
220
|
+
strict: true,
|
|
221
|
+
schema: {
|
|
222
|
+
type: "object",
|
|
223
|
+
properties: {
|
|
224
|
+
module: { type: "string" },
|
|
225
|
+
naming_conventions: {
|
|
226
|
+
type: "object",
|
|
227
|
+
properties: {
|
|
228
|
+
packages: { type: "string" },
|
|
229
|
+
types: { type: "string" },
|
|
230
|
+
functions: { type: "string" },
|
|
231
|
+
variables: { type: "string" },
|
|
232
|
+
files: { type: "string" },
|
|
233
|
+
tests: { type: "string" },
|
|
234
|
+
},
|
|
235
|
+
additionalProperties: false,
|
|
236
|
+
},
|
|
237
|
+
error_handling_pattern: { type: "string" },
|
|
238
|
+
logging_approach: { type: "string" },
|
|
239
|
+
test_patterns: { type: "string" },
|
|
240
|
+
code_organization: { type: "string" },
|
|
241
|
+
idioms: { type: "array", items: { type: "string" } },
|
|
242
|
+
inconsistencies: {
|
|
243
|
+
type: "array",
|
|
244
|
+
items: {
|
|
245
|
+
type: "object",
|
|
246
|
+
properties: {
|
|
247
|
+
description: { type: "string" },
|
|
248
|
+
locations: { type: "array", items: { type: "string" } },
|
|
249
|
+
},
|
|
250
|
+
required: ["description"],
|
|
251
|
+
additionalProperties: false,
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
promotable_conventions: {
|
|
255
|
+
type: "array",
|
|
256
|
+
items: {
|
|
257
|
+
type: "object",
|
|
258
|
+
properties: {
|
|
259
|
+
title: { type: "string" },
|
|
260
|
+
rule: { type: "string" },
|
|
261
|
+
evidence: { type: "string" },
|
|
262
|
+
},
|
|
263
|
+
required: ["title", "rule"],
|
|
264
|
+
additionalProperties: false,
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
files_scanned: { type: "integer" },
|
|
268
|
+
},
|
|
269
|
+
required: ["module", "naming_conventions", "files_scanned"],
|
|
270
|
+
additionalProperties: false,
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
porting: {
|
|
274
|
+
name: "porting_surface_report",
|
|
275
|
+
strict: true,
|
|
276
|
+
schema: {
|
|
277
|
+
type: "object",
|
|
278
|
+
properties: {
|
|
279
|
+
module: { type: "string" },
|
|
280
|
+
platform_coupling: {
|
|
281
|
+
type: "array",
|
|
282
|
+
items: {
|
|
283
|
+
type: "object",
|
|
284
|
+
properties: {
|
|
285
|
+
platform: { type: "string" },
|
|
286
|
+
mechanisms: { type: "array", items: { type: "string" } },
|
|
287
|
+
files: { type: "array", items: { type: "string" } },
|
|
288
|
+
},
|
|
289
|
+
required: ["platform", "mechanisms"],
|
|
290
|
+
additionalProperties: false,
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
external_dependencies: {
|
|
294
|
+
type: "array",
|
|
295
|
+
items: {
|
|
296
|
+
type: "object",
|
|
297
|
+
properties: {
|
|
298
|
+
name: { type: "string" },
|
|
299
|
+
role: { type: "string" },
|
|
300
|
+
replaceability: { type: "string" },
|
|
301
|
+
},
|
|
302
|
+
required: ["name"],
|
|
303
|
+
additionalProperties: false,
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
build_system_complexity: { type: "string" },
|
|
307
|
+
porting_risk_areas: {
|
|
308
|
+
type: "array",
|
|
309
|
+
items: {
|
|
310
|
+
type: "object",
|
|
311
|
+
properties: {
|
|
312
|
+
area: { type: "string" },
|
|
313
|
+
risk: { type: "string", enum: ["low", "medium", "high"] },
|
|
314
|
+
notes: { type: "string" },
|
|
315
|
+
},
|
|
316
|
+
required: ["area", "risk"],
|
|
317
|
+
additionalProperties: false,
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
files_scanned: { type: "integer" },
|
|
321
|
+
},
|
|
322
|
+
required: ["module", "platform_coupling", "files_scanned"],
|
|
323
|
+
additionalProperties: false,
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
synthesis: {
|
|
327
|
+
name: "synthesis_report",
|
|
328
|
+
strict: true,
|
|
329
|
+
schema: {
|
|
330
|
+
type: "object",
|
|
331
|
+
properties: {
|
|
332
|
+
executive_summary: { type: "string" },
|
|
333
|
+
severity_summary: {
|
|
334
|
+
type: "object",
|
|
335
|
+
properties: {
|
|
336
|
+
critical: { type: "integer" },
|
|
337
|
+
high: { type: "integer" },
|
|
338
|
+
medium: { type: "integer" },
|
|
339
|
+
low: { type: "integer" },
|
|
340
|
+
},
|
|
341
|
+
required: ["critical", "high", "medium", "low"],
|
|
342
|
+
additionalProperties: false,
|
|
343
|
+
},
|
|
344
|
+
top_findings: {
|
|
345
|
+
type: "array",
|
|
346
|
+
items: {
|
|
347
|
+
type: "object",
|
|
348
|
+
properties: {
|
|
349
|
+
title: { type: "string" },
|
|
350
|
+
severity: { type: "string" },
|
|
351
|
+
source_lens: { type: "string" },
|
|
352
|
+
summary: { type: "string" },
|
|
353
|
+
},
|
|
354
|
+
required: ["title", "severity", "source_lens", "summary"],
|
|
355
|
+
additionalProperties: false,
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
module_assessments: {
|
|
359
|
+
type: "array",
|
|
360
|
+
items: {
|
|
361
|
+
type: "object",
|
|
362
|
+
properties: {
|
|
363
|
+
module: { type: "string" },
|
|
364
|
+
quality_notes: { type: "string" },
|
|
365
|
+
risk_level: { type: "string", enum: ["low", "medium", "high"] },
|
|
366
|
+
},
|
|
367
|
+
required: ["module", "risk_level"],
|
|
368
|
+
additionalProperties: false,
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
porting_readiness: { type: "string" },
|
|
372
|
+
gaps_and_unknowns: { type: "array", items: { type: "string" } },
|
|
373
|
+
coverage: { type: "string" },
|
|
374
|
+
},
|
|
375
|
+
required: ["executive_summary", "severity_summary", "top_findings"],
|
|
376
|
+
additionalProperties: false,
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
triage: {
|
|
380
|
+
name: "triage_report",
|
|
381
|
+
strict: true,
|
|
382
|
+
schema: {
|
|
383
|
+
type: "object",
|
|
384
|
+
properties: {
|
|
385
|
+
summary: { type: "string" },
|
|
386
|
+
items: {
|
|
387
|
+
type: "array",
|
|
388
|
+
items: {
|
|
389
|
+
type: "object",
|
|
390
|
+
properties: {
|
|
391
|
+
title: { type: "string" },
|
|
392
|
+
severity: { type: "string" },
|
|
393
|
+
module: { type: "string" },
|
|
394
|
+
impact: { type: "string", enum: ["high", "medium", "low"] },
|
|
395
|
+
difficulty: { type: "string", enum: ["high", "medium", "low"] },
|
|
396
|
+
priority: { type: "string" },
|
|
397
|
+
effort_estimate: { type: "string" },
|
|
398
|
+
rationale: { type: "string" },
|
|
399
|
+
},
|
|
400
|
+
required: ["title", "severity", "module", "impact", "difficulty", "priority", "rationale"],
|
|
401
|
+
additionalProperties: false,
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
omitted: {
|
|
405
|
+
type: "array",
|
|
406
|
+
items: { type: "string" },
|
|
407
|
+
description: "Leads deliberately dropped from the queue and why (duplicates, too vague, out of scope)",
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
required: ["summary", "items"],
|
|
411
|
+
additionalProperties: false,
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
const TS_PROFILE = {
|
|
416
|
+
defectPatterns: [
|
|
417
|
+
"Null/undefined dereference risks (unchecked optional access)",
|
|
418
|
+
"Error handling gaps (unhandled promise rejections, swallowed catches)",
|
|
419
|
+
"Resource leaks (unclosed handles, missing cleanup, dangling timers/listeners)",
|
|
420
|
+
"Race conditions (shared mutable state, async interleavings without guards)",
|
|
421
|
+
"Integer/precision assumptions in arithmetic",
|
|
422
|
+
"Unsafe type assumptions (as-casts, any leaks, non-null assertions)",
|
|
423
|
+
"Panic-prone code (out-of-bounds access, runtime TypeError paths)",
|
|
424
|
+
"Timezone/locale assumptions",
|
|
425
|
+
],
|
|
426
|
+
conventionCategories: [
|
|
427
|
+
{ key: "packages", label: "modules and imports" },
|
|
428
|
+
{ key: "types", label: "interfaces and type aliases" },
|
|
429
|
+
{ key: "functions", label: "functions (camelCase), components (PascalCase)" },
|
|
430
|
+
{ key: "variables", label: "variables and constants (camelCase)" },
|
|
431
|
+
{ key: "files", label: "file naming (kebab vs camel) and folder organization" },
|
|
432
|
+
{ key: "tests", label: "test files (*.test.ts, describe/it patterns)" },
|
|
433
|
+
],
|
|
434
|
+
idiomHints: ["strict null checks usage", "async/await vs promise chains", "dependency injection patterns"],
|
|
435
|
+
};
|
|
436
|
+
const LANGUAGE_PROFILES = {
|
|
437
|
+
go: {
|
|
438
|
+
defectPatterns: [
|
|
439
|
+
"Nil pointer dereference risks (unchecked returns, missing nil guards)",
|
|
440
|
+
"Error handling gaps (ignored errors, deferred errors unchecked)",
|
|
441
|
+
"Resource leaks (unclosed files, connections, goroutines without ctx)",
|
|
442
|
+
"Race conditions (shared state without sync, channel misuse)",
|
|
443
|
+
"Integer overflow/underflow in arithmetic or bounds",
|
|
444
|
+
"Unsafe type assertions without ok check",
|
|
445
|
+
"Panic-prone code (slice out of bounds, map access without ok)",
|
|
446
|
+
"Timezone/locale assumptions",
|
|
447
|
+
],
|
|
448
|
+
conventionCategories: [
|
|
449
|
+
{ key: "packages", label: "packages" },
|
|
450
|
+
{ key: "types", label: "types and interfaces" },
|
|
451
|
+
{ key: "functions", label: "functions and methods" },
|
|
452
|
+
{ key: "variables", label: "variables and fields" },
|
|
453
|
+
{ key: "files", label: "file and directory organization" },
|
|
454
|
+
{ key: "tests", label: "test files and table-driven tests" },
|
|
455
|
+
],
|
|
456
|
+
idiomHints: ["error wrapping with %w", "zero-value construction"],
|
|
457
|
+
},
|
|
458
|
+
python: {
|
|
459
|
+
defectPatterns: [
|
|
460
|
+
"None dereference risks (unchecked optional returns, AttributeError paths)",
|
|
461
|
+
"Exception handling gaps (bare except, swallowed exceptions, broad catch-all)",
|
|
462
|
+
"Resource leaks (unclosed files, sockets, connections, context managers)",
|
|
463
|
+
"Race conditions (shared mutable state, threading without locks, async pitfalls)",
|
|
464
|
+
"Integer/float precision assumptions in arithmetic",
|
|
465
|
+
"Unsafe type assumptions (unpacking mismatches, isinstance without fallback)",
|
|
466
|
+
"Panic-prone code (IndexError/KeyError paths, unbounded slicing)",
|
|
467
|
+
"Timezone/locale assumptions (naive datetimes)",
|
|
468
|
+
],
|
|
469
|
+
conventionCategories: [
|
|
470
|
+
{ key: "packages", label: "modules and packages" },
|
|
471
|
+
{ key: "types", label: "classes and type hints" },
|
|
472
|
+
{ key: "functions", label: "functions and methods (snake_case vs camelCase)" },
|
|
473
|
+
{ key: "variables", label: "variables and constants" },
|
|
474
|
+
{ key: "files", label: "file and module organization" },
|
|
475
|
+
{ key: "tests", label: "test files (pytest fixtures, naming)" },
|
|
476
|
+
],
|
|
477
|
+
idiomHints: ["dunder method usage", "context manager idioms", "dataclass/pydantic models"],
|
|
478
|
+
},
|
|
479
|
+
rust: {
|
|
480
|
+
defectPatterns: [
|
|
481
|
+
"Unwrap/expect panics on fallible paths",
|
|
482
|
+
"Error handling gaps (swallowed Results, lossy conversions)",
|
|
483
|
+
"Resource leaks (unclosed handles, drop order assumptions)",
|
|
484
|
+
"Data races and Send/Sync violations (unsafe blocks, interior mutability misuse)",
|
|
485
|
+
"Integer overflow/underflow (arithmetic, casting)",
|
|
486
|
+
"Unsafe type assumptions (transmute/casts without invariants)",
|
|
487
|
+
"Panic-prone code (indexing, slicing, unreachable! in library paths)",
|
|
488
|
+
"Timezone/locale assumptions",
|
|
489
|
+
],
|
|
490
|
+
conventionCategories: [
|
|
491
|
+
{ key: "packages", label: "crates and modules" },
|
|
492
|
+
{ key: "types", label: "structs, enums, and traits" },
|
|
493
|
+
{ key: "functions", label: "functions and methods (snake_case)" },
|
|
494
|
+
{ key: "variables", label: "variables and constants (SCREAMING_SNAKE)" },
|
|
495
|
+
{ key: "files", label: "module file organization" },
|
|
496
|
+
{ key: "tests", label: "test modules and #[cfg(test)] patterns" },
|
|
497
|
+
],
|
|
498
|
+
idiomHints: ["Result/Option handling with ?", "builder patterns", "trait-based extension"],
|
|
499
|
+
},
|
|
500
|
+
typescript: TS_PROFILE,
|
|
501
|
+
javascript: TS_PROFILE,
|
|
502
|
+
default: {
|
|
503
|
+
defectPatterns: [
|
|
504
|
+
"Null/undefined dereference risks (unchecked optional access)",
|
|
505
|
+
"Error handling gaps (ignored or swallowed errors)",
|
|
506
|
+
"Resource leaks (unclosed files, connections, handles)",
|
|
507
|
+
"Race conditions (shared mutable state without synchronization)",
|
|
508
|
+
"Integer overflow/underflow in arithmetic or bounds",
|
|
509
|
+
"Unsafe type assumptions and unchecked casts",
|
|
510
|
+
"Panic-prone code (out-of-bounds access, missing keys)",
|
|
511
|
+
"Timezone/locale assumptions",
|
|
512
|
+
],
|
|
513
|
+
conventionCategories: [
|
|
514
|
+
{ key: "packages", label: "modules, packages, or namespaces" },
|
|
515
|
+
{ key: "types", label: "types, classes, and interfaces" },
|
|
516
|
+
{ key: "functions", label: "functions and methods" },
|
|
517
|
+
{ key: "variables", label: "variables and constants" },
|
|
518
|
+
{ key: "files", label: "file and directory organization" },
|
|
519
|
+
{ key: "tests", label: "test files and test organization" },
|
|
520
|
+
],
|
|
521
|
+
idiomHints: [],
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
function languageProfile(language) {
|
|
525
|
+
return LANGUAGE_PROFILES[language] ?? LANGUAGE_PROFILES.default;
|
|
526
|
+
}
|
|
527
|
+
const LENSES = {
|
|
528
|
+
architecture: {
|
|
529
|
+
id: "architecture",
|
|
530
|
+
name: "Architecture, tech stack & module map",
|
|
531
|
+
description: "Repo-wide structural analysis from the manifest, entry point, README, and file tree.",
|
|
532
|
+
schemaName: "architecture",
|
|
533
|
+
sliceBy: "none",
|
|
534
|
+
maxChars: 0, // repo-info lens; no file slurping
|
|
535
|
+
maxTokens: 8000,
|
|
536
|
+
globsFor: () => [],
|
|
537
|
+
systemPrompt: () => "You are a senior software architect performing a structural analysis of a " +
|
|
538
|
+
"codebase. You receive the project manifest, entry point, README excerpt, and " +
|
|
539
|
+
"file tree. Return a JSON object following the architecture_report schema " +
|
|
540
|
+
"exactly. All findings must be traceable to the provided files — cite file " +
|
|
541
|
+
"paths. If you can't determine something, say so rather than guessing.",
|
|
542
|
+
userPrompt: (info) => {
|
|
543
|
+
const manifest = info.manifest
|
|
544
|
+
? `## ${info.manifest.path}\n\`\`\`\n${info.manifest.content}\n\`\`\`\n\n`
|
|
545
|
+
: "## Manifest\n[no manifest found]\n\n";
|
|
546
|
+
return ("Analyze the architecture of this project.\n\n" +
|
|
547
|
+
manifest +
|
|
548
|
+
`## Entry point\n\`\`\`\n${info.mainFile || "[missing]"}\n\`\`\`\n\n` +
|
|
549
|
+
`## README (first 4000 chars)\n${info.readmeFirst || "[missing]"}\n\n` +
|
|
550
|
+
`## File tree (depth 3, capped)\n${info.fileTree || "[missing]"}\n\n` +
|
|
551
|
+
"## File counts by extension\n```json\n" +
|
|
552
|
+
JSON.stringify(info.fileCounts) +
|
|
553
|
+
"\n```\n\n" +
|
|
554
|
+
"Return the architecture_report JSON schema.");
|
|
555
|
+
},
|
|
556
|
+
},
|
|
557
|
+
api: {
|
|
558
|
+
id: "api",
|
|
559
|
+
name: "API surface audit",
|
|
560
|
+
description: "Endpoint catalog, request/response types, auth flow, error handling.",
|
|
561
|
+
schemaName: "api_surface",
|
|
562
|
+
sliceBy: "none",
|
|
563
|
+
maxChars: 70_000,
|
|
564
|
+
maxTokens: 8000,
|
|
565
|
+
skipTestFiles: true,
|
|
566
|
+
globsFor: (info) => info.language === "go"
|
|
567
|
+
? ["server/**/*.go", "server/*.go", "api/**/*.go", "api/*.go"]
|
|
568
|
+
: [
|
|
569
|
+
"server/**",
|
|
570
|
+
"api/**",
|
|
571
|
+
"src/server/**",
|
|
572
|
+
"src/api/**",
|
|
573
|
+
"mcp-server/**",
|
|
574
|
+
"**/*routes*",
|
|
575
|
+
"**/*router*",
|
|
576
|
+
"**/*handler*",
|
|
577
|
+
"**/*endpoint*",
|
|
578
|
+
],
|
|
579
|
+
systemPrompt: () => "You are a senior API auditor. Given source files from an HTTP server, " +
|
|
580
|
+
"extract every HTTP endpoint (method, path, handler function, auth requirement) " +
|
|
581
|
+
"and every key request/response data type. Return a JSON object following the " +
|
|
582
|
+
"api_surface_report schema exactly. Cite specific file:line locations.",
|
|
583
|
+
userPrompt: (info, source, moduleName) => "Extract the full API surface from these server source files:\n\n" +
|
|
584
|
+
source +
|
|
585
|
+
"\n\nReturn the api_surface_report JSON schema.",
|
|
586
|
+
},
|
|
587
|
+
security: {
|
|
588
|
+
id: "security",
|
|
589
|
+
name: "Security review",
|
|
590
|
+
description: "Auth, authorization, input validation, TLS, secrets, trust boundaries.",
|
|
591
|
+
schemaName: "security",
|
|
592
|
+
sliceBy: "none",
|
|
593
|
+
maxChars: 70_000,
|
|
594
|
+
maxTokens: 8000,
|
|
595
|
+
skipTestFiles: true,
|
|
596
|
+
globsFor: (info) => info.language === "go"
|
|
597
|
+
? ["server/**/*.go", "server/*.go", "**/auth*.go", "**/middleware/**/*.go", "SECURITY.md"]
|
|
598
|
+
: ["server/**", "**/auth*", "**/middleware/**", "SECURITY.md"],
|
|
599
|
+
systemPrompt: () => "You are a security engineer performing a first-pass review of a codebase. " +
|
|
600
|
+
"Given source files, identify potential security issues — focusing on " +
|
|
601
|
+
"authentication, authorization, input validation, TLS, secrets handling, " +
|
|
602
|
+
"and trust boundaries. Return a JSON object following the security_review_report " +
|
|
603
|
+
"schema. Rate severity as critical/high/medium/low. Be specific: cite file:line. " +
|
|
604
|
+
"If the provided files don't cover an area, state the gap in coverage_note.",
|
|
605
|
+
userPrompt: (info, source, moduleName) => "Review these server source files for security issues:\n\n" +
|
|
606
|
+
source +
|
|
607
|
+
"\n\nReturn the security_review_report JSON schema.",
|
|
608
|
+
},
|
|
609
|
+
defect: {
|
|
610
|
+
id: "defect",
|
|
611
|
+
name: "Mechanical defect scan",
|
|
612
|
+
description: "Nil derefs, error gaps, leaks, races, panics — pattern-based, sliced per module.",
|
|
613
|
+
schemaName: "defect_mechanical",
|
|
614
|
+
sliceBy: "auto",
|
|
615
|
+
maxChars: 60_000,
|
|
616
|
+
maxTokens: 6000,
|
|
617
|
+
globsFor: (info) => [info.sourceGlob],
|
|
618
|
+
systemPrompt: (info) => {
|
|
619
|
+
const profile = languageProfile(info.language);
|
|
620
|
+
const patterns = profile.defectPatterns.map((p, i) => ` ${i + 1}. ${p}`).join("\n");
|
|
621
|
+
return (`You are a senior code reviewer performing an automated defect scan on ${info.language} ` +
|
|
622
|
+
"source files. Look for these specific patterns:\n" +
|
|
623
|
+
patterns +
|
|
624
|
+
"\n\n" +
|
|
625
|
+
"Return a JSON object following the defect_scan_report schema. " +
|
|
626
|
+
"Cite file:line for every finding. List which patterns you checked. " +
|
|
627
|
+
"If the code looks clean for a pattern, say so rather than staying silent. " +
|
|
628
|
+
"Prefer precision over volume — 3 solid findings beat 15 vague ones.");
|
|
629
|
+
},
|
|
630
|
+
userPrompt: (info, source, moduleName) => `Scan this ${info.language} module for mechanical defects.\n\n` +
|
|
631
|
+
`Module: ${moduleName}\n\n` +
|
|
632
|
+
"## Source files\n\n" +
|
|
633
|
+
source +
|
|
634
|
+
"\n\nReturn the defect_scan_report JSON schema.",
|
|
635
|
+
},
|
|
636
|
+
conventions: {
|
|
637
|
+
id: "conventions",
|
|
638
|
+
name: "Convention extraction",
|
|
639
|
+
description: "Naming, error handling, idioms, inconsistencies, promotable conventions.",
|
|
640
|
+
schemaName: "conventions",
|
|
641
|
+
sliceBy: "auto",
|
|
642
|
+
maxChars: 60_000,
|
|
643
|
+
maxTokens: 6000,
|
|
644
|
+
globsFor: (info) => [info.sourceGlob],
|
|
645
|
+
systemPrompt: (info) => {
|
|
646
|
+
const profile = languageProfile(info.language);
|
|
647
|
+
const categories = profile.conventionCategories.map((c) => `${c.key} (${c.label})`).join(", ");
|
|
648
|
+
const idiomHint = profile.idiomHints.length > 0
|
|
649
|
+
? ` Keep an eye out for ${info.language} idioms such as ${profile.idiomHints.join(", ")}.`
|
|
650
|
+
: "";
|
|
651
|
+
return (`You are a code style analyst extracting conventions from ${info.language} source files. ` +
|
|
652
|
+
"Catalog naming conventions per category — " + categories + " — plus the dominant " +
|
|
653
|
+
"error-handling pattern, logging approach, test organization patterns, file/package " +
|
|
654
|
+
"organization rules, and recurring idioms." + idiomHint +
|
|
655
|
+
" Also flag inconsistencies — places where the same convention is violated. " +
|
|
656
|
+
"If you find well-established conventions worth formalizing, list them as " +
|
|
657
|
+
"promotable_conventions with a title, rule, and evidence from the code. " +
|
|
658
|
+
"Return a JSON object following the conventions_report schema.");
|
|
659
|
+
},
|
|
660
|
+
userPrompt: (info, source, moduleName) => "Extract coding conventions from this module.\n\n" +
|
|
661
|
+
`Module: ${moduleName}\n\n` +
|
|
662
|
+
"## Source files\n\n" +
|
|
663
|
+
source +
|
|
664
|
+
"\n\nReturn the conventions_report JSON schema.",
|
|
665
|
+
},
|
|
666
|
+
porting: {
|
|
667
|
+
id: "porting",
|
|
668
|
+
name: "Porting surface assessment",
|
|
669
|
+
description: "Platform coupling, external deps, build complexity, porting risk areas.",
|
|
670
|
+
schemaName: "porting",
|
|
671
|
+
sliceBy: "auto",
|
|
672
|
+
maxChars: 60_000,
|
|
673
|
+
maxTokens: 6000,
|
|
674
|
+
skipTestFiles: true,
|
|
675
|
+
globsFor: (info) => [
|
|
676
|
+
info.sourceGlob,
|
|
677
|
+
"**/*.c",
|
|
678
|
+
"**/*.h",
|
|
679
|
+
"**/*.cpp",
|
|
680
|
+
"**/*.cc",
|
|
681
|
+
"**/*.m",
|
|
682
|
+
"**/*.mm",
|
|
683
|
+
"**/CMakeLists.txt",
|
|
684
|
+
"**/*.cmake",
|
|
685
|
+
"go.mod",
|
|
686
|
+
],
|
|
687
|
+
systemPrompt: () => "You are a software portability analyst. Examine source files and " +
|
|
688
|
+
"identify everything that ties this codebase to a specific platform, OS, " +
|
|
689
|
+
"architecture, or external dependency. Catalog: platform-specific build tags, " +
|
|
690
|
+
"FFI usage, OS-specific syscalls, external library bindings, and " +
|
|
691
|
+
"compile-time constants that encode platform assumptions. " +
|
|
692
|
+
"For each external dependency, note whether it could be replaced by a " +
|
|
693
|
+
"cross-platform alternative. Assess the build system complexity. " +
|
|
694
|
+
"Return a JSON object following the porting_surface_report schema.",
|
|
695
|
+
userPrompt: (info, source, moduleName) => "Assess porting surface for this module.\n\n" +
|
|
696
|
+
`Module: ${moduleName}\n\n` +
|
|
697
|
+
"## Source files\n\n" +
|
|
698
|
+
source +
|
|
699
|
+
"\n\nReturn the porting_surface_report JSON schema.",
|
|
700
|
+
},
|
|
701
|
+
};
|
|
702
|
+
export function getLens(lensId) {
|
|
703
|
+
return LENSES[lensId];
|
|
704
|
+
}
|
|
705
|
+
export function listLenses() {
|
|
706
|
+
return BROADSIDE_LENS_IDS.map((id) => LENSES[id]);
|
|
707
|
+
}
|
|
708
|
+
// ---------- repo info ----------
|
|
709
|
+
const SKIP_DIR_NAMES = new Set([
|
|
710
|
+
".git",
|
|
711
|
+
".github",
|
|
712
|
+
".claude",
|
|
713
|
+
".opencode",
|
|
714
|
+
".codecarto",
|
|
715
|
+
"node_modules",
|
|
716
|
+
"vendor",
|
|
717
|
+
"dist",
|
|
718
|
+
"build",
|
|
719
|
+
"target",
|
|
720
|
+
"testdata",
|
|
721
|
+
"__pycache__",
|
|
722
|
+
]);
|
|
723
|
+
const SKIP_FILE_EXTENSIONS = new Set([
|
|
724
|
+
".png",
|
|
725
|
+
".jpg",
|
|
726
|
+
".jpeg",
|
|
727
|
+
".gif",
|
|
728
|
+
".svg",
|
|
729
|
+
".ico",
|
|
730
|
+
".icns",
|
|
731
|
+
".bmp",
|
|
732
|
+
".webp",
|
|
733
|
+
".mp3",
|
|
734
|
+
".mp4",
|
|
735
|
+
".mov",
|
|
736
|
+
".avi",
|
|
737
|
+
".wav",
|
|
738
|
+
".ogg",
|
|
739
|
+
".zip",
|
|
740
|
+
".gz",
|
|
741
|
+
".tar",
|
|
742
|
+
".bz2",
|
|
743
|
+
".xz",
|
|
744
|
+
".7z",
|
|
745
|
+
".pdf",
|
|
746
|
+
".woff",
|
|
747
|
+
".woff2",
|
|
748
|
+
".ttf",
|
|
749
|
+
".eot",
|
|
750
|
+
".otf",
|
|
751
|
+
".bin",
|
|
752
|
+
".exe",
|
|
753
|
+
".dll",
|
|
754
|
+
".so",
|
|
755
|
+
".dylib",
|
|
756
|
+
".a",
|
|
757
|
+
".o",
|
|
758
|
+
".obj",
|
|
759
|
+
".class",
|
|
760
|
+
".jar",
|
|
761
|
+
".war",
|
|
762
|
+
".pyc",
|
|
763
|
+
".wasm",
|
|
764
|
+
".model",
|
|
765
|
+
".bpe",
|
|
766
|
+
]);
|
|
767
|
+
const MANIFEST_CANDIDATES = [
|
|
768
|
+
["go.mod", "go"],
|
|
769
|
+
["package.json", "typescript"],
|
|
770
|
+
["Cargo.toml", "rust"],
|
|
771
|
+
["pyproject.toml", "python"],
|
|
772
|
+
["setup.py", "python"],
|
|
773
|
+
["requirements.txt", "python"],
|
|
774
|
+
];
|
|
775
|
+
const SOURCE_SPECS = {
|
|
776
|
+
go: { glob: "**/*.go", exts: [".go"] },
|
|
777
|
+
python: { glob: "**/*.py", exts: [".py"] },
|
|
778
|
+
rust: { glob: "**/*.rs", exts: [".rs"] },
|
|
779
|
+
typescript: { glob: "**/*.ts", exts: [".ts", ".tsx"] },
|
|
780
|
+
javascript: { glob: "**/*.js", exts: [".js", ".jsx"] },
|
|
781
|
+
};
|
|
782
|
+
async function listRepoFiles(targetDir) {
|
|
783
|
+
// git ls-tree is the fast path; fall back to a bounded walk for non-git trees.
|
|
784
|
+
try {
|
|
785
|
+
const { stdout } = await execFileAsync("git", ["-C", targetDir, "ls-tree", "-r", "--name-only", "HEAD"], {
|
|
786
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
787
|
+
});
|
|
788
|
+
return stdout.split("\n").filter(Boolean);
|
|
789
|
+
}
|
|
790
|
+
catch {
|
|
791
|
+
return walkFiles(targetDir, targetDir, 0, 30_000);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
async function gitHead(targetDir) {
|
|
795
|
+
try {
|
|
796
|
+
const { stdout } = await execFileAsync("git", ["-C", targetDir, "rev-parse", "HEAD"], { maxBuffer: 1024 * 1024 });
|
|
797
|
+
return stdout.trim() || null;
|
|
798
|
+
}
|
|
799
|
+
catch {
|
|
800
|
+
return null;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
async function gitDirty(targetDir) {
|
|
804
|
+
try {
|
|
805
|
+
const { stdout } = await execFileAsync("git", ["-C", targetDir, "status", "--porcelain"], { maxBuffer: 1024 * 1024 });
|
|
806
|
+
return stdout.trim().length > 0;
|
|
807
|
+
}
|
|
808
|
+
catch {
|
|
809
|
+
return false;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Repo-relative paths changed since `baseHead` (or all files when there is
|
|
814
|
+
* no base). Returns null when the diff cannot be computed (non-git tree,
|
|
815
|
+
* missing base commit) so callers fall back to a full scan.
|
|
816
|
+
*/
|
|
817
|
+
async function changedFilesSince(targetDir, baseHead) {
|
|
818
|
+
if (!baseHead)
|
|
819
|
+
return null;
|
|
820
|
+
try {
|
|
821
|
+
const { stdout } = await execFileAsync("git", ["-C", targetDir, "diff", "--name-only", baseHead, "HEAD"], { maxBuffer: 64 * 1024 * 1024 });
|
|
822
|
+
return new Set(stdout.split("\n").filter(Boolean));
|
|
823
|
+
}
|
|
824
|
+
catch {
|
|
825
|
+
return null;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
async function walkFiles(rootDir, dir, depth, remaining) {
|
|
829
|
+
if (remaining <= 0)
|
|
830
|
+
return [];
|
|
831
|
+
let out = [];
|
|
832
|
+
let entries = [];
|
|
833
|
+
try {
|
|
834
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
835
|
+
}
|
|
836
|
+
catch {
|
|
837
|
+
return out;
|
|
838
|
+
}
|
|
839
|
+
for (const entry of entries) {
|
|
840
|
+
if (entry.name.startsWith(".") && entry.name !== ".github")
|
|
841
|
+
continue;
|
|
842
|
+
if (entry.isDirectory()) {
|
|
843
|
+
if (SKIP_DIR_NAMES.has(entry.name))
|
|
844
|
+
continue;
|
|
845
|
+
if (depth > 8)
|
|
846
|
+
continue;
|
|
847
|
+
const children = await walkFiles(rootDir, join(dir, entry.name), depth + 1, remaining - out.length);
|
|
848
|
+
out = out.concat(children);
|
|
849
|
+
}
|
|
850
|
+
else if (entry.isFile()) {
|
|
851
|
+
const rel = join(dir, entry.name).slice(rootDir.length + 1).split("\\").join("/");
|
|
852
|
+
out.push(rel);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
return out;
|
|
856
|
+
}
|
|
857
|
+
function detectLanguage(fileCounts, manifestPath) {
|
|
858
|
+
if (manifestPath) {
|
|
859
|
+
for (const [candidate, lang] of MANIFEST_CANDIDATES) {
|
|
860
|
+
if (manifestPath === candidate)
|
|
861
|
+
return lang;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
const counts = {
|
|
865
|
+
go: fileCounts[".go"] ?? 0,
|
|
866
|
+
python: fileCounts[".py"] ?? 0,
|
|
867
|
+
rust: fileCounts[".rs"] ?? 0,
|
|
868
|
+
typescript: (fileCounts[".ts"] ?? 0) + (fileCounts[".tsx"] ?? 0),
|
|
869
|
+
javascript: fileCounts[".js"] ?? 0,
|
|
870
|
+
};
|
|
871
|
+
const best = Object.entries(counts).sort((a, b) => b[1] - a[1])[0];
|
|
872
|
+
return best && best[1] > 0 ? best[0] : "unknown";
|
|
873
|
+
}
|
|
874
|
+
export async function collectRepoInfo(targetDir) {
|
|
875
|
+
const allFiles = await listRepoFiles(targetDir);
|
|
876
|
+
const fileCounts = {};
|
|
877
|
+
for (const f of allFiles) {
|
|
878
|
+
const slash = f.lastIndexOf("/");
|
|
879
|
+
const base = slash >= 0 ? f.slice(slash + 1) : f;
|
|
880
|
+
const dot = base.lastIndexOf(".");
|
|
881
|
+
const ext = dot > 0 ? base.slice(dot).toLowerCase() : "(no ext)";
|
|
882
|
+
fileCounts[ext] = (fileCounts[ext] ?? 0) + 1;
|
|
883
|
+
}
|
|
884
|
+
const sortedCounts = {};
|
|
885
|
+
for (const [ext, n] of Object.entries(fileCounts).sort((a, b) => b[1] - a[1])) {
|
|
886
|
+
sortedCounts[ext] = n;
|
|
887
|
+
}
|
|
888
|
+
let manifest = null;
|
|
889
|
+
for (const [candidate] of MANIFEST_CANDIDATES) {
|
|
890
|
+
const p = join(targetDir, candidate);
|
|
891
|
+
if (await pathExists(p)) {
|
|
892
|
+
try {
|
|
893
|
+
manifest = { path: candidate, content: await readFile(p, "utf8") };
|
|
894
|
+
}
|
|
895
|
+
catch {
|
|
896
|
+
manifest = null;
|
|
897
|
+
}
|
|
898
|
+
break;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
let mainFile = "";
|
|
902
|
+
for (const candidate of ["main.go", "main.py", "src/main.rs", "src/index.ts", "index.ts"]) {
|
|
903
|
+
const p = join(targetDir, candidate);
|
|
904
|
+
if (await pathExists(p)) {
|
|
905
|
+
try {
|
|
906
|
+
mainFile = await readFile(p, "utf8");
|
|
907
|
+
}
|
|
908
|
+
catch {
|
|
909
|
+
mainFile = "";
|
|
910
|
+
}
|
|
911
|
+
break;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
let readmeFirst = "";
|
|
915
|
+
const readmePath = join(targetDir, "README.md");
|
|
916
|
+
if (await pathExists(readmePath)) {
|
|
917
|
+
try {
|
|
918
|
+
readmeFirst = (await readFile(readmePath, "utf8")).slice(0, 4000);
|
|
919
|
+
}
|
|
920
|
+
catch {
|
|
921
|
+
readmeFirst = "";
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
const fileTree = buildFileTree(allFiles);
|
|
925
|
+
const language = detectLanguage(sortedCounts, manifest?.path ?? null);
|
|
926
|
+
const sourceSpec = SOURCE_SPECS[language] ?? SOURCE_SPECS.go;
|
|
927
|
+
const name = targetDir.split(/[\\/]/).filter(Boolean).pop() ?? "repo";
|
|
928
|
+
return {
|
|
929
|
+
name,
|
|
930
|
+
path: targetDir,
|
|
931
|
+
language,
|
|
932
|
+
manifest,
|
|
933
|
+
mainFile,
|
|
934
|
+
readmeFirst,
|
|
935
|
+
fileTree,
|
|
936
|
+
fileCounts: sortedCounts,
|
|
937
|
+
sourceGlob: sourceSpec.glob,
|
|
938
|
+
sourceExts: sourceSpec.exts,
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
function buildFileTree(allFiles, maxDepth = 3, maxLines = 200) {
|
|
942
|
+
const lines = [];
|
|
943
|
+
let count = 0;
|
|
944
|
+
for (const f of allFiles) {
|
|
945
|
+
if (f.split("/").length - 1 > maxDepth)
|
|
946
|
+
continue;
|
|
947
|
+
if (f.startsWith(".git/") || f.startsWith(".github/"))
|
|
948
|
+
continue;
|
|
949
|
+
if (f.endsWith(".sum") || f.endsWith(".lock"))
|
|
950
|
+
continue;
|
|
951
|
+
lines.push(f);
|
|
952
|
+
count += 1;
|
|
953
|
+
if (count >= maxLines) {
|
|
954
|
+
lines.push(`... (${allFiles.length} total files, showing first ${maxLines})`);
|
|
955
|
+
break;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
return lines.join("\n");
|
|
959
|
+
}
|
|
960
|
+
// ---------- glob matching & file slurping ----------
|
|
961
|
+
function globToRegExp(glob) {
|
|
962
|
+
let re = "";
|
|
963
|
+
for (let i = 0; i < glob.length; i++) {
|
|
964
|
+
const c = glob[i];
|
|
965
|
+
if (c === "*") {
|
|
966
|
+
if (glob[i + 1] === "*") {
|
|
967
|
+
// `**/` matches zero or more directories; a trailing `**`
|
|
968
|
+
// matches anything including slashes.
|
|
969
|
+
if (glob[i + 2] === "/") {
|
|
970
|
+
re += "(?:.*/)?";
|
|
971
|
+
i += 2;
|
|
972
|
+
}
|
|
973
|
+
else {
|
|
974
|
+
re += ".*";
|
|
975
|
+
i += 1;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
else {
|
|
979
|
+
re += "[^/]*";
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
else if (c === "?") {
|
|
983
|
+
re += "[^/]";
|
|
984
|
+
}
|
|
985
|
+
else {
|
|
986
|
+
re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
return new RegExp(`^${re}$`);
|
|
990
|
+
}
|
|
991
|
+
function matchesAnyGlob(path, globs) {
|
|
992
|
+
for (const glob of globs) {
|
|
993
|
+
if (globToRegExp(glob).test(path))
|
|
994
|
+
return true;
|
|
995
|
+
}
|
|
996
|
+
return false;
|
|
997
|
+
}
|
|
998
|
+
function isSlurpable(relPath) {
|
|
999
|
+
const segments = relPath.split("/");
|
|
1000
|
+
for (const seg of segments) {
|
|
1001
|
+
if (SKIP_DIR_NAMES.has(seg))
|
|
1002
|
+
return false;
|
|
1003
|
+
}
|
|
1004
|
+
const slash = relPath.lastIndexOf("/");
|
|
1005
|
+
const base = slash >= 0 ? relPath.slice(slash + 1) : relPath;
|
|
1006
|
+
const dot = base.lastIndexOf(".");
|
|
1007
|
+
if (dot > 0 && SKIP_FILE_EXTENSIONS.has(base.slice(dot).toLowerCase()))
|
|
1008
|
+
return false;
|
|
1009
|
+
return true;
|
|
1010
|
+
}
|
|
1011
|
+
function sanitizeId(segment) {
|
|
1012
|
+
return segment.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "root";
|
|
1013
|
+
}
|
|
1014
|
+
function topLevelModule(relPath) {
|
|
1015
|
+
const slash = relPath.indexOf("/");
|
|
1016
|
+
return slash >= 0 ? relPath.slice(0, slash) : "root";
|
|
1017
|
+
}
|
|
1018
|
+
function isTestFile(relPath) {
|
|
1019
|
+
const base = relPath.slice(relPath.lastIndexOf("/") + 1);
|
|
1020
|
+
return /[._](test|spec)\.[a-z]+$/i.test(base) || base.includes("_test.");
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* "auto" slicing: directory-slice when the repo is large enough that a
|
|
1024
|
+
* single whole-repo slice would overflow the lens's char cap, otherwise a
|
|
1025
|
+
* single slice. The threshold is the lens's own cap — a repo whose matching
|
|
1026
|
+
* files fit in one slice gains nothing from per-module splitting, and a
|
|
1027
|
+
* small repo pays for it in extra requests.
|
|
1028
|
+
*/
|
|
1029
|
+
function resolveSliceMode(lens, files, totalChars) {
|
|
1030
|
+
if (lens.sliceBy !== "auto")
|
|
1031
|
+
return lens.sliceBy;
|
|
1032
|
+
return totalChars > lens.maxChars ? "directory" : "none";
|
|
1033
|
+
}
|
|
1034
|
+
function collectLensFiles(allFiles, lens, info) {
|
|
1035
|
+
const globs = lens.globsFor(info);
|
|
1036
|
+
if (globs.length === 0)
|
|
1037
|
+
return [];
|
|
1038
|
+
const out = [];
|
|
1039
|
+
for (const f of allFiles) {
|
|
1040
|
+
if (!isSlurpable(f))
|
|
1041
|
+
continue;
|
|
1042
|
+
if (lens.skipTestFiles && isTestFile(f))
|
|
1043
|
+
continue;
|
|
1044
|
+
if (!matchesAnyGlob(f, globs))
|
|
1045
|
+
continue;
|
|
1046
|
+
out.push({ relPath: f, moduleName: topLevelModule(f) });
|
|
1047
|
+
}
|
|
1048
|
+
return out;
|
|
1049
|
+
}
|
|
1050
|
+
async function slurpFileList(targetDir, files, maxChars) {
|
|
1051
|
+
const slices = [];
|
|
1052
|
+
let currentModule = "";
|
|
1053
|
+
let parts = [];
|
|
1054
|
+
let running = 0;
|
|
1055
|
+
let fileCount = 0;
|
|
1056
|
+
let filePaths = [];
|
|
1057
|
+
const flush = () => {
|
|
1058
|
+
if (parts.length === 0)
|
|
1059
|
+
return;
|
|
1060
|
+
slices.push({
|
|
1061
|
+
moduleName: currentModule,
|
|
1062
|
+
content: parts.join("\n"),
|
|
1063
|
+
fileCount,
|
|
1064
|
+
chars: running,
|
|
1065
|
+
files: filePaths,
|
|
1066
|
+
});
|
|
1067
|
+
parts = [];
|
|
1068
|
+
running = 0;
|
|
1069
|
+
fileCount = 0;
|
|
1070
|
+
filePaths = [];
|
|
1071
|
+
};
|
|
1072
|
+
for (const file of files) {
|
|
1073
|
+
let content = "";
|
|
1074
|
+
try {
|
|
1075
|
+
content = await readFile(join(targetDir, file.relPath), "utf8");
|
|
1076
|
+
}
|
|
1077
|
+
catch {
|
|
1078
|
+
content = "[BINARY or UNREADABLE]";
|
|
1079
|
+
}
|
|
1080
|
+
const block = `=== ${file.relPath} ===\n${content}\n`;
|
|
1081
|
+
if (file.moduleName !== currentModule && parts.length > 0) {
|
|
1082
|
+
flush();
|
|
1083
|
+
}
|
|
1084
|
+
currentModule = file.moduleName;
|
|
1085
|
+
if (running + block.length > maxChars && parts.length > 0) {
|
|
1086
|
+
// Slice is full: flush it and start another slice for the same module
|
|
1087
|
+
// rather than truncating, so big modules get full coverage.
|
|
1088
|
+
flush();
|
|
1089
|
+
currentModule = file.moduleName;
|
|
1090
|
+
}
|
|
1091
|
+
parts.push(block);
|
|
1092
|
+
running += block.length;
|
|
1093
|
+
fileCount += 1;
|
|
1094
|
+
filePaths.push(file.relPath);
|
|
1095
|
+
}
|
|
1096
|
+
flush();
|
|
1097
|
+
return slices;
|
|
1098
|
+
}
|
|
1099
|
+
export async function gatherSlices(targetDir, lens, info) {
|
|
1100
|
+
if (lens.sliceBy === "none" && lens.globsFor(info).length === 0) {
|
|
1101
|
+
// Repo-info lens (architecture): the prompt is built from info alone.
|
|
1102
|
+
return [{ moduleName: "root", content: "", fileCount: 0, chars: 0, files: [] }];
|
|
1103
|
+
}
|
|
1104
|
+
const allFiles = await listRepoFiles(targetDir);
|
|
1105
|
+
const files = collectLensFiles(allFiles, lens, info);
|
|
1106
|
+
const totalChars = await sumFileSizes(targetDir, files);
|
|
1107
|
+
const mode = resolveSliceMode(lens, files, totalChars);
|
|
1108
|
+
if (mode === "none") {
|
|
1109
|
+
// Whole-repo slice: one module named after the repo, so a small
|
|
1110
|
+
// repo produces a single request instead of one per directory.
|
|
1111
|
+
const single = files.map((f) => ({ ...f, moduleName: info.name }));
|
|
1112
|
+
return slurpFileList(targetDir, single, lens.maxChars);
|
|
1113
|
+
}
|
|
1114
|
+
return slurpFileList(targetDir, files, lens.maxChars);
|
|
1115
|
+
}
|
|
1116
|
+
async function sumFileSizes(targetDir, files) {
|
|
1117
|
+
let total = 0;
|
|
1118
|
+
for (const f of files) {
|
|
1119
|
+
try {
|
|
1120
|
+
total += (await stat(join(targetDir, f.relPath))).size;
|
|
1121
|
+
}
|
|
1122
|
+
catch {
|
|
1123
|
+
// Unreadable file — slurpFileList substitutes a placeholder.
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
return total;
|
|
1127
|
+
}
|
|
1128
|
+
// ---------- request building ----------
|
|
1129
|
+
export function buildBatchRequest(lens, info, slice, index, sliceCount, model = BROADSIDE_MODEL, maxTokensOverride) {
|
|
1130
|
+
const moduleTag = sanitizeId(slice.moduleName);
|
|
1131
|
+
const customId = sliceCount > 1 ? `${lens.id}-${moduleTag}-${index + 1}` : `${lens.id}-${moduleTag}`;
|
|
1132
|
+
return {
|
|
1133
|
+
custom_id: customId,
|
|
1134
|
+
body: {
|
|
1135
|
+
model,
|
|
1136
|
+
messages: [
|
|
1137
|
+
{ role: "system", content: lens.systemPrompt(info) },
|
|
1138
|
+
{ role: "user", content: lens.userPrompt(info, slice.content, slice.moduleName) },
|
|
1139
|
+
],
|
|
1140
|
+
response_format: { type: "json_schema", json_schema: SCHEMAS[lens.schemaName] },
|
|
1141
|
+
max_tokens: maxTokensOverride ?? lens.maxTokens,
|
|
1142
|
+
},
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
export function estimateCost(lens, slices, pricing, maxTokensOverride) {
|
|
1146
|
+
const inputTokens = Math.ceil(slices.reduce((sum, s) => sum + (lens.maxChars === 0 ? 6000 : s.chars), 0) / 4);
|
|
1147
|
+
const outputTokens = Math.ceil((maxTokensOverride ?? lens.maxTokens) * 0.75);
|
|
1148
|
+
const cost = (inputTokens / 1_000_000) * pricing.inputPerM +
|
|
1149
|
+
(outputTokens / 1_000_000) * pricing.outputPerM;
|
|
1150
|
+
return { inputTokens, outputTokens, cost };
|
|
1151
|
+
}
|
|
1152
|
+
// ---------- state & config ----------
|
|
1153
|
+
export function broadsideDirFor(cwd) {
|
|
1154
|
+
return join(cwd, ".codecarto", BROADSIDE_DIR);
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Read the Broad-Side reading guide.
|
|
1158
|
+
*
|
|
1159
|
+
* It is deliberately not a post-pipeline skill under `.codecarto/skills/`: a
|
|
1160
|
+
* scout run is read *before* or *during* the interactive pipeline, and the
|
|
1161
|
+
* post-pipeline machinery gates on a completed run and wraps its prompt in
|
|
1162
|
+
* post-pipeline framing that would be false here. It is also readable on a
|
|
1163
|
+
* repository that has scout state and no workspace at all, which is why this
|
|
1164
|
+
* falls back to the packaged copy.
|
|
1165
|
+
*
|
|
1166
|
+
* @param cwd - Absolute path to the target repository.
|
|
1167
|
+
* @returns the skill text and the path it came from.
|
|
1168
|
+
* @throws when neither the workspace copy nor the packaged copy exists.
|
|
1169
|
+
*/
|
|
1170
|
+
export async function readBroadsideSkill(cwd) {
|
|
1171
|
+
const candidates = [
|
|
1172
|
+
join(broadsideDirFor(cwd), "SKILL.md"),
|
|
1173
|
+
join(packagedWorkspaceDir, BROADSIDE_DIR, "SKILL.md"),
|
|
1174
|
+
];
|
|
1175
|
+
for (const path of candidates) {
|
|
1176
|
+
if (await pathExists(path))
|
|
1177
|
+
return { path, content: await readFile(path, "utf8") };
|
|
1178
|
+
}
|
|
1179
|
+
throw new Error(`Broad-Side skill not found at ${candidates.join(" or ")}. Reinstall codecartographer-pi.`);
|
|
1180
|
+
}
|
|
1181
|
+
export function defaultBroadsideState() {
|
|
1182
|
+
return { schema_version: BROADSIDE_STATE_SCHEMA_VERSION, runs: [] };
|
|
1183
|
+
}
|
|
1184
|
+
export async function loadBroadsideState(broadsideDir) {
|
|
1185
|
+
const statePath = join(broadsideDir, BROADSIDE_STATE_FILE);
|
|
1186
|
+
if (!(await pathExists(statePath)))
|
|
1187
|
+
return defaultBroadsideState();
|
|
1188
|
+
try {
|
|
1189
|
+
const raw = JSON.parse(await readFile(statePath, "utf8"));
|
|
1190
|
+
if (!raw || typeof raw !== "object" || !Array.isArray(raw.runs))
|
|
1191
|
+
return defaultBroadsideState();
|
|
1192
|
+
return raw;
|
|
1193
|
+
}
|
|
1194
|
+
catch {
|
|
1195
|
+
return defaultBroadsideState();
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
export async function saveBroadsideState(broadsideDir, state) {
|
|
1199
|
+
await mkdir(broadsideDir, { recursive: true });
|
|
1200
|
+
await writeFile(join(broadsideDir, BROADSIDE_STATE_FILE), `${JSON.stringify(state, null, "\t")}\n`, "utf8");
|
|
1201
|
+
}
|
|
1202
|
+
export async function loadBroadsideConfig(broadsideDir) {
|
|
1203
|
+
const configPath = join(broadsideDir, BROADSIDE_CONFIG_FILE);
|
|
1204
|
+
let raw = {};
|
|
1205
|
+
if (await pathExists(configPath)) {
|
|
1206
|
+
try {
|
|
1207
|
+
raw = (await loadYamlFile(configPath)) ?? {};
|
|
1208
|
+
}
|
|
1209
|
+
catch {
|
|
1210
|
+
raw = {};
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
const lenses = Array.isArray(raw.default_lenses)
|
|
1214
|
+
? (raw.default_lenses.filter((l) => BROADSIDE_LENS_IDS.includes(l)))
|
|
1215
|
+
: [];
|
|
1216
|
+
const rawPricing = (raw.pricing ?? {});
|
|
1217
|
+
const inputOverride = typeof rawPricing.input_per_m === "number" ? rawPricing.input_per_m : undefined;
|
|
1218
|
+
const outputOverride = typeof rawPricing.output_per_m === "number" ? rawPricing.output_per_m : undefined;
|
|
1219
|
+
// A malformed value falls back to the shipped default rather than failing
|
|
1220
|
+
// the run: config.yaml is hand-edited, and a typo in a poll budget must not
|
|
1221
|
+
// cost a user their batches.
|
|
1222
|
+
const flag = (key, fallback) => typeof raw[key] === "boolean" ? raw[key] : fallback;
|
|
1223
|
+
// An override for an unknown lens id is dropped rather than carried: it can
|
|
1224
|
+
// only be a typo, and a silently-ignored key that looks applied is worse
|
|
1225
|
+
// than one that never appears.
|
|
1226
|
+
const lensModels = {};
|
|
1227
|
+
const rawLensModels = (raw.lens_models ?? {});
|
|
1228
|
+
for (const lensId of BROADSIDE_LENS_IDS) {
|
|
1229
|
+
const value = rawLensModels[lensId];
|
|
1230
|
+
if (typeof value === "string" && value.trim())
|
|
1231
|
+
lensModels[lensId] = value.trim();
|
|
1232
|
+
}
|
|
1233
|
+
return {
|
|
1234
|
+
model: typeof raw.model === "string" && raw.model.trim() ? raw.model.trim() : BROADSIDE_MODEL,
|
|
1235
|
+
apiKey: typeof raw.api_key === "string" ? raw.api_key.trim() : "",
|
|
1236
|
+
defaultLenses: lenses.length > 0 ? lenses : [...BROADSIDE_LENS_IDS],
|
|
1237
|
+
maxCost: typeof raw.max_cost === "number" && raw.max_cost > 0 ? raw.max_cost : 0,
|
|
1238
|
+
pricing: inputOverride !== undefined && outputOverride !== undefined
|
|
1239
|
+
? { inputPerM: inputOverride, outputPerM: outputOverride }
|
|
1240
|
+
: null,
|
|
1241
|
+
lensModels,
|
|
1242
|
+
incremental: flag("incremental", false),
|
|
1243
|
+
retryTruncated: flag("retry_truncated", true),
|
|
1244
|
+
includeSynthesis: flag("include_synthesis", true),
|
|
1245
|
+
includeTriage: flag("include_triage", true),
|
|
1246
|
+
waitSeconds: typeof raw.wait_seconds === "number" && raw.wait_seconds > 0 ? raw.wait_seconds : 0,
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
async function readCatalogCache(broadsideDir) {
|
|
1250
|
+
const cachePath = join(broadsideDir, BROADSIDE_CATALOG_CACHE_FILE);
|
|
1251
|
+
if (!(await pathExists(cachePath)))
|
|
1252
|
+
return null;
|
|
1253
|
+
try {
|
|
1254
|
+
const parsed = JSON.parse(await readFile(cachePath, "utf8"));
|
|
1255
|
+
if (!parsed || typeof parsed !== "object" || typeof parsed.models !== "object")
|
|
1256
|
+
return null;
|
|
1257
|
+
return parsed;
|
|
1258
|
+
}
|
|
1259
|
+
catch {
|
|
1260
|
+
return null;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
async function writeCatalogCache(broadsideDir, cache) {
|
|
1264
|
+
await mkdir(broadsideDir, { recursive: true });
|
|
1265
|
+
await writeFile(join(broadsideDir, BROADSIDE_CATALOG_CACHE_FILE), `${JSON.stringify(cache, null, "\t")}\n`, "utf8");
|
|
1266
|
+
}
|
|
1267
|
+
function parseCatalogEntry(raw) {
|
|
1268
|
+
const id = String(raw.id ?? "");
|
|
1269
|
+
if (!id)
|
|
1270
|
+
return null;
|
|
1271
|
+
const p = (raw.pricing ?? {});
|
|
1272
|
+
const input = typeof p.prompt === "string" ? Number(p.prompt) : NaN;
|
|
1273
|
+
const output = typeof p.completion === "string" ? Number(p.completion) : NaN;
|
|
1274
|
+
if (!Number.isFinite(input) || !Number.isFinite(output))
|
|
1275
|
+
return null;
|
|
1276
|
+
const cached = typeof p.cached_input === "string" ? Number(p.cached_input) : NaN;
|
|
1277
|
+
const topProvider = (raw.top_provider ?? {});
|
|
1278
|
+
const contextLength = typeof raw.context_length === "number" ? raw.context_length : undefined;
|
|
1279
|
+
const maxCompletion = typeof topProvider.max_completion_tokens === "number" ? topProvider.max_completion_tokens : undefined;
|
|
1280
|
+
return {
|
|
1281
|
+
id,
|
|
1282
|
+
name: String(raw.name ?? id),
|
|
1283
|
+
inputPerM: input * 1_000_000,
|
|
1284
|
+
outputPerM: output * 1_000_000,
|
|
1285
|
+
cachedInputPerM: Number.isFinite(cached) ? cached * 1_000_000 : undefined,
|
|
1286
|
+
contextLength,
|
|
1287
|
+
maxCompletionTokens: maxCompletion,
|
|
1288
|
+
supportedParameters: Array.isArray(raw.supported_parameters)
|
|
1289
|
+
? raw.supported_parameters.map((entry) => String(entry))
|
|
1290
|
+
: [],
|
|
1291
|
+
expirationDate: typeof raw.expiration_date === "string" ? raw.expiration_date : null,
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
export function builtInCatalogEntry(model) {
|
|
1295
|
+
// The default model's rates are compile-time constants; its capabilities
|
|
1296
|
+
// are asserted from the shipped configuration (1M context, 64K output,
|
|
1297
|
+
// structured outputs used by every lens).
|
|
1298
|
+
if (model !== BROADSIDE_MODEL)
|
|
1299
|
+
return null;
|
|
1300
|
+
return {
|
|
1301
|
+
id: BROADSIDE_MODEL,
|
|
1302
|
+
name: "Google: Gemini 3.7 Flash (batch)",
|
|
1303
|
+
inputPerM: BROADSIDE_INPUT_PRICE_PER_M,
|
|
1304
|
+
outputPerM: BROADSIDE_OUTPUT_PRICE_PER_M,
|
|
1305
|
+
contextLength: 1_048_576,
|
|
1306
|
+
maxCompletionTokens: 65_536,
|
|
1307
|
+
supportedParameters: ["tools", "structured_outputs", "json_schema", "response_format"],
|
|
1308
|
+
expirationDate: null,
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
export function builtInPricing(model) {
|
|
1312
|
+
const entry = builtInCatalogEntry(model);
|
|
1313
|
+
if (!entry)
|
|
1314
|
+
return null;
|
|
1315
|
+
return { inputPerM: entry.inputPerM, outputPerM: entry.outputPerM, source: "built-in" };
|
|
1316
|
+
}
|
|
1317
|
+
export async function resolveCatalogEntry(broadsideDir, config, model, apiKey, fetcher = fetch) {
|
|
1318
|
+
// Manual overrides always win for pricing — the user is asserting a rate,
|
|
1319
|
+
// and a config assertion is cheaper to respect than to second-guess.
|
|
1320
|
+
// Capabilities stay unknown in that case: nothing is refused, nothing
|
|
1321
|
+
// is clamped, and the submit text says the pricing came from config.
|
|
1322
|
+
if (config.pricing) {
|
|
1323
|
+
return {
|
|
1324
|
+
model,
|
|
1325
|
+
source: "config",
|
|
1326
|
+
entry: {
|
|
1327
|
+
id: model,
|
|
1328
|
+
name: model,
|
|
1329
|
+
inputPerM: config.pricing.inputPerM,
|
|
1330
|
+
outputPerM: config.pricing.outputPerM,
|
|
1331
|
+
supportedParameters: [],
|
|
1332
|
+
},
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
const builtIn = builtInCatalogEntry(model);
|
|
1336
|
+
if (builtIn)
|
|
1337
|
+
return { model, source: "built-in", entry: builtIn };
|
|
1338
|
+
// Unknown model: on-disk cache first, then the live catalog.
|
|
1339
|
+
const cache = await readCatalogCache(broadsideDir);
|
|
1340
|
+
const cached = cache?.models[model];
|
|
1341
|
+
if (cached && Date.now() - new Date(cache.fetched_at).getTime() < BROADSIDE_CATALOG_CACHE_TTL_MS) {
|
|
1342
|
+
return { model, source: "cache", entry: cached };
|
|
1343
|
+
}
|
|
1344
|
+
let live = null;
|
|
1345
|
+
try {
|
|
1346
|
+
const resp = await fetcher(BROADSIDE_MODELS_URL, {
|
|
1347
|
+
method: "GET",
|
|
1348
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
1349
|
+
signal: AbortSignal.timeout(30_000),
|
|
1350
|
+
});
|
|
1351
|
+
const data = (await resp.json());
|
|
1352
|
+
const hit = (data.data ?? []).find((m) => String(m.id) === model);
|
|
1353
|
+
if (hit)
|
|
1354
|
+
live = parseCatalogEntry(hit);
|
|
1355
|
+
}
|
|
1356
|
+
catch {
|
|
1357
|
+
live = null;
|
|
1358
|
+
}
|
|
1359
|
+
if (live) {
|
|
1360
|
+
const updated = {
|
|
1361
|
+
schema_version: 2,
|
|
1362
|
+
fetched_at: new Date().toISOString(),
|
|
1363
|
+
models: { ...(cache?.models ?? {}) },
|
|
1364
|
+
};
|
|
1365
|
+
updated.models[model] = live;
|
|
1366
|
+
await writeCatalogCache(broadsideDir, updated);
|
|
1367
|
+
return { model, source: "live", entry: live };
|
|
1368
|
+
}
|
|
1369
|
+
throw new Error(`Could not resolve per-token pricing for batch model "${model}". ` +
|
|
1370
|
+
"Set pricing.input_per_m and pricing.output_per_m in .codecarto/broadside/config.yaml " +
|
|
1371
|
+
"(USD per million tokens), or check the model id against https://openrouter.ai/models?variant=batch.");
|
|
1372
|
+
}
|
|
1373
|
+
export async function resolveModelPricing(broadsideDir, config, model, apiKey, fetcher = fetch) {
|
|
1374
|
+
const { source, entry } = await resolveCatalogEntry(broadsideDir, config, model, apiKey, fetcher);
|
|
1375
|
+
if (!entry)
|
|
1376
|
+
throw new Error(`No pricing resolved for ${model}.`);
|
|
1377
|
+
return { inputPerM: entry.inputPerM, outputPerM: entry.outputPerM, source };
|
|
1378
|
+
}
|
|
1379
|
+
/** Base slug with the OpenRouter variant suffix (e.g. `:batch`) stripped. */
|
|
1380
|
+
function baseSlug(modelId) {
|
|
1381
|
+
const idx = modelId.indexOf(":");
|
|
1382
|
+
return idx >= 0 ? modelId.slice(0, idx) : modelId;
|
|
1383
|
+
}
|
|
1384
|
+
export async function fetchCodingBenchmarks(apiKey, fetcher = fetch) {
|
|
1385
|
+
try {
|
|
1386
|
+
const resp = await fetcher(`${BROADSIDE_BENCHMARKS_URL}?source=artificial-analysis&task_type=coding`, {
|
|
1387
|
+
method: "GET",
|
|
1388
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
1389
|
+
signal: AbortSignal.timeout(30_000),
|
|
1390
|
+
});
|
|
1391
|
+
const data = (await resp.json());
|
|
1392
|
+
const byBaseSlug = {};
|
|
1393
|
+
for (const row of data.data ?? []) {
|
|
1394
|
+
const slug = baseSlug(String(row.model_permaslug ?? ""));
|
|
1395
|
+
if (!slug)
|
|
1396
|
+
continue;
|
|
1397
|
+
const toIndex = (v) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
|
|
1398
|
+
byBaseSlug[slug] = {
|
|
1399
|
+
codingIndex: toIndex(row.coding_index),
|
|
1400
|
+
intelligenceIndex: toIndex(row.intelligence_index),
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
return { byBaseSlug, meta: data.meta ?? {} };
|
|
1404
|
+
}
|
|
1405
|
+
catch {
|
|
1406
|
+
return null;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
export async function listBatchModels(broadsideDir, config, apiKey, opts = {}) {
|
|
1410
|
+
const fetcher = opts.fetcher ?? fetch;
|
|
1411
|
+
const resp = await fetcher(BROADSIDE_MODELS_URL, {
|
|
1412
|
+
method: "GET",
|
|
1413
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
1414
|
+
signal: AbortSignal.timeout(30_000),
|
|
1415
|
+
});
|
|
1416
|
+
const data = (await resp.json());
|
|
1417
|
+
const entries = [];
|
|
1418
|
+
const seen = new Set();
|
|
1419
|
+
for (const raw of data.data ?? []) {
|
|
1420
|
+
const entry = parseCatalogEntry(raw);
|
|
1421
|
+
if (!entry || seen.has(entry.id))
|
|
1422
|
+
continue;
|
|
1423
|
+
seen.add(entry.id);
|
|
1424
|
+
if (!entry.id.endsWith(":batch"))
|
|
1425
|
+
continue;
|
|
1426
|
+
entries.push(entry);
|
|
1427
|
+
}
|
|
1428
|
+
entries.sort((a, b) => a.inputPerM + a.outputPerM - (b.inputPerM + b.outputPerM));
|
|
1429
|
+
// Persist the catalog so the next submit's pricing resolution hits cache.
|
|
1430
|
+
const cache = { schema_version: 2, fetched_at: new Date().toISOString(), models: {} };
|
|
1431
|
+
for (const entry of entries)
|
|
1432
|
+
cache.models[entry.id] = entry;
|
|
1433
|
+
await writeCatalogCache(broadsideDir, cache);
|
|
1434
|
+
const benchmarks = opts.includeBenchmarks ? await fetchCodingBenchmarks(apiKey, fetcher) : null;
|
|
1435
|
+
return { entries, source: "live", benchmarks, defaultModel: config.model };
|
|
1436
|
+
}
|
|
1437
|
+
export async function submitBatch(batchRequests, apiKey, fetcher = fetch, model = BROADSIDE_MODEL) {
|
|
1438
|
+
// The OpenRouter batch endpoint stream-parses the body and requires
|
|
1439
|
+
// `endpoint` and `model` to serialize before `requests` — key order matters.
|
|
1440
|
+
const payload = {
|
|
1441
|
+
endpoint: "/v1/chat/completions",
|
|
1442
|
+
model,
|
|
1443
|
+
requests: batchRequests,
|
|
1444
|
+
};
|
|
1445
|
+
const resp = await fetcher(BROADSIDE_BATCH_URL, {
|
|
1446
|
+
method: "POST",
|
|
1447
|
+
headers: {
|
|
1448
|
+
Authorization: `Bearer ${apiKey}`,
|
|
1449
|
+
"Content-Type": "application/json",
|
|
1450
|
+
},
|
|
1451
|
+
body: JSON.stringify(payload),
|
|
1452
|
+
signal: AbortSignal.timeout(30_000),
|
|
1453
|
+
});
|
|
1454
|
+
const data = (await resp.json());
|
|
1455
|
+
if (resp.status !== 202) {
|
|
1456
|
+
return { batchId: "", status: "rejected", error: data };
|
|
1457
|
+
}
|
|
1458
|
+
return { batchId: String(data.id), status: String(data.status) };
|
|
1459
|
+
}
|
|
1460
|
+
export async function fetchBatch(batchId, apiKey, fetcher = fetch) {
|
|
1461
|
+
const resp = await fetcher(`${BROADSIDE_BATCH_URL}/${batchId}`, {
|
|
1462
|
+
method: "GET",
|
|
1463
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
1464
|
+
signal: AbortSignal.timeout(30_000),
|
|
1465
|
+
});
|
|
1466
|
+
const data = (await resp.json());
|
|
1467
|
+
// Surface the HTTP status so the poller can bail fast on auth expiry
|
|
1468
|
+
// instead of retrying a dead key for the whole budget.
|
|
1469
|
+
data.http_status = resp.status;
|
|
1470
|
+
return data;
|
|
1471
|
+
}
|
|
1472
|
+
export async function pollBatchUntilTerminal(batchId, apiKey, opts = {}) {
|
|
1473
|
+
const deadline = Date.now() + (opts.deadlineMs ?? BROADSIDE_DEFAULT_POLL_BUDGET_MS);
|
|
1474
|
+
const intervalMs = opts.pollIntervalMs ?? BROADSIDE_POLL_INTERVAL_MS;
|
|
1475
|
+
const fetcher = opts.fetcher ?? fetch;
|
|
1476
|
+
for (;;) {
|
|
1477
|
+
let batch;
|
|
1478
|
+
try {
|
|
1479
|
+
batch = await fetchBatch(batchId, apiKey, fetcher);
|
|
1480
|
+
}
|
|
1481
|
+
catch {
|
|
1482
|
+
if (Date.now() >= deadline)
|
|
1483
|
+
return { id: batchId, status: "timeout" };
|
|
1484
|
+
await sleep(intervalMs);
|
|
1485
|
+
continue;
|
|
1486
|
+
}
|
|
1487
|
+
const httpStatus = Number(batch.http_status ?? 200);
|
|
1488
|
+
if (httpStatus === 401 || httpStatus === 403) {
|
|
1489
|
+
return { id: batchId, status: "auth-failed", error: batch.error ?? batch };
|
|
1490
|
+
}
|
|
1491
|
+
const status = String(batch.status ?? "unknown");
|
|
1492
|
+
const counts = (batch.request_counts ?? {});
|
|
1493
|
+
opts.onStatus?.(status, counts);
|
|
1494
|
+
if (["completed", "failed", "expired", "cancelled", "auth-failed"].includes(status))
|
|
1495
|
+
return batch;
|
|
1496
|
+
if (Date.now() >= deadline)
|
|
1497
|
+
return { id: batchId, status: "timeout" };
|
|
1498
|
+
await sleep(intervalMs);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
/**
|
|
1502
|
+
* Poll several batch ids in parallel against one shared deadline. Collect
|
|
1503
|
+
* previously polled one lens at a time, so a slow first lens serialized the
|
|
1504
|
+
* wall clock for lenses that had already finished server-side (#136). The
|
|
1505
|
+
* onStatus callback identifies the lens so progress output stays readable
|
|
1506
|
+
* even while the polls interleave.
|
|
1507
|
+
*/
|
|
1508
|
+
export async function pollBatchesConcurrently(entries, apiKey, opts = {}) {
|
|
1509
|
+
const results = new Map();
|
|
1510
|
+
const deadlineMs = opts.deadlineMs ?? BROADSIDE_DEFAULT_POLL_BUDGET_MS;
|
|
1511
|
+
await Promise.all(entries.map(async ({ lensId, batchId }) => {
|
|
1512
|
+
const batch = await pollBatchUntilTerminal(batchId, apiKey, {
|
|
1513
|
+
deadlineMs,
|
|
1514
|
+
fetcher: opts.fetcher,
|
|
1515
|
+
pollIntervalMs: opts.pollIntervalMs,
|
|
1516
|
+
onStatus: (status, counts) => opts.onStatus?.(lensId, status, counts),
|
|
1517
|
+
});
|
|
1518
|
+
results.set(batchId, batch);
|
|
1519
|
+
}));
|
|
1520
|
+
return results;
|
|
1521
|
+
}
|
|
1522
|
+
// ---------- run orchestration ----------
|
|
1523
|
+
export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
1524
|
+
const info = await collectRepoInfo(cwd);
|
|
1525
|
+
const lensIds = opts.lenses ?? BROADSIDE_LENS_IDS;
|
|
1526
|
+
const broadsideDir = broadsideDirFor(cwd);
|
|
1527
|
+
const model = opts.model ?? BROADSIDE_MODEL;
|
|
1528
|
+
// Resolve a catalog entry per distinct model before anything is submitted:
|
|
1529
|
+
// the guardrail must know real per-token rates, and every lens requires
|
|
1530
|
+
// structured-output support that not all batch models offer. Lenses may run
|
|
1531
|
+
// on different models (config `lens_models`), so each one is pre-flighted.
|
|
1532
|
+
const config = await loadBroadsideConfig(broadsideDir);
|
|
1533
|
+
const modelForLens = (lensId) => config.lensModels[lensId] ?? model;
|
|
1534
|
+
const resolved = new Map();
|
|
1535
|
+
for (const candidate of new Set([model, ...lensIds.map(modelForLens)])) {
|
|
1536
|
+
const catalog = await resolveCatalogEntry(broadsideDir, config, candidate, apiKey, opts.fetcher);
|
|
1537
|
+
const entry = catalog.entry;
|
|
1538
|
+
const supportsStructuredOutputs = entry.supportedParameters.length === 0 ||
|
|
1539
|
+
entry.supportedParameters.some((p) => ["structured_outputs", "json_schema", "response_format", "structuredoutputs"].includes(p.toLowerCase()));
|
|
1540
|
+
if (!supportsStructuredOutputs) {
|
|
1541
|
+
throw new Error(`Batch model "${candidate}" does not advertise structured-output support ` +
|
|
1542
|
+
`(supported_parameters: ${entry.supportedParameters.join(", ") || "unknown"}), but every ` +
|
|
1543
|
+
"Broad-Side lens requires json_schema response_format. Choose another batch model " +
|
|
1544
|
+
"(codecarto_broadside action 'models') or pass a pricing override only if you know it works.");
|
|
1545
|
+
}
|
|
1546
|
+
resolved.set(candidate, {
|
|
1547
|
+
entry,
|
|
1548
|
+
supportsStructuredOutputs,
|
|
1549
|
+
pricing: { inputPerM: entry.inputPerM, outputPerM: entry.outputPerM, source: catalog.source },
|
|
1550
|
+
// Respect the provider's completion ceiling: a request asking for more
|
|
1551
|
+
// output than the model can produce fails the whole batch.
|
|
1552
|
+
...(entry.maxCompletionTokens !== undefined && { outputCap: entry.maxCompletionTokens }),
|
|
1553
|
+
});
|
|
1554
|
+
}
|
|
1555
|
+
const pricing = resolved.get(model).pricing;
|
|
1556
|
+
const outputCap = resolved.get(model).outputCap;
|
|
1557
|
+
const defaultEntry = resolved.get(model).entry;
|
|
1558
|
+
const limit = opts.maxCost ?? config.maxCost;
|
|
1559
|
+
// Incremental re-scouting (#142): diff against the previous run's HEAD
|
|
1560
|
+
// and scan only the modules whose files changed. Falls back to a full
|
|
1561
|
+
// scan when there is no prior run, the tree is dirty, or the diff fails.
|
|
1562
|
+
const sourceHead = await gitHead(cwd);
|
|
1563
|
+
const sourceDirty = await gitDirty(cwd);
|
|
1564
|
+
let baseHead = null;
|
|
1565
|
+
let changed = null;
|
|
1566
|
+
if (opts.incremental) {
|
|
1567
|
+
const state = await loadBroadsideState(broadsideDir);
|
|
1568
|
+
// The baseline is the most recent run that recorded a HEAD — a
|
|
1569
|
+
// submit-only run (never collected) is still a valid committed base.
|
|
1570
|
+
const previous = [...state.runs].reverse().find((r) => r.sourceHead);
|
|
1571
|
+
if (previous?.sourceHead && !sourceDirty) {
|
|
1572
|
+
baseHead = previous.sourceHead;
|
|
1573
|
+
changed = await changedFilesSince(cwd, baseHead);
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
// Slice offline first so the estimate covers every request we would send.
|
|
1577
|
+
const slicesByLens = new Map();
|
|
1578
|
+
let estimatedInputTokens = 0;
|
|
1579
|
+
let estimatedOutputTokens = 0;
|
|
1580
|
+
let estimatedTotalCost = 0;
|
|
1581
|
+
const perLensEstimate = [];
|
|
1582
|
+
for (const lensId of lensIds) {
|
|
1583
|
+
const lens = getLens(lensId);
|
|
1584
|
+
let slices = await gatherSlices(cwd, lens, info);
|
|
1585
|
+
if (changed) {
|
|
1586
|
+
// Repo-info slices (empty files, e.g. architecture) always run;
|
|
1587
|
+
// file-backed slices run only when one of their files changed.
|
|
1588
|
+
slices = slices.filter((s) => s.files.length === 0 || s.files.some((f) => changed.has(f)));
|
|
1589
|
+
}
|
|
1590
|
+
slicesByLens.set(lensId, slices);
|
|
1591
|
+
const lensModel = modelForLens(lensId);
|
|
1592
|
+
const { pricing: lensPricing, outputCap: lensOutputCap } = resolved.get(lensModel);
|
|
1593
|
+
const maxTokens = lensOutputCap ? Math.min(lens.maxTokens, lensOutputCap) : lens.maxTokens;
|
|
1594
|
+
const estimate = estimateCost(lens, slices, lensPricing, maxTokens);
|
|
1595
|
+
estimatedInputTokens += estimate.inputTokens;
|
|
1596
|
+
estimatedOutputTokens += estimate.outputTokens;
|
|
1597
|
+
estimatedTotalCost += estimate.cost;
|
|
1598
|
+
perLensEstimate.push({
|
|
1599
|
+
lens,
|
|
1600
|
+
cost: estimate.cost,
|
|
1601
|
+
maxTokens,
|
|
1602
|
+
lensModel,
|
|
1603
|
+
lensPricing,
|
|
1604
|
+
...(lensOutputCap !== undefined && { lensOutputCap }),
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
const exceedsLimit = limit > 0 && estimatedTotalCost > limit;
|
|
1608
|
+
if (opts.confirm) {
|
|
1609
|
+
const approved = await opts.confirm({
|
|
1610
|
+
model,
|
|
1611
|
+
pricing,
|
|
1612
|
+
lenses: perLensEstimate.map(({ lens, cost, maxTokens, lensModel, lensPricing }) => ({
|
|
1613
|
+
lensId: lens.id,
|
|
1614
|
+
name: lens.name,
|
|
1615
|
+
slices: (slicesByLens.get(lens.id) ?? []).length,
|
|
1616
|
+
maxTokens,
|
|
1617
|
+
cost,
|
|
1618
|
+
model: lensModel,
|
|
1619
|
+
pricing: lensPricing,
|
|
1620
|
+
})),
|
|
1621
|
+
mixedModels: perLensEstimate.some(({ lensModel }) => lensModel !== model),
|
|
1622
|
+
totalCost: estimatedTotalCost,
|
|
1623
|
+
inputTokens: estimatedInputTokens,
|
|
1624
|
+
outputTokens: estimatedOutputTokens,
|
|
1625
|
+
maxCost: limit,
|
|
1626
|
+
exceedsLimit,
|
|
1627
|
+
baseHead,
|
|
1628
|
+
sourceDirty,
|
|
1629
|
+
...(outputCap !== undefined && { outputCap }),
|
|
1630
|
+
});
|
|
1631
|
+
if (!approved)
|
|
1632
|
+
throw new BroadsideCancelledError();
|
|
1633
|
+
}
|
|
1634
|
+
else if (exceedsLimit && !opts.force) {
|
|
1635
|
+
const breakdown = perLensEstimate
|
|
1636
|
+
.map(({ lens, cost, lensModel }) => ` ${lens.name}: ~$${cost.toFixed(4)}${lensModel === model ? "" : ` (${lensModel})`}`)
|
|
1637
|
+
.join("\n");
|
|
1638
|
+
throw new Error(`Estimated Broad-Side cost ~$${estimatedTotalCost.toFixed(4)} exceeds the run limit ` +
|
|
1639
|
+
`$${limit.toFixed(2)}. Nothing was submitted.\nBreakdown:\n${breakdown}\n` +
|
|
1640
|
+
`Pass force: true to submit anyway, or raise max_cost in .codecarto/broadside/config.yaml.`);
|
|
1641
|
+
}
|
|
1642
|
+
const state = await loadBroadsideState(broadsideDir);
|
|
1643
|
+
const runId = new Date().toISOString().replace(/[:.]/g, "-");
|
|
1644
|
+
const run = {
|
|
1645
|
+
id: runId,
|
|
1646
|
+
createdAt: new Date().toISOString(),
|
|
1647
|
+
model,
|
|
1648
|
+
lenses: [...lensIds],
|
|
1649
|
+
status: "in-flight",
|
|
1650
|
+
outputDir: runId,
|
|
1651
|
+
batches: {},
|
|
1652
|
+
synthesis: { status: "pending" },
|
|
1653
|
+
triage: { status: "pending" },
|
|
1654
|
+
pricing,
|
|
1655
|
+
maxCost: limit > 0 ? limit : undefined,
|
|
1656
|
+
outputCap,
|
|
1657
|
+
sourceHead,
|
|
1658
|
+
sourceDirty,
|
|
1659
|
+
baseHead,
|
|
1660
|
+
};
|
|
1661
|
+
state.runs.push(run);
|
|
1662
|
+
await saveBroadsideState(broadsideDir, state);
|
|
1663
|
+
const requestsByCustomId = {};
|
|
1664
|
+
const submissions = [];
|
|
1665
|
+
// Submit from the estimate rather than recomputing: the user approved that
|
|
1666
|
+
// breakdown, so the request that fires must be the one that was priced.
|
|
1667
|
+
for (const priced of perLensEstimate) {
|
|
1668
|
+
const { lens, maxTokens, lensModel, lensOutputCap } = priced;
|
|
1669
|
+
const lensId = lens.id;
|
|
1670
|
+
const slices = slicesByLens.get(lensId) ?? [];
|
|
1671
|
+
const requests = slices.map((sl, i) => buildBatchRequest(lens, info, sl, i, slices.length, lensModel, maxTokens));
|
|
1672
|
+
for (const request of requests)
|
|
1673
|
+
requestsByCustomId[request.custom_id] = request;
|
|
1674
|
+
const entry = {
|
|
1675
|
+
batchId: "",
|
|
1676
|
+
requests: requests.length,
|
|
1677
|
+
status: "submitting",
|
|
1678
|
+
submittedAt: new Date().toISOString(),
|
|
1679
|
+
estimatedCost: priced.cost,
|
|
1680
|
+
// Recorded per lens so collect's truncation retry re-submits against
|
|
1681
|
+
// the model and ceiling this lens actually used, not the run default.
|
|
1682
|
+
...(lensModel !== model && { model: lensModel }),
|
|
1683
|
+
...(lensOutputCap !== undefined && { outputCap: lensOutputCap }),
|
|
1684
|
+
};
|
|
1685
|
+
run.batches[lensId] = entry;
|
|
1686
|
+
if (requests.length === 0) {
|
|
1687
|
+
// No files matched the lens's globs. That is a coverage gap to
|
|
1688
|
+
// report, not a batch to submit — the API rejects empty batches.
|
|
1689
|
+
entry.status = "skipped";
|
|
1690
|
+
continue;
|
|
1691
|
+
}
|
|
1692
|
+
submissions.push((async () => {
|
|
1693
|
+
// A network-level throw (DNS, abort, TLS) must not strand the
|
|
1694
|
+
// entry in "submitting" forever — allSettled would swallow the
|
|
1695
|
+
// rejection and collect would never see a terminal status.
|
|
1696
|
+
try {
|
|
1697
|
+
const { batchId, status, error } = await submitBatch(requests, apiKey, opts.fetcher, lensModel);
|
|
1698
|
+
entry.batchId = batchId;
|
|
1699
|
+
entry.status = status;
|
|
1700
|
+
if (error)
|
|
1701
|
+
entry.error = error;
|
|
1702
|
+
}
|
|
1703
|
+
catch (error) {
|
|
1704
|
+
entry.status = "rejected";
|
|
1705
|
+
entry.error = error instanceof Error ? error.message : String(error);
|
|
1706
|
+
}
|
|
1707
|
+
})());
|
|
1708
|
+
}
|
|
1709
|
+
await Promise.allSettled(submissions);
|
|
1710
|
+
await saveBroadsideState(broadsideDir, state);
|
|
1711
|
+
// Persist the exact request bodies so collect can re-submit a truncated
|
|
1712
|
+
// slice (bumped output cap) without re-walking the repo (#133). The run
|
|
1713
|
+
// dir is created here rather than waiting for collect so a crash between
|
|
1714
|
+
// submit and collect still leaves the retry input on disk.
|
|
1715
|
+
const runDir = join(broadsideDir, runId);
|
|
1716
|
+
await mkdir(runDir, { recursive: true });
|
|
1717
|
+
await writeFile(join(runDir, "requests.json"), `${JSON.stringify(requestsByCustomId, null, "\t")}\n`, "utf8");
|
|
1718
|
+
return {
|
|
1719
|
+
runId,
|
|
1720
|
+
outputDir: join(".codecarto", BROADSIDE_DIR, runId),
|
|
1721
|
+
batches: run.batches,
|
|
1722
|
+
estimatedTotalCost,
|
|
1723
|
+
estimatedInputTokens,
|
|
1724
|
+
estimatedOutputTokens,
|
|
1725
|
+
pricing,
|
|
1726
|
+
maxCost: limit > 0 ? limit : undefined,
|
|
1727
|
+
// modelInfo describes the run's default model. Per-lens overrides are
|
|
1728
|
+
// recorded on their own batch entries.
|
|
1729
|
+
modelInfo: {
|
|
1730
|
+
contextLength: defaultEntry.contextLength,
|
|
1731
|
+
maxCompletionTokens: defaultEntry.maxCompletionTokens,
|
|
1732
|
+
supportsStructuredOutputs: defaultEntry.supportedParameters.length === 0
|
|
1733
|
+
? undefined
|
|
1734
|
+
: resolved.get(model).supportsStructuredOutputs,
|
|
1735
|
+
expirationDate: defaultEntry.expirationDate ?? null,
|
|
1736
|
+
},
|
|
1737
|
+
};
|
|
1738
|
+
}
|
|
1739
|
+
function extractContent(result) {
|
|
1740
|
+
const response = result.response;
|
|
1741
|
+
if (!response?.body)
|
|
1742
|
+
return null;
|
|
1743
|
+
const body = response.body;
|
|
1744
|
+
const choices = body.choices;
|
|
1745
|
+
const message = choices?.[0]?.message;
|
|
1746
|
+
return typeof message?.content === "string" ? message.content : null;
|
|
1747
|
+
}
|
|
1748
|
+
/**
|
|
1749
|
+
* Parse lens content as JSON, tolerating the markdown code fences some models
|
|
1750
|
+
* wrap structured output in (the same tolerance OpenRouter's headless-agent
|
|
1751
|
+
* scaffold ships for --output-schema). Returns null when the content is not
|
|
1752
|
+
* JSON at all — which for a strict json_schema request means the output was
|
|
1753
|
+
* truncated at max_tokens, not that the model chose prose.
|
|
1754
|
+
*/
|
|
1755
|
+
export function parseLensJson(content) {
|
|
1756
|
+
const trimmed = content.trim();
|
|
1757
|
+
const fenced = /^```(?:json)?\s*\n?([\s\S]*?)\n?```\s*$/.exec(trimmed);
|
|
1758
|
+
const candidate = fenced ? fenced[1].trim() : trimmed;
|
|
1759
|
+
if (!candidate.startsWith("{") && !candidate.startsWith("["))
|
|
1760
|
+
return null;
|
|
1761
|
+
try {
|
|
1762
|
+
return JSON.parse(candidate);
|
|
1763
|
+
}
|
|
1764
|
+
catch {
|
|
1765
|
+
return null;
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
export async function saveLensResults(runDir, lensId, batch) {
|
|
1769
|
+
const results = Array.isArray(batch.results) ? batch.results : [];
|
|
1770
|
+
const out = [];
|
|
1771
|
+
for (const result of results) {
|
|
1772
|
+
const customId = String(result.custom_id ?? "unknown");
|
|
1773
|
+
const content = extractContent(result);
|
|
1774
|
+
if (content === null) {
|
|
1775
|
+
if (result.error) {
|
|
1776
|
+
await writeFile(join(runDir, `${sanitizeId(customId)}.error.json`), `${JSON.stringify(result.error, null, "\t")}\n`, "utf8");
|
|
1777
|
+
}
|
|
1778
|
+
continue;
|
|
1779
|
+
}
|
|
1780
|
+
const parsed = parseLensJson(content);
|
|
1781
|
+
const truncated = parsed === null;
|
|
1782
|
+
if (parsed !== null) {
|
|
1783
|
+
await writeFile(join(runDir, `${sanitizeId(customId)}.json`), `${JSON.stringify(parsed, null, "\t")}\n`, "utf8");
|
|
1784
|
+
}
|
|
1785
|
+
else {
|
|
1786
|
+
// Save the raw bytes verbatim so nothing is lost, but name the
|
|
1787
|
+
// gap: an unparseable strict-schema response is a truncation.
|
|
1788
|
+
await writeFile(join(runDir, `${sanitizeId(customId)}.json`), `${content}\n`, "utf8");
|
|
1789
|
+
}
|
|
1790
|
+
await writeFile(join(runDir, `${sanitizeId(customId)}.md`), renderFindingsMarkdown(content), "utf8");
|
|
1791
|
+
out.push({
|
|
1792
|
+
lensId,
|
|
1793
|
+
customId,
|
|
1794
|
+
moduleName: String(customId).replace(/^[a-z]+-/, ""),
|
|
1795
|
+
content,
|
|
1796
|
+
raw: result,
|
|
1797
|
+
truncated,
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1800
|
+
return out;
|
|
1801
|
+
}
|
|
1802
|
+
async function loadStoredRequests(runDir) {
|
|
1803
|
+
const path = join(runDir, "requests.json");
|
|
1804
|
+
if (!(await pathExists(path)))
|
|
1805
|
+
return {};
|
|
1806
|
+
try {
|
|
1807
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
1808
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
1809
|
+
}
|
|
1810
|
+
catch {
|
|
1811
|
+
return {};
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
// ---------- post-lens passes: synthesis + triage ----------
|
|
1815
|
+
function buildSynthesisRequest(findingsText, truncatedNote, model) {
|
|
1816
|
+
return {
|
|
1817
|
+
custom_id: "synthesis",
|
|
1818
|
+
body: {
|
|
1819
|
+
model,
|
|
1820
|
+
messages: [
|
|
1821
|
+
{
|
|
1822
|
+
role: "system",
|
|
1823
|
+
content: "You are a technical editor synthesizing multiple analysis reports about a single " +
|
|
1824
|
+
"codebase into one coherent summary. The reports come from different lenses — " +
|
|
1825
|
+
"architecture, API surface, security review, defect scanning, convention extraction, " +
|
|
1826
|
+
"and porting assessment. Cross-reference findings across lenses: if a security issue " +
|
|
1827
|
+
"also appears as a defect, merge them. Produce a JSON object following the " +
|
|
1828
|
+
"synthesis_report schema. Prioritize the most actionable findings. " +
|
|
1829
|
+
"Be honest about gaps — if a lens found nothing, say 'no issues found' rather than " +
|
|
1830
|
+
"inventing problems. These are scouting signals from a batch model, not verified " +
|
|
1831
|
+
"claims; note that in the summary.",
|
|
1832
|
+
},
|
|
1833
|
+
{
|
|
1834
|
+
role: "user",
|
|
1835
|
+
content: "Synthesize these analysis reports into a single summary.\n\n" +
|
|
1836
|
+
findingsText +
|
|
1837
|
+
truncatedNote +
|
|
1838
|
+
"\nReturn the synthesis_report JSON schema.",
|
|
1839
|
+
},
|
|
1840
|
+
],
|
|
1841
|
+
response_format: { type: "json_schema", json_schema: SCHEMAS.synthesis },
|
|
1842
|
+
max_tokens: 12_000,
|
|
1843
|
+
},
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1846
|
+
function buildTriageRequest(findingsText, truncatedNote, model) {
|
|
1847
|
+
return {
|
|
1848
|
+
custom_id: "triage",
|
|
1849
|
+
body: {
|
|
1850
|
+
model,
|
|
1851
|
+
messages: [
|
|
1852
|
+
{
|
|
1853
|
+
role: "system",
|
|
1854
|
+
content: "You are a senior engineering lead turning unverified scouting findings into a " +
|
|
1855
|
+
"prioritized work order. Given the findings below, produce a JSON object following " +
|
|
1856
|
+
"the triage_report schema. Score every lead by impact and fix difficulty, assign a " +
|
|
1857
|
+
"priority (P0 urgent/safety-critical to P3 nice-to-have), give a rough effort " +
|
|
1858
|
+
"estimate, group the queue by module where sensible, and justify each call in the " +
|
|
1859
|
+
"rationale. Merge duplicate leads instead of listing them twice. Drop leads that are " +
|
|
1860
|
+
"too vague to act on and record each drop in omitted with the reason. These findings " +
|
|
1861
|
+
"are UNVERIFIED scouting signals from a cheap batch model: the queue is a starting " +
|
|
1862
|
+
"point for re-verification, not a commitment — say so in the summary, and never " +
|
|
1863
|
+
"inflate a severity you cannot see evidence for.",
|
|
1864
|
+
},
|
|
1865
|
+
{
|
|
1866
|
+
role: "user",
|
|
1867
|
+
content: "Triage these scouting findings into a prioritized work order.\n\n" +
|
|
1868
|
+
findingsText +
|
|
1869
|
+
truncatedNote +
|
|
1870
|
+
"\nReturn the triage_report JSON schema.",
|
|
1871
|
+
},
|
|
1872
|
+
],
|
|
1873
|
+
response_format: { type: "json_schema", json_schema: SCHEMAS.triage },
|
|
1874
|
+
max_tokens: 10_000,
|
|
1875
|
+
},
|
|
1876
|
+
};
|
|
1877
|
+
}
|
|
1878
|
+
function parseTriageItems(content) {
|
|
1879
|
+
try {
|
|
1880
|
+
const parsed = JSON.parse(content);
|
|
1881
|
+
const items = Array.isArray(parsed.items) ? parsed.items : [];
|
|
1882
|
+
return items
|
|
1883
|
+
.filter((item) => typeof item.title === "string")
|
|
1884
|
+
.map((item) => ({
|
|
1885
|
+
title: String(item.title),
|
|
1886
|
+
severity: String(item.severity ?? "unknown"),
|
|
1887
|
+
module: String(item.module ?? "unknown"),
|
|
1888
|
+
impact: (["high", "medium", "low"].includes(String(item.impact)) ? String(item.impact) : "medium"),
|
|
1889
|
+
difficulty: (["high", "medium", "low"].includes(String(item.difficulty)) ? String(item.difficulty) : "medium"),
|
|
1890
|
+
priority: String(item.priority ?? "?"),
|
|
1891
|
+
effort_estimate: String(item.effort_estimate ?? ""),
|
|
1892
|
+
rationale: String(item.rationale ?? ""),
|
|
1893
|
+
}));
|
|
1894
|
+
}
|
|
1895
|
+
catch {
|
|
1896
|
+
return [];
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
1900
|
+
const broadsideDir = broadsideDirFor(cwd);
|
|
1901
|
+
const state = await loadBroadsideState(broadsideDir);
|
|
1902
|
+
const run = state.runs[state.runs.length - 1];
|
|
1903
|
+
if (!run) {
|
|
1904
|
+
throw new Error("No Broad-Side run recorded. Call codecarto_broadside with action 'submit' first.");
|
|
1905
|
+
}
|
|
1906
|
+
const runDir = join(broadsideDir, run.outputDir);
|
|
1907
|
+
await mkdir(runDir, { recursive: true });
|
|
1908
|
+
const deadline = Date.now() + (opts.waitMs ?? BROADSIDE_DEFAULT_POLL_BUDGET_MS);
|
|
1909
|
+
let totalCost = 0;
|
|
1910
|
+
let resultCount = 0;
|
|
1911
|
+
let truncatedCount = 0;
|
|
1912
|
+
const lensOutcomes = {};
|
|
1913
|
+
const allLensResults = [];
|
|
1914
|
+
// Terminal entries are settled already; everything else polls in parallel
|
|
1915
|
+
// against one shared deadline (#136), then results save in lens order so
|
|
1916
|
+
// output layout stays deterministic.
|
|
1917
|
+
const inFlight = [];
|
|
1918
|
+
for (const lensId of run.lenses) {
|
|
1919
|
+
const entry = run.batches[lensId];
|
|
1920
|
+
if (!entry || !entry.batchId) {
|
|
1921
|
+
lensOutcomes[lensId] = { status: entry?.status ?? "failed", resultCount: 0 };
|
|
1922
|
+
continue;
|
|
1923
|
+
}
|
|
1924
|
+
if (["completed", "failed", "expired", "cancelled", "auth-failed", "skipped", "rejected"].includes(entry.status)) {
|
|
1925
|
+
totalCost += entry.cost ?? 0;
|
|
1926
|
+
resultCount += entry.resultCount ?? 0;
|
|
1927
|
+
lensOutcomes[lensId] = { status: entry.status, cost: entry.cost, resultCount: entry.resultCount };
|
|
1928
|
+
continue;
|
|
1929
|
+
}
|
|
1930
|
+
inFlight.push({ lensId, batchId: entry.batchId });
|
|
1931
|
+
}
|
|
1932
|
+
const polled = await pollBatchesConcurrently(inFlight, apiKey, {
|
|
1933
|
+
deadlineMs: Math.max(0, deadline - Date.now()),
|
|
1934
|
+
fetcher: opts.fetcher,
|
|
1935
|
+
onStatus: opts.onStatus,
|
|
1936
|
+
});
|
|
1937
|
+
for (const { lensId } of inFlight) {
|
|
1938
|
+
const entry = run.batches[lensId];
|
|
1939
|
+
if (!entry)
|
|
1940
|
+
continue;
|
|
1941
|
+
const batch = polled.get(entry.batchId) ?? { id: entry.batchId, status: "timeout" };
|
|
1942
|
+
const status = String(batch.status ?? "unknown");
|
|
1943
|
+
entry.status = status;
|
|
1944
|
+
if (status === "completed") {
|
|
1945
|
+
const usage = (batch.usage ?? {});
|
|
1946
|
+
const cost = typeof usage.cost === "number" ? usage.cost : undefined;
|
|
1947
|
+
entry.cost = cost;
|
|
1948
|
+
entry.completedAt = new Date().toISOString();
|
|
1949
|
+
const stored = await saveLensResults(runDir, lensId, batch);
|
|
1950
|
+
entry.resultCount = stored.length;
|
|
1951
|
+
const truncated = stored.filter((s) => s.truncated).length;
|
|
1952
|
+
allLensResults.push(...stored);
|
|
1953
|
+
resultCount += stored.length;
|
|
1954
|
+
truncatedCount += truncated;
|
|
1955
|
+
totalCost += cost ?? 0;
|
|
1956
|
+
await writeFile(join(runDir, `raw-${lensId}.json`), `${JSON.stringify(batch, null, "\t")}\n`, "utf8");
|
|
1957
|
+
lensOutcomes[lensId] = { status, cost: entry.cost, resultCount: entry.resultCount, truncated };
|
|
1958
|
+
}
|
|
1959
|
+
else if (batch.error) {
|
|
1960
|
+
entry.error = batch.error;
|
|
1961
|
+
lensOutcomes[lensId] = { status, cost: entry.cost, resultCount: entry.resultCount };
|
|
1962
|
+
}
|
|
1963
|
+
await saveBroadsideState(broadsideDir, state);
|
|
1964
|
+
}
|
|
1965
|
+
// #133: re-submit truncated slices once with a bumped output cap. Batch
|
|
1966
|
+
// requests are pure, so re-running is always safe; the aim is to recover
|
|
1967
|
+
// coverage the first pass lost to a max_tokens cutoff, not to loop forever.
|
|
1968
|
+
let retriedCount = 0;
|
|
1969
|
+
if (opts.retryTruncated !== false && truncatedCount > 0) {
|
|
1970
|
+
const requestsByCustomId = await loadStoredRequests(runDir);
|
|
1971
|
+
for (const stored of allLensResults) {
|
|
1972
|
+
if (!stored.truncated)
|
|
1973
|
+
continue;
|
|
1974
|
+
const original = requestsByCustomId[stored.customId];
|
|
1975
|
+
if (!original)
|
|
1976
|
+
continue;
|
|
1977
|
+
const lensEntry = run.batches[stored.lensId];
|
|
1978
|
+
// A lens may have run on its own model (config `lens_models`), with its
|
|
1979
|
+
// own completion ceiling. Re-submitting against the run default would
|
|
1980
|
+
// change the model mid-run and could exceed that lens's real ceiling.
|
|
1981
|
+
const lensModel = lensEntry?.model ?? run.model;
|
|
1982
|
+
const lensCap = lensEntry?.outputCap ?? run.outputCap;
|
|
1983
|
+
const previousMax = original.body.max_tokens ?? getLens(stored.lensId).maxTokens;
|
|
1984
|
+
const bumpedMax = lensCap ? Math.min(previousMax * 2, lensCap) : previousMax * 2;
|
|
1985
|
+
if (bumpedMax <= previousMax)
|
|
1986
|
+
continue; // already at the ceiling
|
|
1987
|
+
const bumped = {
|
|
1988
|
+
...original,
|
|
1989
|
+
body: { ...original.body, max_tokens: bumpedMax },
|
|
1990
|
+
};
|
|
1991
|
+
try {
|
|
1992
|
+
const { batchId, error } = await submitBatch([bumped], apiKey, opts.fetcher, lensModel);
|
|
1993
|
+
if (error)
|
|
1994
|
+
continue;
|
|
1995
|
+
const batch = await pollBatchUntilTerminal(batchId, apiKey, {
|
|
1996
|
+
deadlineMs: BROADSIDE_DEFAULT_POLL_BUDGET_MS,
|
|
1997
|
+
onStatus: (status, counts) => opts.onStatus?.(`${stored.lensId}:retry`, status, counts),
|
|
1998
|
+
fetcher: opts.fetcher,
|
|
1999
|
+
});
|
|
2000
|
+
if (batch.status !== "completed")
|
|
2001
|
+
continue;
|
|
2002
|
+
const results = Array.isArray(batch.results) ? batch.results : [];
|
|
2003
|
+
const content = results.length > 0 ? extractContent(results[0]) : null;
|
|
2004
|
+
if (content === null || parseLensJson(content) === null)
|
|
2005
|
+
continue; // still no good
|
|
2006
|
+
const usage = (batch.usage ?? {});
|
|
2007
|
+
totalCost += typeof usage.cost === "number" ? usage.cost : 0;
|
|
2008
|
+
const parsed = parseLensJson(content);
|
|
2009
|
+
await writeFile(join(runDir, `${sanitizeId(stored.customId)}.json`), `${JSON.stringify(parsed, null, "\t")}\n`, "utf8");
|
|
2010
|
+
await writeFile(join(runDir, `${sanitizeId(stored.customId)}.md`), renderFindingsMarkdown(content), "utf8");
|
|
2011
|
+
stored.content = content;
|
|
2012
|
+
stored.truncated = false;
|
|
2013
|
+
retriedCount += 1;
|
|
2014
|
+
}
|
|
2015
|
+
catch {
|
|
2016
|
+
// A retry that fails to submit/poll leaves the original
|
|
2017
|
+
// truncated result in place — nothing is lost.
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
truncatedCount = allLensResults.filter((s) => s.truncated).length;
|
|
2021
|
+
for (const [lensId, outcome] of Object.entries(lensOutcomes)) {
|
|
2022
|
+
if (outcome.truncated !== undefined) {
|
|
2023
|
+
outcome.truncated = allLensResults.filter((s) => s.lensId === lensId && s.truncated).length;
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
await saveBroadsideState(broadsideDir, state);
|
|
2027
|
+
}
|
|
2028
|
+
// Synthesis + triage: cross-lens post-passes, only after every lens batch
|
|
2029
|
+
// is terminal. Triage turns the leads into a prioritized work order.
|
|
2030
|
+
run.triage ??= { status: "pending" };
|
|
2031
|
+
let topFindings = [];
|
|
2032
|
+
let topTriageItems = [];
|
|
2033
|
+
const wantSynthesis = opts.includeSynthesis !== false;
|
|
2034
|
+
const wantTriage = opts.includeTriage !== false;
|
|
2035
|
+
if ((wantSynthesis || wantTriage) && allLensResults.length > 0) {
|
|
2036
|
+
const allTerminal = run.lenses.every((lensId) => {
|
|
2037
|
+
const entry = run.batches[lensId];
|
|
2038
|
+
return entry && ["completed", "failed", "expired", "cancelled", "auth-failed", "skipped", "rejected"].includes(entry.status);
|
|
2039
|
+
});
|
|
2040
|
+
if (allTerminal && (run.synthesis.status === "pending" || run.triage.status === "pending")) {
|
|
2041
|
+
const findingsText = allLensResults
|
|
2042
|
+
.map((r) => `## ${r.lensId} — ${r.customId}\n\n${r.content}\n`)
|
|
2043
|
+
.join("\n");
|
|
2044
|
+
const truncatedNote = truncatedCount > 0
|
|
2045
|
+
? `\n\nNOTE: ${truncatedCount} lens result(s) were truncated at the output token limit and are ` +
|
|
2046
|
+
"not included above. Any gap they would have covered is unrepresented — do not treat " +
|
|
2047
|
+
"silence on a module as a clean bill.\n"
|
|
2048
|
+
: "";
|
|
2049
|
+
// Both post-passes consume the same findings; they run as two
|
|
2050
|
+
// batches (different response_format schemas cannot share one)
|
|
2051
|
+
// submitted together and polled in turn.
|
|
2052
|
+
const passes = [
|
|
2053
|
+
...(wantSynthesis && run.synthesis.status === "pending"
|
|
2054
|
+
? [{
|
|
2055
|
+
kind: "synthesis",
|
|
2056
|
+
request: buildSynthesisRequest(findingsText, truncatedNote, run.model),
|
|
2057
|
+
entry: run.synthesis,
|
|
2058
|
+
}]
|
|
2059
|
+
: []),
|
|
2060
|
+
...(wantTriage && run.triage.status === "pending"
|
|
2061
|
+
? [{
|
|
2062
|
+
kind: "triage",
|
|
2063
|
+
request: buildTriageRequest(findingsText, truncatedNote, run.model),
|
|
2064
|
+
entry: run.triage,
|
|
2065
|
+
}]
|
|
2066
|
+
: []),
|
|
2067
|
+
];
|
|
2068
|
+
const submitted = new Map();
|
|
2069
|
+
await Promise.allSettled(passes.map(async (pass) => {
|
|
2070
|
+
pass.entry.status = "submitted";
|
|
2071
|
+
try {
|
|
2072
|
+
const { batchId, error } = await submitBatch([pass.request], apiKey, opts.fetcher, run.model);
|
|
2073
|
+
if (error) {
|
|
2074
|
+
pass.entry.status = "failed";
|
|
2075
|
+
return;
|
|
2076
|
+
}
|
|
2077
|
+
pass.entry.batchId = batchId;
|
|
2078
|
+
submitted.set(batchId, { batchId, pass });
|
|
2079
|
+
}
|
|
2080
|
+
catch {
|
|
2081
|
+
pass.entry.status = "failed";
|
|
2082
|
+
}
|
|
2083
|
+
}));
|
|
2084
|
+
await saveBroadsideState(broadsideDir, state);
|
|
2085
|
+
for (const { batchId, pass } of submitted.values()) {
|
|
2086
|
+
const batch = await pollBatchUntilTerminal(batchId, apiKey, {
|
|
2087
|
+
deadlineMs: BROADSIDE_DEFAULT_POLL_BUDGET_MS,
|
|
2088
|
+
onStatus: (status, counts) => opts.onStatus?.(pass.kind, status, counts),
|
|
2089
|
+
fetcher: opts.fetcher,
|
|
2090
|
+
});
|
|
2091
|
+
if (batch.status === "completed") {
|
|
2092
|
+
const usage = (batch.usage ?? {});
|
|
2093
|
+
const cost = typeof usage.cost === "number" ? usage.cost : undefined;
|
|
2094
|
+
pass.entry.status = "completed";
|
|
2095
|
+
pass.entry.cost = cost;
|
|
2096
|
+
totalCost += cost ?? 0;
|
|
2097
|
+
const results = Array.isArray(batch.results) ? batch.results : [];
|
|
2098
|
+
const content = results.length > 0 ? extractContent(results[0]) : null;
|
|
2099
|
+
if (content !== null) {
|
|
2100
|
+
await writeFile(join(runDir, `${pass.kind}.json`), `${content}\n`, "utf8");
|
|
2101
|
+
await writeFile(join(runDir, `${pass.kind}.md`), renderFindingsMarkdown(content), "utf8");
|
|
2102
|
+
if (pass.kind === "synthesis") {
|
|
2103
|
+
topFindings = parseSynthesisTopFindings(content);
|
|
2104
|
+
}
|
|
2105
|
+
else {
|
|
2106
|
+
topTriageItems = parseTriageItems(content);
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
else if (batch.error) {
|
|
2111
|
+
pass.entry.status = "failed";
|
|
2112
|
+
}
|
|
2113
|
+
await saveBroadsideState(broadsideDir, state);
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
const terminal = run.lenses.every((lensId) => {
|
|
2118
|
+
const entry = run.batches[lensId];
|
|
2119
|
+
return entry && ["completed", "failed", "expired", "cancelled", "auth-failed", "skipped", "rejected"].includes(entry.status);
|
|
2120
|
+
});
|
|
2121
|
+
run.status = terminal ? (resultCount > 0 ? "completed" : "failed") : "partial";
|
|
2122
|
+
run.totalCost = totalCost;
|
|
2123
|
+
await saveBroadsideState(broadsideDir, state);
|
|
2124
|
+
await writeFile(join(runDir, "run-meta.json"), `${JSON.stringify({
|
|
2125
|
+
experimental: true,
|
|
2126
|
+
method: "Broad-Side (OpenRouter Batch API)",
|
|
2127
|
+
model: run.model,
|
|
2128
|
+
pricing: run.pricing,
|
|
2129
|
+
max_cost: run.maxCost,
|
|
2130
|
+
run_id: run.id,
|
|
2131
|
+
created_at: run.createdAt,
|
|
2132
|
+
status: run.status,
|
|
2133
|
+
total_cost: totalCost,
|
|
2134
|
+
result_count: resultCount,
|
|
2135
|
+
truncated_count: truncatedCount,
|
|
2136
|
+
retried_count: retriedCount,
|
|
2137
|
+
synthesis: run.synthesis,
|
|
2138
|
+
triage: run.triage,
|
|
2139
|
+
lenses: run.lenses,
|
|
2140
|
+
// Which lens ran on which model. Absent means the run default —
|
|
2141
|
+
// a reader comparing two runs needs to know a lens changed model.
|
|
2142
|
+
lens_models: Object.fromEntries(Object.entries(run.batches)
|
|
2143
|
+
.filter(([, batch]) => batch?.model)
|
|
2144
|
+
.map(([lensId, batch]) => [lensId, batch.model])),
|
|
2145
|
+
disclaimer: "Findings are unverified scouting signals from a batch model, not validated claims. " +
|
|
2146
|
+
"Re-verify every file:line lead with the interactive pipeline or by hand.",
|
|
2147
|
+
}, null, "\t")}\n`, "utf8");
|
|
2148
|
+
return {
|
|
2149
|
+
runId: run.id,
|
|
2150
|
+
status: run.status,
|
|
2151
|
+
totalCost,
|
|
2152
|
+
resultCount,
|
|
2153
|
+
truncatedCount,
|
|
2154
|
+
retriedCount,
|
|
2155
|
+
lensOutcomes,
|
|
2156
|
+
synthesis: run.synthesis,
|
|
2157
|
+
triage: run.triage,
|
|
2158
|
+
topFindings,
|
|
2159
|
+
topTriageItems,
|
|
2160
|
+
};
|
|
2161
|
+
}
|
|
2162
|
+
export async function runBroadsideStatus(cwd) {
|
|
2163
|
+
const broadsideDir = broadsideDirFor(cwd);
|
|
2164
|
+
const state = await loadBroadsideState(broadsideDir);
|
|
2165
|
+
return { state };
|
|
2166
|
+
}
|
|
2167
|
+
// ---------- rendering ----------
|
|
2168
|
+
export function renderFindingsMarkdown(content) {
|
|
2169
|
+
const parsed = parseLensJson(content);
|
|
2170
|
+
if (parsed === null)
|
|
2171
|
+
return content;
|
|
2172
|
+
return formatAsMarkdown(parsed);
|
|
2173
|
+
}
|
|
2174
|
+
function formatAsMarkdown(value, depth = 0) {
|
|
2175
|
+
const indent = "\t".repeat(depth);
|
|
2176
|
+
if (Array.isArray(value)) {
|
|
2177
|
+
const lines = [];
|
|
2178
|
+
for (let i = 0; i < value.length; i++) {
|
|
2179
|
+
const item = value[i];
|
|
2180
|
+
if (item && typeof item === "object") {
|
|
2181
|
+
const title = (item.title ?? item.name ?? item.module ?? item.area ?? item.platform ?? "");
|
|
2182
|
+
lines.push(`${indent}${i + 1}. ${title}`);
|
|
2183
|
+
lines.push(formatAsMarkdown(item, depth + 1));
|
|
2184
|
+
}
|
|
2185
|
+
else {
|
|
2186
|
+
lines.push(`${indent}- ${String(item)}`);
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
return lines.join("\n");
|
|
2190
|
+
}
|
|
2191
|
+
if (value && typeof value === "object") {
|
|
2192
|
+
const lines = [];
|
|
2193
|
+
for (const [key, entryValue] of Object.entries(value)) {
|
|
2194
|
+
if (entryValue && typeof entryValue === "object") {
|
|
2195
|
+
lines.push(`${indent}**${key}**:`);
|
|
2196
|
+
lines.push(formatAsMarkdown(entryValue, depth + 1));
|
|
2197
|
+
}
|
|
2198
|
+
else {
|
|
2199
|
+
lines.push(`${indent}- **${key}**: ${String(entryValue)}`);
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
return lines.join("\n");
|
|
2203
|
+
}
|
|
2204
|
+
return `${indent}${String(value)}`;
|
|
2205
|
+
}
|
|
2206
|
+
function parseSynthesisTopFindings(content) {
|
|
2207
|
+
try {
|
|
2208
|
+
const parsed = JSON.parse(content);
|
|
2209
|
+
const findings = Array.isArray(parsed.top_findings)
|
|
2210
|
+
? parsed.top_findings
|
|
2211
|
+
: [];
|
|
2212
|
+
return findings
|
|
2213
|
+
.filter((f) => typeof f.title === "string")
|
|
2214
|
+
.map((f) => ({
|
|
2215
|
+
title: String(f.title),
|
|
2216
|
+
severity: String(f.severity ?? "unknown"),
|
|
2217
|
+
sourceLens: String(f.source_lens ?? "unknown"),
|
|
2218
|
+
summary: String(f.summary ?? ""),
|
|
2219
|
+
}));
|
|
2220
|
+
}
|
|
2221
|
+
catch {
|
|
2222
|
+
return [];
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
// ---------- formatting helpers for tool output ----------
|
|
2226
|
+
export function estimateSubmitText(result, lenses) {
|
|
2227
|
+
const lines = [
|
|
2228
|
+
`Broad-Side submitted ${result.batches ? Object.keys(result.batches).length : 0} batch(es).`,
|
|
2229
|
+
];
|
|
2230
|
+
for (const lens of lenses) {
|
|
2231
|
+
const entry = result.batches[lens.id];
|
|
2232
|
+
if (!entry)
|
|
2233
|
+
continue;
|
|
2234
|
+
const status = entry.batchId ? `batch ${entry.batchId}` : entry.status;
|
|
2235
|
+
const override = entry.model ? ` on ${entry.model}` : "";
|
|
2236
|
+
lines.push(` ${lens.name}: ${status} (${entry.requests} request(s), ~$${entry.estimatedCost.toFixed(4)})${override}`);
|
|
2237
|
+
}
|
|
2238
|
+
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})`);
|
|
2239
|
+
if (result.modelInfo.contextLength) {
|
|
2240
|
+
lines.push(`Model: ${result.modelInfo.contextLength.toLocaleString()} context, ${result.modelInfo.maxCompletionTokens?.toLocaleString() ?? "?"} max output`);
|
|
2241
|
+
}
|
|
2242
|
+
if (result.modelInfo.supportsStructuredOutputs === false) {
|
|
2243
|
+
lines.push("Warning: model does not advertise structured-output support; lens JSON may be unreliable.");
|
|
2244
|
+
}
|
|
2245
|
+
if (result.modelInfo.expirationDate) {
|
|
2246
|
+
lines.push(`Warning: this model is deprecated (expires ${result.modelInfo.expirationDate}).`);
|
|
2247
|
+
}
|
|
2248
|
+
if (result.maxCost) {
|
|
2249
|
+
lines.push(`Run limit: $${result.maxCost.toFixed(2)} (enforced on estimate; pass force to override)`);
|
|
2250
|
+
}
|
|
2251
|
+
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.");
|
|
2252
|
+
return lines.join("\n");
|
|
2253
|
+
}
|
|
2254
|
+
export function modelsText(entries, opts) {
|
|
2255
|
+
const lines = [
|
|
2256
|
+
`Batch models on OpenRouter (${entries.length}, cheapest first).`,
|
|
2257
|
+
"",
|
|
2258
|
+
"id | $/M in | $/M out | ctx | max out | structured | coding idx",
|
|
2259
|
+
];
|
|
2260
|
+
for (const entry of entries) {
|
|
2261
|
+
const bench = opts.benchmarks?.byBaseSlug[baseSlug(entry.id)];
|
|
2262
|
+
const structured = entry.supportedParameters.length === 0
|
|
2263
|
+
? "?"
|
|
2264
|
+
: entry.supportedParameters.some((p) => ["structured_outputs", "json_schema", "response_format", "structuredoutputs"].includes(p.toLowerCase()))
|
|
2265
|
+
? "yes"
|
|
2266
|
+
: "no";
|
|
2267
|
+
const coding = bench?.codingIndex !== undefined ? bench.codingIndex.toFixed(1) : "-";
|
|
2268
|
+
const ctx = entry.contextLength
|
|
2269
|
+
? entry.contextLength >= 1_000_000
|
|
2270
|
+
? `${(entry.contextLength / 1_000_000).toFixed(1)}M`
|
|
2271
|
+
: `${(entry.contextLength / 1024).toFixed(0)}k`
|
|
2272
|
+
: "?";
|
|
2273
|
+
const out = entry.maxCompletionTokens ? `${(entry.maxCompletionTokens / 1024).toFixed(0)}k` : "?";
|
|
2274
|
+
const tag = entry.id === opts.defaultModel ? " (default)" : "";
|
|
2275
|
+
const exp = entry.expirationDate ? " [deprecated]" : "";
|
|
2276
|
+
lines.push(`${entry.id}${tag}${exp} | ${entry.inputPerM.toFixed(3)} | ${entry.outputPerM.toFixed(3)} | ${ctx} | ${out} | ${structured} | ${coding}`);
|
|
2277
|
+
}
|
|
2278
|
+
if (opts.benchmarks?.meta.as_of) {
|
|
2279
|
+
lines.push("", `Benchmarks: Artificial Analysis coding index (as of ${String(opts.benchmarks.meta.as_of)}).`);
|
|
2280
|
+
}
|
|
2281
|
+
lines.push("", "Set the batch model in .codecarto/broadside/config.yaml (model key). Higher coding index ≠ better scout: precision, context, and structured-output support matter most here.");
|
|
2282
|
+
return lines.join("\n");
|
|
2283
|
+
}
|
|
2284
|
+
export function collectResultText(result) {
|
|
2285
|
+
const lines = [
|
|
2286
|
+
`Broad-Side run ${result.runId}: ${result.status}`,
|
|
2287
|
+
` Results: ${result.resultCount} | Total cost: $${result.totalCost.toFixed(6)}`,
|
|
2288
|
+
];
|
|
2289
|
+
for (const lensId of BROADSIDE_LENS_IDS) {
|
|
2290
|
+
const outcome = result.lensOutcomes[lensId];
|
|
2291
|
+
if (!outcome)
|
|
2292
|
+
continue;
|
|
2293
|
+
const truncation = outcome.truncated ? `, ${outcome.truncated} truncated` : "";
|
|
2294
|
+
lines.push(` ${lensId}: ${outcome.status}` +
|
|
2295
|
+
(outcome.cost !== undefined ? `, $${outcome.cost.toFixed(6)}` : "") +
|
|
2296
|
+
(outcome.resultCount !== undefined ? `, ${outcome.resultCount} result(s)` : "") +
|
|
2297
|
+
truncation);
|
|
2298
|
+
}
|
|
2299
|
+
if (result.retriedCount > 0) {
|
|
2300
|
+
lines.push(` ↻ ${result.retriedCount} truncated result(s) recovered by re-submission with a doubled output cap.`);
|
|
2301
|
+
}
|
|
2302
|
+
if (result.truncatedCount > 0) {
|
|
2303
|
+
lines.push(` ⚠ ${result.truncatedCount} result(s) still truncated after retry — their modules are unscouted, not clean.`);
|
|
2304
|
+
}
|
|
2305
|
+
if (result.synthesis.status === "completed") {
|
|
2306
|
+
lines.push(` synthesis: completed, $${(result.synthesis.cost ?? 0).toFixed(6)}`);
|
|
2307
|
+
if (result.topFindings.length > 0) {
|
|
2308
|
+
lines.push("", "Top findings (unverified leads):");
|
|
2309
|
+
for (const f of result.topFindings.slice(0, 10)) {
|
|
2310
|
+
lines.push(` [${f.severity}] ${f.title}`);
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
if (result.triage.status === "completed") {
|
|
2315
|
+
lines.push(` triage: completed, $${(result.triage.cost ?? 0).toFixed(6)}`);
|
|
2316
|
+
if (result.topTriageItems.length > 0) {
|
|
2317
|
+
lines.push("", "Triage — prioritized work order (re-verify before acting):");
|
|
2318
|
+
for (const item of result.topTriageItems.slice(0, 10)) {
|
|
2319
|
+
lines.push(` ${item.priority} [${item.severity}/${item.module}] ${item.title}` +
|
|
2320
|
+
(item.effort_estimate ? ` (${item.effort_estimate})` : ""));
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
else if (result.triage.status === "failed") {
|
|
2325
|
+
lines.push(" triage: failed");
|
|
2326
|
+
}
|
|
2327
|
+
lines.push("", "Disclaimer: Broad-Side findings are unverified scouting signals from a batch model, not validated claims.");
|
|
2328
|
+
return lines.join("\n");
|
|
2329
|
+
}
|
|
2330
|
+
export function statusText(state) {
|
|
2331
|
+
if (state.runs.length === 0) {
|
|
2332
|
+
return "No Broad-Side runs recorded. Call codecarto_broadside with action 'submit' first.";
|
|
2333
|
+
}
|
|
2334
|
+
const lines = [];
|
|
2335
|
+
for (const run of [...state.runs].reverse().slice(0, 3)) {
|
|
2336
|
+
lines.push(`Run ${run.id} — ${run.status}`);
|
|
2337
|
+
for (const lensId of BROADSIDE_LENS_IDS) {
|
|
2338
|
+
const entry = run.batches[lensId];
|
|
2339
|
+
if (!entry)
|
|
2340
|
+
continue;
|
|
2341
|
+
lines.push(` ${lensId}: ${entry.status}${entry.batchId ? ` (${entry.batchId})` : ""}${entry.cost !== undefined ? `, $${entry.cost.toFixed(6)}` : ""}`);
|
|
2342
|
+
}
|
|
2343
|
+
lines.push(` synthesis: ${run.synthesis.status}`);
|
|
2344
|
+
lines.push(` triage: ${run.triage?.status ?? "pending"}`);
|
|
2345
|
+
if (run.totalCost !== undefined)
|
|
2346
|
+
lines.push(` total cost: $${run.totalCost.toFixed(6)}`);
|
|
2347
|
+
}
|
|
2348
|
+
return lines.join("\n");
|
|
2349
|
+
}
|