codecartographer-pi 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.codecarto/broadside/SKILL.md +4 -1
  2. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  3. package/README.md +2 -2
  4. package/dist/core/broadside/client.d.ts +56 -0
  5. package/dist/core/broadside/client.js +200 -0
  6. package/dist/core/broadside/collect.d.ts +68 -0
  7. package/dist/core/broadside/collect.js +676 -0
  8. package/dist/core/broadside/constants.d.ts +51 -0
  9. package/dist/core/broadside/constants.js +74 -0
  10. package/dist/core/broadside/lenses.d.ts +31 -0
  11. package/dist/core/broadside/lenses.js +312 -0
  12. package/dist/core/broadside/models.d.ts +46 -0
  13. package/dist/core/broadside/models.js +321 -0
  14. package/dist/core/broadside/render.d.ts +20 -0
  15. package/dist/core/broadside/render.js +285 -0
  16. package/dist/core/broadside/repo.d.ts +58 -0
  17. package/dist/core/broadside/repo.js +592 -0
  18. package/dist/core/broadside/requests.d.ts +23 -0
  19. package/dist/core/broadside/requests.js +71 -0
  20. package/dist/core/broadside/results.d.ts +36 -0
  21. package/dist/core/broadside/results.js +163 -0
  22. package/dist/core/broadside/schemas.d.ts +2 -0
  23. package/dist/core/broadside/schemas.js +342 -0
  24. package/dist/core/broadside/state.d.ts +99 -0
  25. package/dist/core/broadside/state.js +384 -0
  26. package/dist/core/broadside/submit.d.ts +30 -0
  27. package/dist/core/broadside/submit.js +350 -0
  28. package/dist/core/broadside/types.d.ts +491 -0
  29. package/dist/core/broadside/types.js +107 -0
  30. package/dist/core/{broadside-verify.d.ts → broadside/verify.d.ts} +23 -2
  31. package/dist/core/{broadside-verify.js → broadside/verify.js} +43 -5
  32. package/dist/core/broadside.d.ts +14 -952
  33. package/dist/core/broadside.js +25 -3726
  34. package/dist/core/completion.js +91 -72
  35. package/dist/core/dashboard-writer.js +9 -1
  36. package/dist/core/index.d.ts +0 -1
  37. package/dist/core/index.js +0 -1
  38. package/dist/core/library.d.ts +24 -1
  39. package/dist/core/library.js +46 -15
  40. package/dist/core/orchestrator-config.js +22 -8
  41. package/dist/core/status.d.ts +42 -23
  42. package/dist/core/status.js +163 -137
  43. package/dist/core/workspace.d.ts +2 -0
  44. package/dist/core/workspace.js +49 -25
  45. package/dist/core/yaml.js +9 -3
  46. package/dist/extensions/codecarto/auto-runner.js +41 -23
  47. package/dist/extensions/codecarto/index.js +9 -4
  48. package/dist/extensions/codecarto/phase-compaction.js +6 -2
  49. package/dist/mcp-server/server.js +15 -4
  50. package/package.json +1 -1
@@ -0,0 +1,321 @@
1
+ // The model catalog, pricing, benchmarks, and the per-repository batch-endpoint memory.
2
+ //
3
+ // Split out of core/broadside.ts (#339); the barrel there re-exports every
4
+ // name, so `core/index.ts` and the tests see one module as before.
5
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ import { atomicWriteFile, pathExists } from "../utils.js";
8
+ import { BROADSIDE_BENCHMARKS_URL, BROADSIDE_CATALOG_CACHE_FILE, BROADSIDE_CATALOG_CACHE_TTL_MS, BROADSIDE_ENDPOINTS_FILE, BROADSIDE_INPUT_PRICE_PER_M, BROADSIDE_MODEL, BROADSIDE_MODELS_URL, BROADSIDE_OUTPUT_PRICE_PER_M } from "./constants.js";
9
+ import { BroadsideAuthError } from "./types.js";
10
+ import { NO_BATCH_ENDPOINT_RE, describeBatchError } from "./client.js";
11
+ // ---------- model catalog, pricing, benchmarks ----------
12
+ /** The catalog cache schema this build writes; a file from another is not read. */
13
+ export const BROADSIDE_CATALOG_CACHE_SCHEMA = 3;
14
+ async function readCatalogCache(broadsideDir) {
15
+ const cachePath = join(broadsideDir, BROADSIDE_CATALOG_CACHE_FILE);
16
+ if (!(await pathExists(cachePath)))
17
+ return null;
18
+ try {
19
+ const parsed = JSON.parse(await readFile(cachePath, "utf8"));
20
+ if (!parsed || typeof parsed !== "object" || !parsed.models || typeof parsed.models !== "object")
21
+ return null;
22
+ if (parsed.schema_version !== BROADSIDE_CATALOG_CACHE_SCHEMA && parsed.schema_version !== 2)
23
+ return null;
24
+ return parsed;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ /** When a cached entry was fetched: its own stamp, or the file's for a schema-2 cache. */
31
+ function catalogEntryFetchedAt(cache, model) {
32
+ const stamp = cache.models[model]?.fetched_at ?? cache.fetched_at;
33
+ return new Date(stamp).getTime();
34
+ }
35
+ async function writeCatalogCache(broadsideDir, cache) {
36
+ await mkdir(broadsideDir, { recursive: true });
37
+ await writeFile(join(broadsideDir, BROADSIDE_CATALOG_CACHE_FILE), `${JSON.stringify(cache, null, "\t")}\n`, "utf8");
38
+ }
39
+ const BROADSIDE_ENDPOINTS_SCHEMA = 1;
40
+ export async function readBatchEndpoints(broadsideDir) {
41
+ const path = join(broadsideDir, BROADSIDE_ENDPOINTS_FILE);
42
+ if (!(await pathExists(path)))
43
+ return {};
44
+ try {
45
+ const parsed = JSON.parse(await readFile(path, "utf8"));
46
+ if (!parsed || typeof parsed !== "object" || parsed.schema_version !== BROADSIDE_ENDPOINTS_SCHEMA)
47
+ return {};
48
+ if (!parsed.models || typeof parsed.models !== "object")
49
+ return {};
50
+ const out = {};
51
+ for (const [model, record] of Object.entries(parsed.models)) {
52
+ if (!record || typeof record !== "object")
53
+ continue;
54
+ if (record.status !== "accepted" && record.status !== "rejected")
55
+ continue;
56
+ if (typeof record.at !== "string")
57
+ continue;
58
+ out[model] = { status: record.status, at: record.at, ...(typeof record.error === "string" && { error: record.error }) };
59
+ }
60
+ return out;
61
+ }
62
+ catch {
63
+ // An unreadable memory is an empty one: it only annotates a listing.
64
+ return {};
65
+ }
66
+ }
67
+ /**
68
+ * The refusal OpenRouter returns for a catalog id that has no batch endpoint
69
+ * behind it. Matched loosely: the message is the only signal there is.
70
+ */
71
+ /**
72
+ * Remember what a submit learned about each model it posted to. An accepted
73
+ * job proves the endpoint exists; a "does not have a :batch endpoint"
74
+ * refusal proves it does not. Any other rejection (quota, malformed request,
75
+ * auth) says nothing about the endpoint and leaves the record alone.
76
+ */
77
+ export async function recordBatchEndpoints(broadsideDir, outcomes) {
78
+ const at = new Date().toISOString();
79
+ const updates = {};
80
+ for (const { model, batchId, error } of outcomes) {
81
+ if (batchId) {
82
+ updates[model] = { status: "accepted", at };
83
+ continue;
84
+ }
85
+ const message = describeBatchError(error);
86
+ if (message && NO_BATCH_ENDPOINT_RE.test(message)) {
87
+ updates[model] = { status: "rejected", at, error: message };
88
+ }
89
+ }
90
+ if (Object.keys(updates).length === 0)
91
+ return;
92
+ const models = { ...(await readBatchEndpoints(broadsideDir)), ...updates };
93
+ await mkdir(broadsideDir, { recursive: true });
94
+ const file = { schema_version: BROADSIDE_ENDPOINTS_SCHEMA, models };
95
+ await atomicWriteFile(join(broadsideDir, BROADSIDE_ENDPOINTS_FILE), `${JSON.stringify(file, null, "\t")}\n`);
96
+ }
97
+ function parseCatalogEntry(raw) {
98
+ const id = String(raw.id ?? "");
99
+ if (!id)
100
+ return null;
101
+ const p = (raw.pricing ?? {});
102
+ const input = typeof p.prompt === "string" ? Number(p.prompt) : NaN;
103
+ const output = typeof p.completion === "string" ? Number(p.completion) : NaN;
104
+ if (!Number.isFinite(input) || !Number.isFinite(output))
105
+ return null;
106
+ const cached = typeof p.cached_input === "string" ? Number(p.cached_input) : NaN;
107
+ const topProvider = (raw.top_provider ?? {});
108
+ const contextLength = typeof raw.context_length === "number" ? raw.context_length : undefined;
109
+ const maxCompletion = typeof topProvider.max_completion_tokens === "number" ? topProvider.max_completion_tokens : undefined;
110
+ return {
111
+ id,
112
+ name: String(raw.name ?? id),
113
+ inputPerM: input * 1_000_000,
114
+ outputPerM: output * 1_000_000,
115
+ cachedInputPerM: Number.isFinite(cached) ? cached * 1_000_000 : undefined,
116
+ contextLength,
117
+ maxCompletionTokens: maxCompletion,
118
+ supportedParameters: Array.isArray(raw.supported_parameters)
119
+ ? raw.supported_parameters.map((entry) => String(entry))
120
+ : [],
121
+ expirationDate: typeof raw.expiration_date === "string" ? raw.expiration_date : null,
122
+ };
123
+ }
124
+ export function builtInCatalogEntry(model) {
125
+ // The default model's rates are compile-time constants; its capabilities
126
+ // are asserted from the shipped configuration (1M context, 64K output,
127
+ // structured outputs used by every lens).
128
+ if (model !== BROADSIDE_MODEL)
129
+ return null;
130
+ return {
131
+ id: BROADSIDE_MODEL,
132
+ name: "Google: Gemini 3.7 Flash (batch)",
133
+ inputPerM: BROADSIDE_INPUT_PRICE_PER_M,
134
+ outputPerM: BROADSIDE_OUTPUT_PRICE_PER_M,
135
+ contextLength: 1_048_576,
136
+ maxCompletionTokens: 65_536,
137
+ supportedParameters: ["tools", "structured_outputs", "json_schema", "response_format"],
138
+ expirationDate: null,
139
+ };
140
+ }
141
+ export function builtInPricing(model) {
142
+ const entry = builtInCatalogEntry(model);
143
+ if (!entry)
144
+ return null;
145
+ return { inputPerM: entry.inputPerM, outputPerM: entry.outputPerM, source: "built-in" };
146
+ }
147
+ export async function resolveCatalogEntry(broadsideDir, config, model, apiKey, fetcher = fetch) {
148
+ // Manual overrides always win for pricing — the user is asserting a rate,
149
+ // and a config assertion is cheaper to respect than to second-guess.
150
+ // Capabilities stay unknown in that case: nothing is refused, nothing
151
+ // is clamped, and the submit text says the pricing came from config.
152
+ if (config.pricing) {
153
+ return {
154
+ model,
155
+ source: "config",
156
+ entry: {
157
+ id: model,
158
+ name: model,
159
+ inputPerM: config.pricing.inputPerM,
160
+ outputPerM: config.pricing.outputPerM,
161
+ supportedParameters: [],
162
+ },
163
+ };
164
+ }
165
+ // On-disk cache first, then the live catalog — for the default model too.
166
+ // Hardcoded rates used to short-circuit here, which meant a stale constant
167
+ // could never self-correct even though the catalog was already being
168
+ // fetched for every other model. The authoritative source wins; the
169
+ // constants below are what we fall back to when the network is unavailable.
170
+ const cache = await readCatalogCache(broadsideDir);
171
+ const cached = cache?.models[model];
172
+ if (cache && cached && Date.now() - catalogEntryFetchedAt(cache, model) < BROADSIDE_CATALOG_CACHE_TTL_MS) {
173
+ return { model, source: "cache", entry: cached };
174
+ }
175
+ // What went wrong when the live lookup produced nothing, for the error
176
+ // below: a 401 and a dead network used to read the same — "could not
177
+ // resolve per-token pricing" — or, for the default model, nothing at all.
178
+ let live = null;
179
+ let catalogFailure = null;
180
+ try {
181
+ const resp = await fetcher(BROADSIDE_MODELS_URL, {
182
+ method: "GET",
183
+ headers: { Authorization: `Bearer ${apiKey}` },
184
+ signal: AbortSignal.timeout(30_000),
185
+ });
186
+ if (resp.status === 401 || resp.status === 403) {
187
+ throw new BroadsideAuthError(resp.status, await responseDetail(resp));
188
+ }
189
+ if (resp.ok === false) {
190
+ catalogFailure = `the model catalog request failed (HTTP ${resp.status}${await responseDetail(resp).then((d) => (d ? `: ${d}` : ""))})`;
191
+ }
192
+ else {
193
+ const data = (await resp.json());
194
+ const hit = (data.data ?? []).find((m) => String(m.id) === model);
195
+ if (hit)
196
+ live = parseCatalogEntry(hit);
197
+ else
198
+ catalogFailure = `the model catalog has no entry for "${model}"`;
199
+ }
200
+ }
201
+ catch (error) {
202
+ if (error instanceof BroadsideAuthError)
203
+ throw error;
204
+ live = null;
205
+ catalogFailure = `the model catalog could not be fetched (${error instanceof Error ? error.message : String(error)})`;
206
+ }
207
+ if (live) {
208
+ const now = new Date().toISOString();
209
+ const updated = {
210
+ schema_version: BROADSIDE_CATALOG_CACHE_SCHEMA,
211
+ fetched_at: now,
212
+ // Other entries keep their own stamps (a schema-2 file's entries
213
+ // inherit the file's, once, on this upgrade); only this model is fresh.
214
+ models: Object.fromEntries(Object.entries(cache?.models ?? {}).map(([id, entry]) => [id, { ...entry, fetched_at: entry.fetched_at ?? cache.fetched_at }])),
215
+ };
216
+ updated.models[model] = { ...live, fetched_at: now };
217
+ await writeCatalogCache(broadsideDir, updated);
218
+ return { model, source: "live", entry: live };
219
+ }
220
+ // Offline fallback: the default model's rates and capabilities are known at
221
+ // compile time, so a network failure does not have to stop a run.
222
+ const builtIn = builtInCatalogEntry(model);
223
+ if (builtIn)
224
+ return { model, source: "built-in", entry: builtIn };
225
+ throw new Error(`Could not resolve per-token pricing for batch model "${model}": ${catalogFailure ?? "no catalog entry"}. ` +
226
+ "Set pricing.input_per_m and pricing.output_per_m in .codecarto/broadside/config.yaml " +
227
+ "(USD per million tokens), or check the model id against https://openrouter.ai/models?variant=batch.");
228
+ }
229
+ /** A short, safe excerpt of an error response body for a message. */
230
+ async function responseDetail(resp) {
231
+ try {
232
+ if (typeof resp.text === "function") {
233
+ const text = (await resp.text()).trim();
234
+ try {
235
+ const parsed = JSON.parse(text);
236
+ const message = typeof parsed?.error === "string" ? parsed.error : parsed?.error?.message;
237
+ if (typeof message === "string" && message)
238
+ return message.slice(0, 200);
239
+ }
240
+ catch {
241
+ // not JSON; fall through to the raw excerpt
242
+ }
243
+ return text.replace(/\s+/g, " ").slice(0, 200);
244
+ }
245
+ if (typeof resp.json === "function") {
246
+ const parsed = (await resp.json());
247
+ const message = typeof parsed?.error === "string" ? parsed.error : parsed?.error?.message;
248
+ return typeof message === "string" ? message.slice(0, 200) : "";
249
+ }
250
+ }
251
+ catch {
252
+ // an unreadable body adds nothing to the message
253
+ }
254
+ return "";
255
+ }
256
+ export async function resolveModelPricing(broadsideDir, config, model, apiKey, fetcher = fetch) {
257
+ const { source, entry } = await resolveCatalogEntry(broadsideDir, config, model, apiKey, fetcher);
258
+ if (!entry)
259
+ throw new Error(`No pricing resolved for ${model}.`);
260
+ return { inputPerM: entry.inputPerM, outputPerM: entry.outputPerM, source };
261
+ }
262
+ /** Base slug with the OpenRouter variant suffix (e.g. `:batch`) stripped. */
263
+ export function baseSlug(modelId) {
264
+ const idx = modelId.indexOf(":");
265
+ return idx >= 0 ? modelId.slice(0, idx) : modelId;
266
+ }
267
+ export async function fetchCodingBenchmarks(apiKey, fetcher = fetch) {
268
+ try {
269
+ const resp = await fetcher(`${BROADSIDE_BENCHMARKS_URL}?source=artificial-analysis&task_type=coding`, {
270
+ method: "GET",
271
+ headers: { Authorization: `Bearer ${apiKey}` },
272
+ signal: AbortSignal.timeout(30_000),
273
+ });
274
+ const data = (await resp.json());
275
+ const byBaseSlug = {};
276
+ for (const row of data.data ?? []) {
277
+ const slug = baseSlug(String(row.model_permaslug ?? ""));
278
+ if (!slug)
279
+ continue;
280
+ const toIndex = (v) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
281
+ byBaseSlug[slug] = {
282
+ codingIndex: toIndex(row.coding_index),
283
+ intelligenceIndex: toIndex(row.intelligence_index),
284
+ };
285
+ }
286
+ return { byBaseSlug, meta: data.meta ?? {} };
287
+ }
288
+ catch {
289
+ return null;
290
+ }
291
+ }
292
+ export async function listBatchModels(broadsideDir, config, apiKey, opts = {}) {
293
+ const fetcher = opts.fetcher ?? fetch;
294
+ const resp = await fetcher(BROADSIDE_MODELS_URL, {
295
+ method: "GET",
296
+ headers: { Authorization: `Bearer ${apiKey}` },
297
+ signal: AbortSignal.timeout(30_000),
298
+ });
299
+ const data = (await resp.json());
300
+ const entries = [];
301
+ const seen = new Set();
302
+ for (const raw of data.data ?? []) {
303
+ const entry = parseCatalogEntry(raw);
304
+ if (!entry || seen.has(entry.id))
305
+ continue;
306
+ seen.add(entry.id);
307
+ if (!entry.id.endsWith(":batch"))
308
+ continue;
309
+ entries.push(entry);
310
+ }
311
+ entries.sort((a, b) => a.inputPerM + a.outputPerM - (b.inputPerM + b.outputPerM));
312
+ // Persist the catalog so the next submit's pricing resolution hits cache.
313
+ const fetchedAt = new Date().toISOString();
314
+ const cache = { schema_version: BROADSIDE_CATALOG_CACHE_SCHEMA, fetched_at: fetchedAt, models: {} };
315
+ for (const entry of entries)
316
+ cache.models[entry.id] = { ...entry, fetched_at: fetchedAt };
317
+ await writeCatalogCache(broadsideDir, cache);
318
+ const benchmarks = opts.includeBenchmarks ? await fetchCodingBenchmarks(apiKey, fetcher) : null;
319
+ const endpoints = await readBatchEndpoints(broadsideDir);
320
+ return { entries, source: "live", benchmarks, defaultModel: config.model, endpoints };
321
+ }
@@ -0,0 +1,20 @@
1
+ import { type BroadsideCollectResult, type BroadsideIncrementalOutcome, type BroadsideStateFile, type BroadsideSubmitResult, type CatalogEntry, type CodingBenchmarks } from "./types.ts";
2
+ import { type LensDefinition } from "./lenses.ts";
3
+ import { type BatchEndpointRecord } from "./models.ts";
4
+ export declare function parseSynthesisTopFindings(content: string): BroadsideCollectResult["topFindings"];
5
+ export declare function describeIncrementalFallback(reason: BroadsideIncrementalOutcome["reason"]): string;
6
+ export declare function estimateSubmitText(result: BroadsideSubmitResult, lenses: LensDefinition[]): string;
7
+ export declare function modelsText(entries: CatalogEntry[], opts: {
8
+ benchmarks: CodingBenchmarks | null;
9
+ defaultModel: string;
10
+ endpoints?: Record<string, BatchEndpointRecord>;
11
+ }): string;
12
+ export declare function collectResultText(result: BroadsideCollectResult): string;
13
+ /**
14
+ * An `onStatus` callback that appends one line to `lines` per *change* of a
15
+ * lens's polled status. Every poll used to append a line, so a four-minute
16
+ * wait returned twenty-six identical "in_progress (0/1)" lines per lens
17
+ * before the result (0.22.0 live run).
18
+ */
19
+ export declare function statusLineWriter(lines: string[]): (lensId: string, status: string, counts: Record<string, unknown>) => void;
20
+ export declare function statusText(state: BroadsideStateFile): string;
@@ -0,0 +1,285 @@
1
+ // Markdown rendering of results and the text both surfaces print: estimates, collect reports, model listings, status.
2
+ //
3
+ // Split out of core/broadside.ts (#339); the barrel there re-exports every
4
+ // name, so `core/index.ts` and the tests see one module as before.
5
+ import { describeRedactions } from "../secrets.js";
6
+ import { BROADSIDE_LENS_IDS } from "./constants.js";
7
+ import { baseSlug } from "./models.js";
8
+ import { explainBatchError } from "./client.js";
9
+ import { parseLensJson } from "./results.js";
10
+ // ---------- rendering ----------
11
+ export function parseSynthesisTopFindings(content) {
12
+ try {
13
+ const parsed = (parseLensJson(content) ?? {});
14
+ const findings = Array.isArray(parsed.top_findings)
15
+ ? parsed.top_findings
16
+ : [];
17
+ return findings
18
+ .filter((f) => typeof f.title === "string")
19
+ .map((f) => ({
20
+ title: String(f.title),
21
+ severity: String(f.severity ?? "unknown"),
22
+ sourceLens: String(f.source_lens ?? "unknown"),
23
+ summary: String(f.summary ?? ""),
24
+ }));
25
+ }
26
+ catch {
27
+ return [];
28
+ }
29
+ }
30
+ // ---------- formatting helpers for tool output ----------
31
+ export function describeIncrementalFallback(reason) {
32
+ switch (reason) {
33
+ case "dirty-worktree":
34
+ return "the working tree has uncommitted changes, so there is no committed state to diff against";
35
+ case "no-baseline":
36
+ return "no earlier run recorded a commit to diff against";
37
+ case "diff-failed":
38
+ return "the diff against the previous run's commit could not be read";
39
+ default:
40
+ return "no baseline was available";
41
+ }
42
+ }
43
+ export function estimateSubmitText(result, lenses) {
44
+ // Count the lenses that actually got a batch, not every lens considered. A
45
+ // lens with nothing to scan is reported as `skipped (0 request(s))` two
46
+ // lines below, so counting it here made the header contradict its own body:
47
+ // a Rust CLI with no server surface reported "submitted 6 batch(es)" over a
48
+ // list showing four batches and two skips.
49
+ const entries = Object.values(result.batches ?? {});
50
+ const submittedCount = entries.filter((entry) => entry.batchId).length;
51
+ const withoutBatch = entries.length - submittedCount;
52
+ const lines = [
53
+ withoutBatch > 0
54
+ ? `Broad-Side submitted ${submittedCount} batch(es); ${withoutBatch} lens(es) produced none (see below).`
55
+ : `Broad-Side submitted ${submittedCount} batch(es).`,
56
+ ];
57
+ for (const lens of lenses) {
58
+ const entry = result.batches[lens.id];
59
+ if (!entry)
60
+ continue;
61
+ const status = entry.batchId ? `batch ${entry.batchId}` : entry.status;
62
+ const override = entry.model ? ` on ${entry.model}` : "";
63
+ // A rejected lens says why: the message is the only way to tell a
64
+ // catalog id with no batch endpoint from a full job quota, and both
65
+ // used to read as a bare "rejected". A skipped lens names the globs
66
+ // that matched nothing.
67
+ const reason = !entry.batchId && entry.error
68
+ ? ` — ${explainBatchError(entry.error)}`
69
+ : !entry.batchId && entry.reason
70
+ ? ` — ${entry.reason}`
71
+ : entry.fallback
72
+ ? ` — ${entry.fallback}`
73
+ : "";
74
+ lines.push(` ${lens.name}: ${status} (${entry.requests} request(s), ~$${entry.estimatedCost.toFixed(4)})${override}${reason}`);
75
+ }
76
+ if (result.repo) {
77
+ const head = result.repo.sourceHead ? ` at ${result.repo.sourceHead.slice(0, 8)}${result.repo.sourceDirty ? " (dirty)" : ""}` : "";
78
+ const source = result.repo.snapshot === "working-tree" ? `working tree${head}` : "directory walk (not a git repository)";
79
+ lines.push(`Scanned as ${result.repo.language}: ${result.repo.sourceFiles} source file(s) from the ${source}.`);
80
+ }
81
+ if (result.redaction) {
82
+ const line = result.redaction.enabled
83
+ ? describeRedactions(result.redaction.values, result.redaction.files, result.redaction.skippedFiles)
84
+ : "Before upload: secret redaction is OFF (redact_secrets: false in config.yaml); files were sent as they are.";
85
+ if (line)
86
+ lines.push(line);
87
+ }
88
+ const incremental = result.incremental;
89
+ if (incremental?.requested) {
90
+ lines.push(incremental.applied
91
+ ? `Incremental: scanning only what changed since ${(incremental.baseHead ?? "").slice(0, 8)}.`
92
+ : `Incremental: requested but NOT applied — ${describeIncrementalFallback(incremental.reason)}. Every module was scanned, at full cost.`);
93
+ }
94
+ 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})`);
95
+ if (result.modelInfo.contextLength) {
96
+ lines.push(`Model: ${result.modelInfo.contextLength.toLocaleString()} context, ${result.modelInfo.maxCompletionTokens?.toLocaleString() ?? "?"} max output`);
97
+ }
98
+ if (result.modelInfo.supportsStructuredOutputs === false) {
99
+ lines.push("Warning: model does not advertise structured-output support; lens JSON may be unreliable.");
100
+ }
101
+ if (result.modelInfo.expirationDate) {
102
+ lines.push(`Warning: this model is deprecated (expires ${result.modelInfo.expirationDate}).`);
103
+ }
104
+ if (result.maxCost) {
105
+ lines.push(`Run limit: $${result.maxCost.toFixed(2)} (enforced on estimate; pass force to override)`);
106
+ }
107
+ 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.");
108
+ return lines.join("\n");
109
+ }
110
+ export function modelsText(entries, opts) {
111
+ const endpoints = opts.endpoints ?? {};
112
+ const lines = [
113
+ `Batch models on OpenRouter (${entries.length}, cheapest first).`,
114
+ // The catalog over-reports: it returns a `:batch` id for models whose
115
+ // Batch API refuses the job, with nothing in the entry to tell them
116
+ // apart (#141). Say so before the table, not after it.
117
+ "Advisory: this is the catalog's list of :batch ids, not a list of working batch endpoints. Some ids are refused at submit " +
118
+ "(\"does not have a :batch endpoint\"), at no cost. Rows tagged [no batch endpoint …] or [batch OK …] carry what this " +
119
+ "repository's own submits found; an untagged row has not been tried here.",
120
+ "",
121
+ "id | $/M in | $/M out | ctx | max out | structured | coding idx",
122
+ ];
123
+ for (const entry of entries) {
124
+ const bench = opts.benchmarks?.byBaseSlug[baseSlug(entry.id)];
125
+ const structured = entry.supportedParameters.length === 0
126
+ ? "?"
127
+ : entry.supportedParameters.some((p) => ["structured_outputs", "json_schema", "response_format", "structuredoutputs"].includes(p.toLowerCase()))
128
+ ? "yes"
129
+ : "no";
130
+ const coding = bench?.codingIndex !== undefined ? bench.codingIndex.toFixed(1) : "-";
131
+ const ctx = entry.contextLength
132
+ ? entry.contextLength >= 1_000_000
133
+ ? `${(entry.contextLength / 1_000_000).toFixed(1)}M`
134
+ : `${(entry.contextLength / 1024).toFixed(0)}k`
135
+ : "?";
136
+ const out = entry.maxCompletionTokens ? `${(entry.maxCompletionTokens / 1024).toFixed(0)}k` : "?";
137
+ const tag = entry.id === opts.defaultModel ? " (default)" : "";
138
+ const exp = entry.expirationDate ? " [deprecated]" : "";
139
+ const record = endpoints[entry.id];
140
+ const seen = record
141
+ ? record.status === "rejected"
142
+ ? ` [no batch endpoint, refused ${record.at.slice(0, 10)}]`
143
+ : ` [batch OK ${record.at.slice(0, 10)}]`
144
+ : "";
145
+ lines.push(`${entry.id}${tag}${exp}${seen} | ${entry.inputPerM.toFixed(3)} | ${entry.outputPerM.toFixed(3)} | ${ctx} | ${out} | ${structured} | ${coding}`);
146
+ }
147
+ if (opts.benchmarks?.meta.as_of) {
148
+ lines.push("", `Benchmarks: Artificial Analysis coding index (as of ${String(opts.benchmarks.meta.as_of)}).`);
149
+ }
150
+ lines.push("", "Choose with the model parameter (--model= on Pi) for one run, lens_models (--lens-model=LENS:ID) per lens, or the model key in " +
151
+ ".codecarto/broadside/config.yaml for the repository. Higher coding index ≠ better scout: precision, context, structured-output " +
152
+ "support, and whether the model spends its output budget reasoning (see reasoning: in config.yaml) matter most here. " +
153
+ "A refused submit costs nothing, so probe an untried model on one lens first.");
154
+ return lines.join("\n");
155
+ }
156
+ export function collectResultText(result) {
157
+ const lines = [
158
+ `Broad-Side run ${result.runId}: ${result.status}`,
159
+ ` Results: ${result.resultCount} | Total cost: $${result.totalCost.toFixed(6)}`,
160
+ ];
161
+ for (const lensId of BROADSIDE_LENS_IDS) {
162
+ const outcome = result.lensOutcomes[lensId];
163
+ if (!outcome)
164
+ continue;
165
+ const truncation = outcome.truncated ? `, ${outcome.truncated} truncated` : "";
166
+ lines.push(` ${lensId}: ${outcome.status}` +
167
+ (outcome.cost !== undefined ? `, $${outcome.cost.toFixed(6)}` : "") +
168
+ (outcome.resultCount !== undefined ? `, ${outcome.resultCount} result(s)` : "") +
169
+ truncation +
170
+ // The reason a lens did not complete, when the poll recorded one:
171
+ // an auth failure or a dead network used to read as a slow batch.
172
+ (outcome.error ? ` — ${outcome.error}` : ""));
173
+ }
174
+ if (result.retriedCount > 0) {
175
+ lines.push(` ↻ ${result.retriedCount} truncated result(s) recovered by re-submission with a doubled output cap.`);
176
+ }
177
+ if (result.retryElsewhere) {
178
+ lines.push(" ↻ The truncation retry is in flight in another collect on this run; collect again for its result.");
179
+ }
180
+ if (result.retryError) {
181
+ lines.push(` ↻ The truncation retry could not be submitted — ${result.retryError}. The truncated results stand as collected.`);
182
+ }
183
+ if (result.truncatedCount > 0) {
184
+ lines.push(` ⚠ ${result.truncatedCount} result(s) still truncated after retry — their modules are unscouted, not clean.`);
185
+ }
186
+ // A pass still in flight or retired must appear: a run reported
187
+ // "completed" with no synthesis line read as "no synthesis was run",
188
+ // when the batch was running and a later collect would have claimed it
189
+ // (0.22.1 live run — the collect's wait ran out during the pass).
190
+ const passInFlight = (kind, entry) => {
191
+ if (entry.status === "submitted") {
192
+ lines.push(` ${kind}: ${entry.batchId ? "still running" : "in flight in another collect"} — collect again for its result.`);
193
+ }
194
+ else if (entry.status === "failed") {
195
+ lines.push(` ${kind}: failed${entry.error ? ` — ${explainBatchError(entry.error)}` : ""}`);
196
+ }
197
+ };
198
+ // Whether a pass was built from a verify pass's verdicts is part of what
199
+ // it is: a work order that ranked on batch severities alone is the one
200
+ // that put two dismissed casts above the confirmed finding (#338).
201
+ const builtFrom = (entry) => entry.verdicts ? ` (built from ${entry.verdicts} verdict${entry.verdicts === 1 ? "" : "s"})` : " (no verdicts)";
202
+ if (result.regenerated && result.regenerated.length > 0) {
203
+ lines.push(` regenerated: ${result.regenerated.join(", ")}`);
204
+ }
205
+ if (result.synthesis.status === "completed") {
206
+ lines.push(` synthesis: completed, $${(result.synthesis.cost ?? 0).toFixed(6)}${builtFrom(result.synthesis)}`);
207
+ if (result.topFindings.length > 0) {
208
+ lines.push("", result.synthesis.verdicts ? "Top findings (verdicts applied; unread ones are unverified leads):" : "Top findings (unverified leads):");
209
+ for (const f of result.topFindings.slice(0, 10)) {
210
+ lines.push(` [${f.severity}] ${f.title}`);
211
+ }
212
+ }
213
+ }
214
+ else {
215
+ passInFlight("synthesis", result.synthesis);
216
+ }
217
+ if (result.triage.status === "completed") {
218
+ lines.push(` triage: completed, $${(result.triage.cost ?? 0).toFixed(6)}${builtFrom(result.triage)}`);
219
+ if (result.topTriageItems.length > 0) {
220
+ lines.push("", result.triage.verdicts
221
+ ? "Triage — prioritized work order (confirmed findings first; re-verify the unread ones before acting):"
222
+ : "Triage — prioritized work order (re-verify before acting):");
223
+ for (const item of result.topTriageItems.slice(0, 10)) {
224
+ lines.push(` ${item.priority} [${item.severity}/${item.module}] ${item.title}` +
225
+ (item.effort_estimate ? ` (${item.effort_estimate})` : ""));
226
+ }
227
+ }
228
+ }
229
+ else {
230
+ passInFlight("triage", result.triage);
231
+ }
232
+ if (result.status === "completed" && !result.synthesis.verdicts && !result.triage.verdicts
233
+ && (result.synthesis.status === "completed" || result.triage.status === "completed")) {
234
+ lines.push("", "Run verify, then collect --regenerate, to rebuild the report and the work order from verdicts.");
235
+ }
236
+ lines.push("", "Disclaimer: Broad-Side findings are unverified scouting signals from a batch model, not validated claims.");
237
+ return lines.join("\n");
238
+ }
239
+ /**
240
+ * An `onStatus` callback that appends one line to `lines` per *change* of a
241
+ * lens's polled status. Every poll used to append a line, so a four-minute
242
+ * wait returned twenty-six identical "in_progress (0/1)" lines per lens
243
+ * before the result (0.22.0 live run).
244
+ */
245
+ export function statusLineWriter(lines) {
246
+ const last = new Map();
247
+ return (lensId, status, counts) => {
248
+ const line = ` ${lensId}: ${status} (${counts.completed ?? 0}/${counts.total ?? "?"})`;
249
+ if (last.get(lensId) === line)
250
+ return;
251
+ last.set(lensId, line);
252
+ lines.push(line);
253
+ };
254
+ }
255
+ export function statusText(state) {
256
+ if (state.runs.length === 0) {
257
+ return "No Broad-Side runs recorded. Call codecarto_broadside with action 'submit' first.";
258
+ }
259
+ const lines = [];
260
+ for (const run of [...state.runs].reverse().slice(0, 3)) {
261
+ lines.push(`Run ${run.id} — ${run.status}`);
262
+ // Recorded since #248; a run from an older version has neither field.
263
+ if (run.language || run.snapshot) {
264
+ const head = run.sourceHead ? ` at ${run.sourceHead.slice(0, 8)}${run.sourceDirty ? " (dirty)" : ""}` : "";
265
+ const source = run.snapshot === "walk" ? "directory walk" : run.snapshot ? `working tree${head}` : "unknown source";
266
+ lines.push(` scanned as ${run.language ?? "unknown"} from the ${source}`);
267
+ }
268
+ for (const lensId of BROADSIDE_LENS_IDS) {
269
+ const entry = run.batches[lensId];
270
+ if (!entry)
271
+ continue;
272
+ lines.push(` ${lensId}: ${entry.status}${entry.batchId ? ` (${entry.batchId})` : ""}${entry.cost !== undefined ? `, $${entry.cost.toFixed(6)}` : ""}` +
273
+ (entry.status === "skipped" && entry.reason ? ` — ${entry.reason}` : entry.fallback ? ` — ${entry.fallback}` : ""));
274
+ }
275
+ const builtFrom = (entry) => entry?.status === "completed" ? (entry.verdicts ? ` (built from ${entry.verdicts} verdict${entry.verdicts === 1 ? "" : "s"})` : " (no verdicts)") : "";
276
+ lines.push(` synthesis: ${run.synthesis.status}${builtFrom(run.synthesis)}`);
277
+ lines.push(` triage: ${run.triage?.status ?? "pending"}${builtFrom(run.triage)}`);
278
+ if (run.verify) {
279
+ lines.push(` verify: ${run.verify.status} — ${run.verify.confirmed} confirmed of ${run.verify.verified} read on ${run.verify.model}, $${run.verify.cost.toFixed(4)}`);
280
+ }
281
+ if (run.totalCost !== undefined)
282
+ lines.push(` total cost: $${run.totalCost.toFixed(6)}`);
283
+ }
284
+ return lines.join("\n");
285
+ }