codecartographer-pi 0.19.6 → 0.21.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 +2 -2
- package/.codecarto/broadside/SKILL.md +21 -3
- package/.codecarto/broadside/config.yaml +35 -9
- package/.codecarto/findings/contracts/SKILL.md +4 -1
- package/.codecarto/findings/defect-scan/SKILL.md +10 -0
- package/.codecarto/findings/defect-scan-mechanical/SKILL.md +6 -0
- package/.codecarto/findings/defect-scan-semantic/SKILL.md +8 -1
- package/.codecarto/findings/porting/SKILL.md +4 -0
- package/.codecarto/findings/protocols/SKILL.md +4 -0
- package/.codecarto/templates/mechanical-defects.md +15 -0
- package/.codecarto/templates/reimplementation-spec.md +5 -3
- package/.codecarto/templates/reverse-engineering-bundle.md +10 -1
- package/.codecarto/templates/semantic-defects.md +15 -0
- package/.codecarto/workflow/VALIDATE.md +3 -2
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/README.md +13 -9
- package/dist/core/amendment.js +9 -4
- package/dist/core/broadside.d.ts +121 -2
- package/dist/core/broadside.js +478 -92
- package/dist/core/completion.js +88 -23
- package/dist/core/dashboard-writer.d.ts +8 -0
- package/dist/core/dashboard-writer.js +159 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/core/library.js +9 -4
- package/dist/core/orchestrator-config.d.ts +32 -7
- package/dist/core/orchestrator-config.js +124 -44
- package/dist/core/pipeline.d.ts +73 -0
- package/dist/core/pipeline.js +134 -10
- package/dist/core/prompts.d.ts +20 -0
- package/dist/core/prompts.js +53 -13
- package/dist/core/secrets.d.ts +16 -0
- package/dist/core/secrets.js +98 -0
- package/dist/core/status.d.ts +16 -0
- package/dist/core/status.js +47 -20
- package/dist/core/synthesis.js +5 -2
- package/dist/core/utils.d.ts +7 -0
- package/dist/core/utils.js +7 -0
- package/dist/core/workspace.d.ts +55 -8
- package/dist/core/workspace.js +116 -8
- package/dist/core/yaml.js +181 -15
- package/dist/extensions/codecarto/agent-rewriter.js +21 -14
- package/dist/extensions/codecarto/agent-runner.d.ts +6 -2
- package/dist/extensions/codecarto/agent-runner.js +27 -9
- package/dist/extensions/codecarto/agent-state.d.ts +0 -2
- package/dist/extensions/codecarto/auto-runner.d.ts +1 -1
- package/dist/extensions/codecarto/auto-runner.js +27 -8
- package/dist/extensions/codecarto/broadside-flags.d.ts +3 -1
- package/dist/extensions/codecarto/broadside-flags.js +12 -0
- package/dist/extensions/codecarto/dashboard-narrator.js +9 -2
- package/dist/extensions/codecarto/dashboard-writer.d.ts +1 -8
- package/dist/extensions/codecarto/dashboard-writer.js +5 -154
- package/dist/extensions/codecarto/index.js +158 -47
- package/dist/extensions/codecarto/phase-compaction.js +5 -1
- package/dist/mcp-server/server.d.ts +3 -1
- package/dist/mcp-server/server.js +205 -77
- package/package.json +3 -2
package/dist/core/broadside.js
CHANGED
|
@@ -31,11 +31,13 @@
|
|
|
31
31
|
// executable surfaces (Pi and MCP), not the pure template. What the template does
|
|
32
32
|
// carry is the reading guide for its output — `.codecarto/broadside/SKILL.md`,
|
|
33
33
|
// served by codecarto_skill under the name `broadside` (see readBroadsideSkill).
|
|
34
|
+
import { createHash } from "node:crypto";
|
|
34
35
|
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
35
36
|
import { execFile } from "node:child_process";
|
|
36
37
|
import { promisify } from "node:util";
|
|
37
38
|
import { join, relative } from "node:path";
|
|
38
|
-
import { atomicWriteFile, pathExists, sleep } from "./utils.js";
|
|
39
|
+
import { atomicWriteFile, GIT_TIMEOUT_MS, pathExists, sleep } from "./utils.js";
|
|
40
|
+
import { describeRedactions, isSecretFile, redactSecrets } from "./secrets.js";
|
|
39
41
|
import { acquireLock } from "./status.js";
|
|
40
42
|
import { loadYamlFile } from "./yaml.js";
|
|
41
43
|
import { packagedWorkspaceDir } from "./workspace.js";
|
|
@@ -74,6 +76,16 @@ export const BROADSIDE_LENS_IDS = [
|
|
|
74
76
|
];
|
|
75
77
|
export const BROADSIDE_POLL_INTERVAL_MS = 15_000;
|
|
76
78
|
export const BROADSIDE_DEFAULT_POLL_BUDGET_MS = 25 * 60 * 1000;
|
|
79
|
+
/**
|
|
80
|
+
* The run expense limit in USD a repository gets before it configures one.
|
|
81
|
+
* Pi asks a human before submitting over the estimate; the MCP surface cannot,
|
|
82
|
+
* and shipped with no limit at all, so a host calling submit with the stock
|
|
83
|
+
* config spent whatever the estimate came to (#231). One dollar covers a
|
|
84
|
+
* six-lens run of a repository this size with room to spare; a larger one
|
|
85
|
+
* raises `max_cost` in config.yaml, passes `max_cost` on the call, or sets it
|
|
86
|
+
* to 0 for no limit.
|
|
87
|
+
*/
|
|
88
|
+
export const BROADSIDE_DEFAULT_MAX_COST = 1;
|
|
77
89
|
/**
|
|
78
90
|
* The share of a lens's output budget reasoning may spend.
|
|
79
91
|
*
|
|
@@ -102,6 +114,54 @@ export const BROADSIDE_MIN_REASONING_TOKENS = 512;
|
|
|
102
114
|
export function defaultReasoningFor(maxTokens) {
|
|
103
115
|
return { max_tokens: Math.max(BROADSIDE_MIN_REASONING_TOKENS, Math.floor(maxTokens * BROADSIDE_REASONING_BUDGET_FRACTION)) };
|
|
104
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* OpenRouter rejected the API key (HTTP 401/403). Thrown from the catalog
|
|
119
|
+
* lookup rather than swallowed into "could not price" or a silent built-in
|
|
120
|
+
* fallback: a run that cannot authenticate cannot submit either, and the
|
|
121
|
+
* message that reaches the user has to say so (#251).
|
|
122
|
+
*/
|
|
123
|
+
export class BroadsideAuthError extends Error {
|
|
124
|
+
httpStatus;
|
|
125
|
+
detail;
|
|
126
|
+
constructor(httpStatus, detail) {
|
|
127
|
+
super(`OpenRouter rejected the API key (HTTP ${httpStatus}${detail ? `: ${detail}` : ""}). ` +
|
|
128
|
+
"Check OPENROUTER_API_KEY, the api_key parameter, or api_key in .codecarto/broadside/config.yaml. Nothing was submitted.");
|
|
129
|
+
this.name = "BroadsideAuthError";
|
|
130
|
+
this.httpStatus = httpStatus;
|
|
131
|
+
this.detail = detail;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* `broadside/config.yaml` exists but cannot be used. A file that failed to
|
|
136
|
+
* parse used to be treated exactly like an absent one — defaults, including
|
|
137
|
+
* no spend cap and no lens routing, with no message — so a typo removed the
|
|
138
|
+
* user's own guard (#232). Only an absent file yields defaults now.
|
|
139
|
+
*/
|
|
140
|
+
export class BroadsideConfigError extends Error {
|
|
141
|
+
path;
|
|
142
|
+
constructor(path, detail) {
|
|
143
|
+
super(`Broad-Side config ${path} ${detail}. Fix or remove the file; nothing runs on defaults while it is unreadable.`);
|
|
144
|
+
this.name = "BroadsideConfigError";
|
|
145
|
+
this.path = path;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* `broadside/state.json` exists but cannot be read. It used to be read as
|
|
150
|
+
* empty and the next checkpoint wrote that empty state over it, losing the
|
|
151
|
+
* batch ids of every in-flight, already-paid run (#233). The corrupt file is
|
|
152
|
+
* preserved beside itself and nothing writes over it until someone looks.
|
|
153
|
+
*/
|
|
154
|
+
export class BroadsideStateError extends Error {
|
|
155
|
+
path;
|
|
156
|
+
backupPath;
|
|
157
|
+
constructor(path, backupPath, detail) {
|
|
158
|
+
super(`Broad-Side state ${path} ${detail}. A copy is preserved at ${backupPath}; the file is not overwritten. ` +
|
|
159
|
+
"Repair state.json from the copy (each run's batch ids are what collect needs), or move it aside to start fresh.");
|
|
160
|
+
this.name = "BroadsideStateError";
|
|
161
|
+
this.path = path;
|
|
162
|
+
this.backupPath = backupPath;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
105
165
|
/** Thrown when a confirm hook declines a run. Nothing was submitted. */
|
|
106
166
|
export class BroadsideCancelledError extends Error {
|
|
107
167
|
constructor(message = "Broad-Side submission cancelled. Nothing was submitted.") {
|
|
@@ -799,14 +859,23 @@ const SKIP_FILE_EXTENSIONS = new Set([
|
|
|
799
859
|
".model",
|
|
800
860
|
".bpe",
|
|
801
861
|
]);
|
|
862
|
+
/**
|
|
863
|
+
* Manifest files and the languages each one can mean. `package.json` covers
|
|
864
|
+
* both TypeScript and JavaScript; which of the two a repository is comes from
|
|
865
|
+
* counting its source files, not from the manifest.
|
|
866
|
+
*/
|
|
802
867
|
const MANIFEST_CANDIDATES = [
|
|
803
|
-
["go.mod", "go"],
|
|
804
|
-
["package.json", "typescript"],
|
|
805
|
-
["Cargo.toml", "rust"],
|
|
806
|
-
["pyproject.toml", "python"],
|
|
807
|
-
["setup.py", "python"],
|
|
808
|
-
["requirements.txt", "python"],
|
|
868
|
+
["go.mod", ["go"]],
|
|
869
|
+
["package.json", ["typescript", "javascript"]],
|
|
870
|
+
["Cargo.toml", ["rust"]],
|
|
871
|
+
["pyproject.toml", ["python"]],
|
|
872
|
+
["setup.py", ["python"]],
|
|
873
|
+
["requirements.txt", ["python"]],
|
|
809
874
|
];
|
|
875
|
+
/** The languages Broad-Side can scan; anything else is refused at submit. */
|
|
876
|
+
export const BROADSIDE_LANGUAGES = ["go", "python", "rust", "typescript", "javascript"];
|
|
877
|
+
/** Chars of the entry-point file and the manifest that ride in the architecture prompt (#249). */
|
|
878
|
+
const REPO_INFO_FILE_CAP = 20_000;
|
|
810
879
|
const SOURCE_SPECS = {
|
|
811
880
|
go: { glob: "**/*.go", exts: [".go"] },
|
|
812
881
|
python: { glob: "**/*.py", exts: [".py"] },
|
|
@@ -814,21 +883,32 @@ const SOURCE_SPECS = {
|
|
|
814
883
|
typescript: { glob: "**/*.ts", exts: [".ts", ".tsx"] },
|
|
815
884
|
javascript: { glob: "**/*.js", exts: [".js", ".jsx"] },
|
|
816
885
|
};
|
|
886
|
+
/**
|
|
887
|
+
* The files a run scans, and where they came from. Contents are always read
|
|
888
|
+
* from the working tree, so the list is the working tree's too: tracked files
|
|
889
|
+
* plus untracked ones git does not ignore, minus files deleted on disk. The
|
|
890
|
+
* list used to come from `git ls-tree HEAD`, so a run mixed the committed
|
|
891
|
+
* file list with uncommitted contents and never saw an untracked file (#248).
|
|
892
|
+
* A target that is not a git repository gets a bounded walk.
|
|
893
|
+
*/
|
|
817
894
|
async function listRepoFiles(targetDir) {
|
|
818
|
-
// git ls-tree is the fast path; fall back to a bounded walk for non-git trees.
|
|
819
895
|
try {
|
|
820
|
-
const
|
|
896
|
+
const listed = await execFileAsync("git", ["-C", targetDir, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { maxBuffer: 64 * 1024 * 1024, timeout: GIT_TIMEOUT_MS });
|
|
897
|
+
const deleted = await execFileAsync("git", ["-C", targetDir, "ls-files", "-z", "--deleted"], {
|
|
821
898
|
maxBuffer: 64 * 1024 * 1024,
|
|
899
|
+
timeout: GIT_TIMEOUT_MS,
|
|
822
900
|
});
|
|
823
|
-
|
|
901
|
+
const gone = new Set(deleted.stdout.split("\0").filter(Boolean));
|
|
902
|
+
const files = listed.stdout.split("\0").filter((path) => path && !gone.has(path));
|
|
903
|
+
return { files, snapshot: "working-tree" };
|
|
824
904
|
}
|
|
825
905
|
catch {
|
|
826
|
-
return walkFiles(targetDir, targetDir, 0, 30_000);
|
|
906
|
+
return { files: await walkFiles(targetDir, targetDir, 0, 30_000), snapshot: "walk" };
|
|
827
907
|
}
|
|
828
908
|
}
|
|
829
909
|
async function gitHead(targetDir) {
|
|
830
910
|
try {
|
|
831
|
-
const { stdout } = await execFileAsync("git", ["-C", targetDir, "rev-parse", "HEAD"], { maxBuffer: 1024 * 1024 });
|
|
911
|
+
const { stdout } = await execFileAsync("git", ["-C", targetDir, "rev-parse", "HEAD"], { maxBuffer: 1024 * 1024, timeout: GIT_TIMEOUT_MS });
|
|
832
912
|
return stdout.trim() || null;
|
|
833
913
|
}
|
|
834
914
|
catch {
|
|
@@ -837,7 +917,7 @@ async function gitHead(targetDir) {
|
|
|
837
917
|
}
|
|
838
918
|
async function gitDirty(targetDir) {
|
|
839
919
|
try {
|
|
840
|
-
const { stdout } = await execFileAsync("git", ["-C", targetDir, "status", "--porcelain"], { maxBuffer: 1024 * 1024 });
|
|
920
|
+
const { stdout } = await execFileAsync("git", ["-C", targetDir, "status", "--porcelain"], { maxBuffer: 1024 * 1024, timeout: GIT_TIMEOUT_MS });
|
|
841
921
|
return stdout.trim().length > 0;
|
|
842
922
|
}
|
|
843
923
|
catch {
|
|
@@ -853,7 +933,7 @@ async function changedFilesSince(targetDir, baseHead) {
|
|
|
853
933
|
if (!baseHead)
|
|
854
934
|
return null;
|
|
855
935
|
try {
|
|
856
|
-
const { stdout } = await execFileAsync("git", ["-C", targetDir, "diff", "--name-only", baseHead, "HEAD"], { maxBuffer: 64 * 1024 * 1024 });
|
|
936
|
+
const { stdout } = await execFileAsync("git", ["-C", targetDir, "diff", "--name-only", baseHead, "HEAD"], { maxBuffer: 64 * 1024 * 1024, timeout: GIT_TIMEOUT_MS });
|
|
857
937
|
return new Set(stdout.split("\n").filter(Boolean));
|
|
858
938
|
}
|
|
859
939
|
catch {
|
|
@@ -872,7 +952,7 @@ async function walkFiles(rootDir, dir, depth, remaining) {
|
|
|
872
952
|
return out;
|
|
873
953
|
}
|
|
874
954
|
for (const entry of entries) {
|
|
875
|
-
if (entry.name.startsWith(".")
|
|
955
|
+
if (entry.name.startsWith("."))
|
|
876
956
|
continue;
|
|
877
957
|
if (entry.isDirectory()) {
|
|
878
958
|
if (SKIP_DIR_NAMES.has(entry.name))
|
|
@@ -892,25 +972,65 @@ async function walkFiles(rootDir, dir, depth, remaining) {
|
|
|
892
972
|
}
|
|
893
973
|
return out;
|
|
894
974
|
}
|
|
895
|
-
function
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
975
|
+
function sourceFileCount(language, fileCounts) {
|
|
976
|
+
return (SOURCE_SPECS[language]?.exts ?? []).reduce((sum, ext) => sum + (fileCounts[ext] ?? 0), 0);
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* The language the lenses scan as. The manifests present name the candidates
|
|
980
|
+
* (all of them, not the first one found: a Python service with a
|
|
981
|
+
* `package.json` for its docs tooling is not a TypeScript repository), and
|
|
982
|
+
* among candidates the one with the most source files wins; without a
|
|
983
|
+
* manifest, the language with the most source files; without any source
|
|
984
|
+
* file, `unknown` — which submit refuses rather than scanning nothing and
|
|
985
|
+
* paying for it (#250). Ties keep manifest order.
|
|
986
|
+
*/
|
|
987
|
+
function detectLanguage(fileCounts, manifestPaths) {
|
|
988
|
+
const candidates = [];
|
|
989
|
+
for (const [candidate, languages] of MANIFEST_CANDIDATES) {
|
|
990
|
+
if (!manifestPaths.includes(candidate))
|
|
991
|
+
continue;
|
|
992
|
+
for (const language of languages)
|
|
993
|
+
if (!candidates.includes(language))
|
|
994
|
+
candidates.push(language);
|
|
995
|
+
}
|
|
996
|
+
const pool = candidates.length > 0 ? candidates : [...BROADSIDE_LANGUAGES];
|
|
997
|
+
let best = null;
|
|
998
|
+
let bestCount = -1;
|
|
999
|
+
for (const language of pool) {
|
|
1000
|
+
const count = sourceFileCount(language, fileCounts);
|
|
1001
|
+
if (count > bestCount) {
|
|
1002
|
+
best = language;
|
|
1003
|
+
bestCount = count;
|
|
900
1004
|
}
|
|
901
1005
|
}
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
javascript: fileCounts[".js"] ?? 0,
|
|
908
|
-
};
|
|
909
|
-
const best = Object.entries(counts).sort((a, b) => b[1] - a[1])[0];
|
|
910
|
-
return best && best[1] > 0 ? best[0] : "unknown";
|
|
1006
|
+
if (bestCount > 0)
|
|
1007
|
+
return best;
|
|
1008
|
+
// A manifest with no source files behind it still names the language;
|
|
1009
|
+
// submit reports the empty count. No manifest and no source: unknown.
|
|
1010
|
+
return candidates[0] ?? "unknown";
|
|
911
1011
|
}
|
|
912
|
-
|
|
913
|
-
|
|
1012
|
+
/** Cut a file that rides whole in a prompt down to the cap, saying so (#249). */
|
|
1013
|
+
function capForPrompt(content, cap) {
|
|
1014
|
+
if (content.length <= cap)
|
|
1015
|
+
return content;
|
|
1016
|
+
return `${content.slice(0, cap)}\n… [truncated: ${cap.toLocaleString()} of ${content.length.toLocaleString()} chars shown]\n`;
|
|
1017
|
+
}
|
|
1018
|
+
export async function collectRepoInfo(targetDir, opts = {}) {
|
|
1019
|
+
const redact = opts.redact ?? true;
|
|
1020
|
+
const { files: allFiles, snapshot } = await listRepoFiles(targetDir);
|
|
1021
|
+
// Named credential stores are out of every lens (isSlurpable); listed here
|
|
1022
|
+
// so the submit report can say so.
|
|
1023
|
+
const secretFilesSkipped = allFiles.filter((path) => isSecretFile(path)).sort();
|
|
1024
|
+
let redactedValues = 0;
|
|
1025
|
+
// The entry point, manifest, and README ride in the architecture prompt
|
|
1026
|
+
// as text, so they get the same pass the slices do (#252).
|
|
1027
|
+
const clean = (text) => {
|
|
1028
|
+
if (!redact)
|
|
1029
|
+
return text;
|
|
1030
|
+
const redaction = redactSecrets(text);
|
|
1031
|
+
redactedValues += redaction.count;
|
|
1032
|
+
return redaction.text;
|
|
1033
|
+
};
|
|
914
1034
|
const fileCounts = {};
|
|
915
1035
|
for (const f of allFiles) {
|
|
916
1036
|
const slash = f.lastIndexOf("/");
|
|
@@ -923,25 +1043,37 @@ export async function collectRepoInfo(targetDir) {
|
|
|
923
1043
|
for (const [ext, n] of Object.entries(fileCounts).sort((a, b) => b[1] - a[1])) {
|
|
924
1044
|
sortedCounts[ext] = n;
|
|
925
1045
|
}
|
|
926
|
-
|
|
1046
|
+
// Every manifest present counts toward language detection; the first one
|
|
1047
|
+
// found is the one the architecture prompt shows.
|
|
1048
|
+
const manifestPaths = [];
|
|
927
1049
|
for (const [candidate] of MANIFEST_CANDIDATES) {
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
1050
|
+
if (await pathExists(join(targetDir, candidate)))
|
|
1051
|
+
manifestPaths.push(candidate);
|
|
1052
|
+
}
|
|
1053
|
+
const language = detectLanguage(sortedCounts, manifestPaths);
|
|
1054
|
+
// Show the manifest that belongs to the detected language when there is
|
|
1055
|
+
// one, so a polyglot repo's prompt does not open with the other stack's file.
|
|
1056
|
+
const manifestPath = manifestPaths.find((path) => MANIFEST_CANDIDATES.find(([candidate]) => candidate === path)?.[1].includes(language))
|
|
1057
|
+
?? manifestPaths[0]
|
|
1058
|
+
?? null;
|
|
1059
|
+
let manifest = null;
|
|
1060
|
+
if (manifestPath) {
|
|
1061
|
+
try {
|
|
1062
|
+
manifest = { path: manifestPath, content: capForPrompt(clean(await readFile(join(targetDir, manifestPath), "utf8")), REPO_INFO_FILE_CAP) };
|
|
1063
|
+
}
|
|
1064
|
+
catch {
|
|
1065
|
+
manifest = null;
|
|
937
1066
|
}
|
|
938
1067
|
}
|
|
1068
|
+
// Read whole and unbounded before, and then estimated at a flat 6,000
|
|
1069
|
+
// chars: a large entry point shipped in full while the cap was checked
|
|
1070
|
+
// against a number that had nothing to do with it (#249).
|
|
939
1071
|
let mainFile = "";
|
|
940
|
-
for (const candidate of ["main.go", "main.py", "src/main.rs", "src/index.ts", "index.ts"]) {
|
|
1072
|
+
for (const candidate of ["main.go", "main.py", "src/main.rs", "src/index.ts", "index.ts", "src/index.js", "index.js"]) {
|
|
941
1073
|
const p = join(targetDir, candidate);
|
|
942
1074
|
if (await pathExists(p)) {
|
|
943
1075
|
try {
|
|
944
|
-
mainFile = await readFile(p, "utf8");
|
|
1076
|
+
mainFile = capForPrompt(clean(await readFile(p, "utf8")), REPO_INFO_FILE_CAP);
|
|
945
1077
|
}
|
|
946
1078
|
catch {
|
|
947
1079
|
mainFile = "";
|
|
@@ -953,16 +1085,18 @@ export async function collectRepoInfo(targetDir) {
|
|
|
953
1085
|
const readmePath = join(targetDir, "README.md");
|
|
954
1086
|
if (await pathExists(readmePath)) {
|
|
955
1087
|
try {
|
|
956
|
-
readmeFirst = (await readFile(readmePath, "utf8")).slice(0, 4000);
|
|
1088
|
+
readmeFirst = clean((await readFile(readmePath, "utf8")).slice(0, 4000));
|
|
957
1089
|
}
|
|
958
1090
|
catch {
|
|
959
1091
|
readmeFirst = "";
|
|
960
1092
|
}
|
|
961
1093
|
}
|
|
962
1094
|
const fileTree = buildFileTree(allFiles);
|
|
963
|
-
|
|
964
|
-
|
|
1095
|
+
// An unknown language used to fall through to Go's globs, so the code
|
|
1096
|
+
// lenses matched nothing and the run paid for empty batches (#250).
|
|
1097
|
+
const sourceSpec = SOURCE_SPECS[language] ?? { glob: "", exts: [] };
|
|
965
1098
|
const name = targetDir.split(/[\\/]/).filter(Boolean).pop() ?? "repo";
|
|
1099
|
+
const sourceFiles = allFiles.filter((path) => isSlurpable(path) && sourceSpec.exts.some((ext) => path.toLowerCase().endsWith(ext))).length;
|
|
966
1100
|
return {
|
|
967
1101
|
name,
|
|
968
1102
|
path: targetDir,
|
|
@@ -974,6 +1108,10 @@ export async function collectRepoInfo(targetDir) {
|
|
|
974
1108
|
fileCounts: sortedCounts,
|
|
975
1109
|
sourceGlob: sourceSpec.glob,
|
|
976
1110
|
sourceExts: sourceSpec.exts,
|
|
1111
|
+
sourceFileCount: sourceFiles,
|
|
1112
|
+
snapshot,
|
|
1113
|
+
secretFilesSkipped,
|
|
1114
|
+
redactedValues,
|
|
977
1115
|
};
|
|
978
1116
|
}
|
|
979
1117
|
function buildFileTree(allFiles, maxDepth = 3, maxLines = 200) {
|
|
@@ -1034,6 +1172,9 @@ function matchesAnyGlob(path, globs) {
|
|
|
1034
1172
|
return false;
|
|
1035
1173
|
}
|
|
1036
1174
|
function isSlurpable(relPath) {
|
|
1175
|
+
// A credential store is never a lens input, whatever its globs say (#252).
|
|
1176
|
+
if (isSecretFile(relPath))
|
|
1177
|
+
return false;
|
|
1037
1178
|
const segments = relPath.split("/");
|
|
1038
1179
|
for (const seg of segments) {
|
|
1039
1180
|
if (SKIP_DIR_NAMES.has(seg))
|
|
@@ -1070,7 +1211,7 @@ function resolveSliceMode(lens, files, totalChars) {
|
|
|
1070
1211
|
return totalChars > lens.maxChars ? "directory" : "none";
|
|
1071
1212
|
}
|
|
1072
1213
|
function collectLensFiles(allFiles, lens, info) {
|
|
1073
|
-
const globs = lens.globsFor(info);
|
|
1214
|
+
const globs = lens.globsFor(info).filter(Boolean);
|
|
1074
1215
|
if (globs.length === 0)
|
|
1075
1216
|
return [];
|
|
1076
1217
|
const out = [];
|
|
@@ -1085,13 +1226,15 @@ function collectLensFiles(allFiles, lens, info) {
|
|
|
1085
1226
|
}
|
|
1086
1227
|
return out;
|
|
1087
1228
|
}
|
|
1088
|
-
async function slurpFileList(targetDir, files, maxChars) {
|
|
1229
|
+
async function slurpFileList(targetDir, files, maxChars, redact = true) {
|
|
1089
1230
|
const slices = [];
|
|
1090
1231
|
let currentModule = "";
|
|
1091
1232
|
let parts = [];
|
|
1092
1233
|
let running = 0;
|
|
1093
1234
|
let fileCount = 0;
|
|
1094
1235
|
let filePaths = [];
|
|
1236
|
+
let redactedValues = 0;
|
|
1237
|
+
let redactedFiles = [];
|
|
1095
1238
|
const flush = () => {
|
|
1096
1239
|
if (parts.length === 0)
|
|
1097
1240
|
return;
|
|
@@ -1101,20 +1244,38 @@ async function slurpFileList(targetDir, files, maxChars) {
|
|
|
1101
1244
|
fileCount,
|
|
1102
1245
|
chars: running,
|
|
1103
1246
|
files: filePaths,
|
|
1247
|
+
redactedValues,
|
|
1248
|
+
redactedFiles,
|
|
1104
1249
|
});
|
|
1105
1250
|
parts = [];
|
|
1106
1251
|
running = 0;
|
|
1107
1252
|
fileCount = 0;
|
|
1108
1253
|
filePaths = [];
|
|
1254
|
+
redactedValues = 0;
|
|
1255
|
+
redactedFiles = [];
|
|
1109
1256
|
};
|
|
1110
1257
|
for (const file of files) {
|
|
1111
1258
|
let content = "";
|
|
1112
1259
|
try {
|
|
1113
1260
|
content = await readFile(join(targetDir, file.relPath), "utf8");
|
|
1114
1261
|
}
|
|
1115
|
-
catch {
|
|
1262
|
+
catch (error) {
|
|
1263
|
+
// The listing is the working tree's, so this is a race with a
|
|
1264
|
+
// concurrent delete rather than a listed-but-deleted file; skip it.
|
|
1265
|
+
if (error.code === "ENOENT")
|
|
1266
|
+
continue;
|
|
1116
1267
|
content = "[BINARY or UNREADABLE]";
|
|
1117
1268
|
}
|
|
1269
|
+
if (redact) {
|
|
1270
|
+
// Before the slice is built, so the count and the chars the estimate
|
|
1271
|
+
// sees are of what is actually sent (#252).
|
|
1272
|
+
const redaction = redactSecrets(content);
|
|
1273
|
+
if (redaction.count > 0) {
|
|
1274
|
+
content = redaction.text;
|
|
1275
|
+
redactedValues += redaction.count;
|
|
1276
|
+
redactedFiles.push(file.relPath);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1118
1279
|
const block = `=== ${file.relPath} ===\n${content}\n`;
|
|
1119
1280
|
if (file.moduleName !== currentModule && parts.length > 0) {
|
|
1120
1281
|
flush();
|
|
@@ -1134,12 +1295,13 @@ async function slurpFileList(targetDir, files, maxChars) {
|
|
|
1134
1295
|
flush();
|
|
1135
1296
|
return slices;
|
|
1136
1297
|
}
|
|
1137
|
-
export async function gatherSlices(targetDir, lens, info) {
|
|
1298
|
+
export async function gatherSlices(targetDir, lens, info, opts = {}) {
|
|
1299
|
+
const redact = opts.redact ?? true;
|
|
1138
1300
|
if (lens.sliceBy === "none" && lens.globsFor(info).length === 0) {
|
|
1139
1301
|
// Repo-info lens (architecture): the prompt is built from info alone.
|
|
1140
1302
|
return [{ moduleName: "root", content: "", fileCount: 0, chars: 0, files: [] }];
|
|
1141
1303
|
}
|
|
1142
|
-
const allFiles = await listRepoFiles(targetDir);
|
|
1304
|
+
const { files: allFiles } = await listRepoFiles(targetDir);
|
|
1143
1305
|
const files = collectLensFiles(allFiles, lens, info);
|
|
1144
1306
|
const totalChars = await sumFileSizes(targetDir, files);
|
|
1145
1307
|
const mode = resolveSliceMode(lens, files, totalChars);
|
|
@@ -1147,9 +1309,9 @@ export async function gatherSlices(targetDir, lens, info) {
|
|
|
1147
1309
|
// Whole-repo slice: one module named after the repo, so a small
|
|
1148
1310
|
// repo produces a single request instead of one per directory.
|
|
1149
1311
|
const single = files.map((f) => ({ ...f, moduleName: info.name }));
|
|
1150
|
-
return slurpFileList(targetDir, single, lens.maxChars);
|
|
1312
|
+
return slurpFileList(targetDir, single, lens.maxChars, redact);
|
|
1151
1313
|
}
|
|
1152
|
-
return slurpFileList(targetDir, files, lens.maxChars);
|
|
1314
|
+
return slurpFileList(targetDir, files, lens.maxChars, redact);
|
|
1153
1315
|
}
|
|
1154
1316
|
async function sumFileSizes(targetDir, files) {
|
|
1155
1317
|
let total = 0;
|
|
@@ -1199,7 +1361,12 @@ export function buildBatchRequest(lens, info, slice, index, sliceCount, model =
|
|
|
1199
1361
|
* estimate covers slice content only, which is what the old signature did.
|
|
1200
1362
|
*/
|
|
1201
1363
|
export function estimateCost(lens, slices, pricing, maxTokensOverride, info) {
|
|
1202
|
-
|
|
1364
|
+
// With repo info, size each request from the user prompt that would be
|
|
1365
|
+
// sent, which is what the architecture lens is made of: it used to be
|
|
1366
|
+
// estimated at a flat 6,000 chars while the entry point, manifest, README
|
|
1367
|
+
// excerpt and file tree it carries ran to whatever they ran to (#249).
|
|
1368
|
+
// Without info, the slice content alone is what the old signature covered.
|
|
1369
|
+
const sliceChars = slices.reduce((sum, s) => sum + (info ? lens.userPrompt(info, s.content, s.moduleName).length : lens.maxChars === 0 ? 6000 : s.chars), 0);
|
|
1203
1370
|
// The system prompt and the response schema ride on every request, so they
|
|
1204
1371
|
// are paid once per slice rather than once per lens.
|
|
1205
1372
|
const perRequestOverhead = info
|
|
@@ -1247,15 +1414,30 @@ export async function loadBroadsideState(broadsideDir) {
|
|
|
1247
1414
|
const statePath = join(broadsideDir, BROADSIDE_STATE_FILE);
|
|
1248
1415
|
if (!(await pathExists(statePath)))
|
|
1249
1416
|
return defaultBroadsideState();
|
|
1417
|
+
const text = await readFile(statePath, "utf8");
|
|
1418
|
+
let raw;
|
|
1250
1419
|
try {
|
|
1251
|
-
|
|
1252
|
-
if (!raw || typeof raw !== "object" || !Array.isArray(raw.runs))
|
|
1253
|
-
return defaultBroadsideState();
|
|
1254
|
-
return raw;
|
|
1420
|
+
raw = JSON.parse(text);
|
|
1255
1421
|
}
|
|
1256
|
-
catch {
|
|
1257
|
-
|
|
1422
|
+
catch (error) {
|
|
1423
|
+
throw new BroadsideStateError(statePath, await preserveCorruptState(statePath, text), `could not be parsed (${error instanceof Error ? error.message : String(error)})`);
|
|
1258
1424
|
}
|
|
1425
|
+
if (!raw || typeof raw !== "object" || !Array.isArray(raw.runs)) {
|
|
1426
|
+
throw new BroadsideStateError(statePath, await preserveCorruptState(statePath, text), "is not a state file (expected an object with a runs array)");
|
|
1427
|
+
}
|
|
1428
|
+
return raw;
|
|
1429
|
+
}
|
|
1430
|
+
/**
|
|
1431
|
+
* Copy an unreadable state file to `state.json.corrupt-<hash>` beside it,
|
|
1432
|
+
* named by content so repeated loads do not multiply copies. Returns the
|
|
1433
|
+
* copy's path (the existing one, when the same content was preserved before).
|
|
1434
|
+
*/
|
|
1435
|
+
async function preserveCorruptState(statePath, text) {
|
|
1436
|
+
const digest = createHash("sha1").update(text).digest("hex").slice(0, 8);
|
|
1437
|
+
const backupPath = `${statePath}.corrupt-${digest}`;
|
|
1438
|
+
if (!(await pathExists(backupPath)))
|
|
1439
|
+
await writeFile(backupPath, text, "utf8");
|
|
1440
|
+
return backupPath;
|
|
1259
1441
|
}
|
|
1260
1442
|
/**
|
|
1261
1443
|
* Overwrite `state.json` wholesale with `state`.
|
|
@@ -1348,13 +1530,26 @@ export async function loadBroadsideConfig(broadsideDir) {
|
|
|
1348
1530
|
const configPath = join(broadsideDir, BROADSIDE_CONFIG_FILE);
|
|
1349
1531
|
let raw = {};
|
|
1350
1532
|
if (await pathExists(configPath)) {
|
|
1533
|
+
let parsed;
|
|
1351
1534
|
try {
|
|
1352
|
-
|
|
1535
|
+
parsed = await loadYamlFile(configPath);
|
|
1353
1536
|
}
|
|
1354
|
-
catch {
|
|
1355
|
-
|
|
1537
|
+
catch (error) {
|
|
1538
|
+
throw new BroadsideConfigError(configPath, `could not be parsed (${error instanceof Error ? error.message : String(error)})`);
|
|
1539
|
+
}
|
|
1540
|
+
if (parsed !== null && parsed !== undefined) {
|
|
1541
|
+
if (typeof parsed !== "object" || Array.isArray(parsed))
|
|
1542
|
+
throw new BroadsideConfigError(configPath, "is not a YAML mapping");
|
|
1543
|
+
raw = parsed;
|
|
1356
1544
|
}
|
|
1357
1545
|
}
|
|
1546
|
+
return buildBroadsideConfig(raw);
|
|
1547
|
+
}
|
|
1548
|
+
/** The shipped defaults: what an absent config.yaml means. */
|
|
1549
|
+
export function defaultBroadsideConfig() {
|
|
1550
|
+
return buildBroadsideConfig({});
|
|
1551
|
+
}
|
|
1552
|
+
function buildBroadsideConfig(raw) {
|
|
1358
1553
|
const lenses = Array.isArray(raw.default_lenses)
|
|
1359
1554
|
? (raw.default_lenses.filter((l) => BROADSIDE_LENS_IDS.includes(l)))
|
|
1360
1555
|
: [];
|
|
@@ -1379,7 +1574,9 @@ export async function loadBroadsideConfig(broadsideDir) {
|
|
|
1379
1574
|
model: typeof raw.model === "string" && raw.model.trim() ? raw.model.trim() : BROADSIDE_MODEL,
|
|
1380
1575
|
apiKey: typeof raw.api_key === "string" ? raw.api_key.trim() : "",
|
|
1381
1576
|
defaultLenses: lenses.length > 0 ? lenses : [...BROADSIDE_LENS_IDS],
|
|
1382
|
-
|
|
1577
|
+
// Absent: the shipped default. An explicit 0 is "no limit", spelled out
|
|
1578
|
+
// on purpose; a negative or non-numeric value is not a limit at all.
|
|
1579
|
+
maxCost: typeof raw.max_cost === "number" && raw.max_cost >= 0 ? raw.max_cost : BROADSIDE_DEFAULT_MAX_COST,
|
|
1383
1580
|
pricing: inputOverride !== undefined && outputOverride !== undefined
|
|
1384
1581
|
? { inputPerM: inputOverride, outputPerM: outputOverride }
|
|
1385
1582
|
: null,
|
|
@@ -1393,15 +1590,21 @@ export async function loadBroadsideConfig(broadsideDir) {
|
|
|
1393
1590
|
includeSynthesis: flag("include_synthesis", true),
|
|
1394
1591
|
includeTriage: flag("include_triage", true),
|
|
1395
1592
|
waitSeconds: typeof raw.wait_seconds === "number" && raw.wait_seconds > 0 ? raw.wait_seconds : 0,
|
|
1593
|
+
redactSecrets: flag("redact_secrets", true),
|
|
1396
1594
|
};
|
|
1397
1595
|
}
|
|
1596
|
+
// ---------- model catalog, pricing, benchmarks ----------
|
|
1597
|
+
/** The catalog cache schema this build writes; a file from another is not read. */
|
|
1598
|
+
export const BROADSIDE_CATALOG_CACHE_SCHEMA = 3;
|
|
1398
1599
|
async function readCatalogCache(broadsideDir) {
|
|
1399
1600
|
const cachePath = join(broadsideDir, BROADSIDE_CATALOG_CACHE_FILE);
|
|
1400
1601
|
if (!(await pathExists(cachePath)))
|
|
1401
1602
|
return null;
|
|
1402
1603
|
try {
|
|
1403
1604
|
const parsed = JSON.parse(await readFile(cachePath, "utf8"));
|
|
1404
|
-
if (!parsed || typeof parsed !== "object" || typeof parsed.models !== "object")
|
|
1605
|
+
if (!parsed || typeof parsed !== "object" || !parsed.models || typeof parsed.models !== "object")
|
|
1606
|
+
return null;
|
|
1607
|
+
if (parsed.schema_version !== BROADSIDE_CATALOG_CACHE_SCHEMA && parsed.schema_version !== 2)
|
|
1405
1608
|
return null;
|
|
1406
1609
|
return parsed;
|
|
1407
1610
|
}
|
|
@@ -1409,6 +1612,11 @@ async function readCatalogCache(broadsideDir) {
|
|
|
1409
1612
|
return null;
|
|
1410
1613
|
}
|
|
1411
1614
|
}
|
|
1615
|
+
/** When a cached entry was fetched: its own stamp, or the file's for a schema-2 cache. */
|
|
1616
|
+
function catalogEntryFetchedAt(cache, model) {
|
|
1617
|
+
const stamp = cache.models[model]?.fetched_at ?? cache.fetched_at;
|
|
1618
|
+
return new Date(stamp).getTime();
|
|
1619
|
+
}
|
|
1412
1620
|
async function writeCatalogCache(broadsideDir, cache) {
|
|
1413
1621
|
await mkdir(broadsideDir, { recursive: true });
|
|
1414
1622
|
await writeFile(join(broadsideDir, BROADSIDE_CATALOG_CACHE_FILE), `${JSON.stringify(cache, null, "\t")}\n`, "utf8");
|
|
@@ -1488,31 +1696,51 @@ export async function resolveCatalogEntry(broadsideDir, config, model, apiKey, f
|
|
|
1488
1696
|
// constants below are what we fall back to when the network is unavailable.
|
|
1489
1697
|
const cache = await readCatalogCache(broadsideDir);
|
|
1490
1698
|
const cached = cache?.models[model];
|
|
1491
|
-
if (cached && Date.now() -
|
|
1699
|
+
if (cache && cached && Date.now() - catalogEntryFetchedAt(cache, model) < BROADSIDE_CATALOG_CACHE_TTL_MS) {
|
|
1492
1700
|
return { model, source: "cache", entry: cached };
|
|
1493
1701
|
}
|
|
1702
|
+
// What went wrong when the live lookup produced nothing, for the error
|
|
1703
|
+
// below: a 401 and a dead network used to read the same — "could not
|
|
1704
|
+
// resolve per-token pricing" — or, for the default model, nothing at all.
|
|
1494
1705
|
let live = null;
|
|
1706
|
+
let catalogFailure = null;
|
|
1495
1707
|
try {
|
|
1496
1708
|
const resp = await fetcher(BROADSIDE_MODELS_URL, {
|
|
1497
1709
|
method: "GET",
|
|
1498
1710
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
1499
1711
|
signal: AbortSignal.timeout(30_000),
|
|
1500
1712
|
});
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1713
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
1714
|
+
throw new BroadsideAuthError(resp.status, await responseDetail(resp));
|
|
1715
|
+
}
|
|
1716
|
+
if (resp.ok === false) {
|
|
1717
|
+
catalogFailure = `the model catalog request failed (HTTP ${resp.status}${await responseDetail(resp).then((d) => (d ? `: ${d}` : ""))})`;
|
|
1718
|
+
}
|
|
1719
|
+
else {
|
|
1720
|
+
const data = (await resp.json());
|
|
1721
|
+
const hit = (data.data ?? []).find((m) => String(m.id) === model);
|
|
1722
|
+
if (hit)
|
|
1723
|
+
live = parseCatalogEntry(hit);
|
|
1724
|
+
else
|
|
1725
|
+
catalogFailure = `the model catalog has no entry for "${model}"`;
|
|
1726
|
+
}
|
|
1505
1727
|
}
|
|
1506
|
-
catch {
|
|
1728
|
+
catch (error) {
|
|
1729
|
+
if (error instanceof BroadsideAuthError)
|
|
1730
|
+
throw error;
|
|
1507
1731
|
live = null;
|
|
1732
|
+
catalogFailure = `the model catalog could not be fetched (${error instanceof Error ? error.message : String(error)})`;
|
|
1508
1733
|
}
|
|
1509
1734
|
if (live) {
|
|
1735
|
+
const now = new Date().toISOString();
|
|
1510
1736
|
const updated = {
|
|
1511
|
-
schema_version:
|
|
1512
|
-
fetched_at:
|
|
1513
|
-
|
|
1737
|
+
schema_version: BROADSIDE_CATALOG_CACHE_SCHEMA,
|
|
1738
|
+
fetched_at: now,
|
|
1739
|
+
// Other entries keep their own stamps (a schema-2 file's entries
|
|
1740
|
+
// inherit the file's, once, on this upgrade); only this model is fresh.
|
|
1741
|
+
models: Object.fromEntries(Object.entries(cache?.models ?? {}).map(([id, entry]) => [id, { ...entry, fetched_at: entry.fetched_at ?? cache.fetched_at }])),
|
|
1514
1742
|
};
|
|
1515
|
-
updated.models[model] = live;
|
|
1743
|
+
updated.models[model] = { ...live, fetched_at: now };
|
|
1516
1744
|
await writeCatalogCache(broadsideDir, updated);
|
|
1517
1745
|
return { model, source: "live", entry: live };
|
|
1518
1746
|
}
|
|
@@ -1521,10 +1749,37 @@ export async function resolveCatalogEntry(broadsideDir, config, model, apiKey, f
|
|
|
1521
1749
|
const builtIn = builtInCatalogEntry(model);
|
|
1522
1750
|
if (builtIn)
|
|
1523
1751
|
return { model, source: "built-in", entry: builtIn };
|
|
1524
|
-
throw new Error(`Could not resolve per-token pricing for batch model "${model}". ` +
|
|
1752
|
+
throw new Error(`Could not resolve per-token pricing for batch model "${model}": ${catalogFailure ?? "no catalog entry"}. ` +
|
|
1525
1753
|
"Set pricing.input_per_m and pricing.output_per_m in .codecarto/broadside/config.yaml " +
|
|
1526
1754
|
"(USD per million tokens), or check the model id against https://openrouter.ai/models?variant=batch.");
|
|
1527
1755
|
}
|
|
1756
|
+
/** A short, safe excerpt of an error response body for a message. */
|
|
1757
|
+
async function responseDetail(resp) {
|
|
1758
|
+
try {
|
|
1759
|
+
if (typeof resp.text === "function") {
|
|
1760
|
+
const text = (await resp.text()).trim();
|
|
1761
|
+
try {
|
|
1762
|
+
const parsed = JSON.parse(text);
|
|
1763
|
+
const message = typeof parsed?.error === "string" ? parsed.error : parsed?.error?.message;
|
|
1764
|
+
if (typeof message === "string" && message)
|
|
1765
|
+
return message.slice(0, 200);
|
|
1766
|
+
}
|
|
1767
|
+
catch {
|
|
1768
|
+
// not JSON; fall through to the raw excerpt
|
|
1769
|
+
}
|
|
1770
|
+
return text.replace(/\s+/g, " ").slice(0, 200);
|
|
1771
|
+
}
|
|
1772
|
+
if (typeof resp.json === "function") {
|
|
1773
|
+
const parsed = (await resp.json());
|
|
1774
|
+
const message = typeof parsed?.error === "string" ? parsed.error : parsed?.error?.message;
|
|
1775
|
+
return typeof message === "string" ? message.slice(0, 200) : "";
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
catch {
|
|
1779
|
+
// an unreadable body adds nothing to the message
|
|
1780
|
+
}
|
|
1781
|
+
return "";
|
|
1782
|
+
}
|
|
1528
1783
|
export async function resolveModelPricing(broadsideDir, config, model, apiKey, fetcher = fetch) {
|
|
1529
1784
|
const { source, entry } = await resolveCatalogEntry(broadsideDir, config, model, apiKey, fetcher);
|
|
1530
1785
|
if (!entry)
|
|
@@ -1582,9 +1837,10 @@ export async function listBatchModels(broadsideDir, config, apiKey, opts = {}) {
|
|
|
1582
1837
|
}
|
|
1583
1838
|
entries.sort((a, b) => a.inputPerM + a.outputPerM - (b.inputPerM + b.outputPerM));
|
|
1584
1839
|
// Persist the catalog so the next submit's pricing resolution hits cache.
|
|
1585
|
-
const
|
|
1840
|
+
const fetchedAt = new Date().toISOString();
|
|
1841
|
+
const cache = { schema_version: BROADSIDE_CATALOG_CACHE_SCHEMA, fetched_at: fetchedAt, models: {} };
|
|
1586
1842
|
for (const entry of entries)
|
|
1587
|
-
cache.models[entry.id] = entry;
|
|
1843
|
+
cache.models[entry.id] = { ...entry, fetched_at: fetchedAt };
|
|
1588
1844
|
await writeCatalogCache(broadsideDir, cache);
|
|
1589
1845
|
const benchmarks = opts.includeBenchmarks ? await fetchCodingBenchmarks(apiKey, fetcher) : null;
|
|
1590
1846
|
return { entries, source: "live", benchmarks, defaultModel: config.model };
|
|
@@ -1618,7 +1874,17 @@ export async function fetchBatch(batchId, apiKey, fetcher = fetch) {
|
|
|
1618
1874
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
1619
1875
|
signal: AbortSignal.timeout(30_000),
|
|
1620
1876
|
});
|
|
1621
|
-
|
|
1877
|
+
let data;
|
|
1878
|
+
try {
|
|
1879
|
+
data = (await resp.json());
|
|
1880
|
+
}
|
|
1881
|
+
catch (error) {
|
|
1882
|
+
// A gateway error page is not JSON. It used to throw out of here and
|
|
1883
|
+
// be retried as if the network were down; keep the status instead.
|
|
1884
|
+
data = { error: `non-JSON response (${error instanceof Error ? error.message : String(error)})` };
|
|
1885
|
+
}
|
|
1886
|
+
if (!data || typeof data !== "object")
|
|
1887
|
+
data = { error: "empty response" };
|
|
1622
1888
|
// Surface the HTTP status so the poller can bail fast on auth expiry
|
|
1623
1889
|
// instead of retrying a dead key for the whole budget.
|
|
1624
1890
|
data.http_status = resp.status;
|
|
@@ -1632,18 +1898,38 @@ export async function fetchBatch(batchId, apiKey, fetcher = fetch) {
|
|
|
1632
1898
|
* charged, so callers must come back for it rather than retire it.
|
|
1633
1899
|
*/
|
|
1634
1900
|
export const BROADSIDE_DEAD_BATCH_STATUSES = ["failed", "expired", "cancelled", "auth-failed"];
|
|
1901
|
+
/**
|
|
1902
|
+
* Batch entry statuses collect never polls again: the dead ones above, plus
|
|
1903
|
+
* `completed`, plus the two a submit assigns without a batch (`skipped`: no
|
|
1904
|
+
* matching files; `rejected`: the provider refused it). The 0.19.1 changelog
|
|
1905
|
+
* called the dead set "a named constant rather than two hand-maintained
|
|
1906
|
+
* lists"; this set was still three literal copies (self-audit sem 5.8).
|
|
1907
|
+
*/
|
|
1908
|
+
export const BROADSIDE_TERMINAL_ENTRY_STATUSES = ["completed", ...BROADSIDE_DEAD_BATCH_STATUSES, "skipped", "rejected"];
|
|
1635
1909
|
export async function pollBatchUntilTerminal(batchId, apiKey, opts = {}) {
|
|
1636
1910
|
const deadline = Date.now() + (opts.deadlineMs ?? BROADSIDE_DEFAULT_POLL_BUDGET_MS);
|
|
1637
1911
|
const intervalMs = opts.pollIntervalMs ?? BROADSIDE_POLL_INTERVAL_MS;
|
|
1638
1912
|
const fetcher = opts.fetcher ?? fetch;
|
|
1913
|
+
// A poll that runs out of budget without one good response is not a slow
|
|
1914
|
+
// batch. The last thing that went wrong rides on the timeout so the report
|
|
1915
|
+
// can tell a dead network or a failing gateway from a batch still running.
|
|
1916
|
+
let lastError = null;
|
|
1917
|
+
let sawBatch = false;
|
|
1918
|
+
const timedOut = () => ({
|
|
1919
|
+
id: batchId,
|
|
1920
|
+
status: "timeout",
|
|
1921
|
+
...(lastError && !sawBatch && { error: `no successful poll response; last error: ${lastError}` }),
|
|
1922
|
+
...(lastError && sawBatch && { last_error: lastError }),
|
|
1923
|
+
});
|
|
1639
1924
|
for (;;) {
|
|
1640
1925
|
let batch;
|
|
1641
1926
|
try {
|
|
1642
1927
|
batch = await fetchBatch(batchId, apiKey, fetcher);
|
|
1643
1928
|
}
|
|
1644
|
-
catch {
|
|
1929
|
+
catch (error) {
|
|
1930
|
+
lastError = `fetch failed (${error instanceof Error ? error.message : String(error)})`;
|
|
1645
1931
|
if (Date.now() >= deadline)
|
|
1646
|
-
return
|
|
1932
|
+
return timedOut();
|
|
1647
1933
|
await sleep(intervalMs);
|
|
1648
1934
|
continue;
|
|
1649
1935
|
}
|
|
@@ -1651,13 +1937,23 @@ export async function pollBatchUntilTerminal(batchId, apiKey, opts = {}) {
|
|
|
1651
1937
|
if (httpStatus === 401 || httpStatus === 403) {
|
|
1652
1938
|
return { id: batchId, status: "auth-failed", error: batch.error ?? batch };
|
|
1653
1939
|
}
|
|
1940
|
+
if (httpStatus >= 400) {
|
|
1941
|
+
// A gateway or server error: retry within the budget, remembered.
|
|
1942
|
+
const detail = typeof batch.error === "string" ? batch.error : JSON.stringify(batch.error ?? "");
|
|
1943
|
+
lastError = `HTTP ${httpStatus}${detail ? ` (${detail.slice(0, 200)})` : ""}`;
|
|
1944
|
+
if (Date.now() >= deadline)
|
|
1945
|
+
return timedOut();
|
|
1946
|
+
await sleep(intervalMs);
|
|
1947
|
+
continue;
|
|
1948
|
+
}
|
|
1949
|
+
sawBatch = true;
|
|
1654
1950
|
const status = String(batch.status ?? "unknown");
|
|
1655
1951
|
const counts = (batch.request_counts ?? {});
|
|
1656
1952
|
opts.onStatus?.(status, counts);
|
|
1657
1953
|
if (status === "completed" || BROADSIDE_DEAD_BATCH_STATUSES.includes(status))
|
|
1658
1954
|
return batch;
|
|
1659
1955
|
if (Date.now() >= deadline)
|
|
1660
|
-
return
|
|
1956
|
+
return timedOut();
|
|
1661
1957
|
await sleep(intervalMs);
|
|
1662
1958
|
}
|
|
1663
1959
|
}
|
|
@@ -1684,7 +1980,6 @@ export async function pollBatchesConcurrently(entries, apiKey, opts = {}) {
|
|
|
1684
1980
|
}
|
|
1685
1981
|
// ---------- run orchestration ----------
|
|
1686
1982
|
export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
1687
|
-
const info = await collectRepoInfo(cwd);
|
|
1688
1983
|
const lensIds = opts.lenses ?? BROADSIDE_LENS_IDS;
|
|
1689
1984
|
const broadsideDir = broadsideDirFor(cwd);
|
|
1690
1985
|
const model = opts.model ?? BROADSIDE_MODEL;
|
|
@@ -1693,6 +1988,19 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
|
1693
1988
|
// structured-output support that not all batch models offer. Lenses may run
|
|
1694
1989
|
// on different models (config `lens_models`), so each one is pre-flighted.
|
|
1695
1990
|
const config = await loadBroadsideConfig(broadsideDir);
|
|
1991
|
+
const redact = config.redactSecrets;
|
|
1992
|
+
const info = await collectRepoInfo(cwd, { redact });
|
|
1993
|
+
// Before pricing, before the network, before any state write: a run on a
|
|
1994
|
+
// language the lenses cannot scan used to submit empty batches and pay for
|
|
1995
|
+
// them (#250).
|
|
1996
|
+
if (info.language === "unknown") {
|
|
1997
|
+
throw new Error(`Broad-Side could not tell what language this repository is: no ${MANIFEST_CANDIDATES.map(([candidate]) => candidate).join(", ")} ` +
|
|
1998
|
+
`and no source files in a language the lenses can scan (${BROADSIDE_LANGUAGES.join(", ")}). Nothing was submitted.`);
|
|
1999
|
+
}
|
|
2000
|
+
if (info.sourceFileCount === 0) {
|
|
2001
|
+
throw new Error(`Broad-Side found no ${info.language} source files to scan (detected from ${info.manifest?.path ?? "the file counts"}; ` +
|
|
2002
|
+
`the lenses look for ${info.sourceExts.join(", ")}). Nothing was submitted.`);
|
|
2003
|
+
}
|
|
1696
2004
|
const modelForLens = (lensId) => config.lensModels[lensId] ?? model;
|
|
1697
2005
|
const resolved = new Map();
|
|
1698
2006
|
for (const candidate of new Set([model, ...lensIds.map(modelForLens)])) {
|
|
@@ -1758,9 +2066,19 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
|
1758
2066
|
let estimatedOutputTokens = 0;
|
|
1759
2067
|
let estimatedTotalCost = 0;
|
|
1760
2068
|
const perLensEstimate = [];
|
|
2069
|
+
// What the redaction pass did across every lens's slices, for the run
|
|
2070
|
+
// record and the report: a value in a file shared by two lenses counts
|
|
2071
|
+
// once per lens it was sent in, files once each.
|
|
2072
|
+
let redactedValues = info.redactedValues;
|
|
2073
|
+
const redactedFiles = new Set();
|
|
1761
2074
|
for (const lensId of lensIds) {
|
|
1762
2075
|
const lens = getLens(lensId);
|
|
1763
|
-
let slices = await gatherSlices(cwd, lens, info);
|
|
2076
|
+
let slices = await gatherSlices(cwd, lens, info, { redact });
|
|
2077
|
+
for (const slice of slices) {
|
|
2078
|
+
redactedValues += slice.redactedValues ?? 0;
|
|
2079
|
+
for (const file of slice.redactedFiles ?? [])
|
|
2080
|
+
redactedFiles.add(file);
|
|
2081
|
+
}
|
|
1764
2082
|
if (changed) {
|
|
1765
2083
|
// Repo-info slices (empty files, e.g. architecture) always run;
|
|
1766
2084
|
// file-backed slices run only when one of their files changed.
|
|
@@ -1819,7 +2137,9 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
|
1819
2137
|
`$${limit.toFixed(2)}. Nothing was submitted.\nBreakdown:\n${breakdown}\n` +
|
|
1820
2138
|
`Pass force: true to submit anyway, or raise max_cost in .codecarto/broadside/config.yaml.`);
|
|
1821
2139
|
}
|
|
1822
|
-
|
|
2140
|
+
// Read before anything is posted: a state.json that cannot be read refuses
|
|
2141
|
+
// the run here (#233), while persistBroadsideRun below merges by run id.
|
|
2142
|
+
await loadBroadsideState(broadsideDir);
|
|
1823
2143
|
const runId = new Date().toISOString().replace(/[:.]/g, "-");
|
|
1824
2144
|
const run = {
|
|
1825
2145
|
id: runId,
|
|
@@ -1837,8 +2157,15 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
|
1837
2157
|
sourceHead,
|
|
1838
2158
|
sourceDirty,
|
|
1839
2159
|
baseHead,
|
|
2160
|
+
snapshot: info.snapshot,
|
|
2161
|
+
language: info.language,
|
|
2162
|
+
redaction: {
|
|
2163
|
+
enabled: redact,
|
|
2164
|
+
values: redactedValues,
|
|
2165
|
+
files: redactedFiles.size,
|
|
2166
|
+
skippedFiles: info.secretFilesSkipped.length,
|
|
2167
|
+
},
|
|
1840
2168
|
};
|
|
1841
|
-
state.runs.push(run);
|
|
1842
2169
|
await persistBroadsideRun(broadsideDir, run);
|
|
1843
2170
|
const requestsByCustomId = {};
|
|
1844
2171
|
const submissions = [];
|
|
@@ -1915,6 +2242,19 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
|
1915
2242
|
expirationDate: defaultEntry.expirationDate ?? null,
|
|
1916
2243
|
},
|
|
1917
2244
|
incremental: incrementalOutcome,
|
|
2245
|
+
repo: {
|
|
2246
|
+
language: info.language,
|
|
2247
|
+
sourceFiles: info.sourceFileCount,
|
|
2248
|
+
snapshot: info.snapshot,
|
|
2249
|
+
sourceHead,
|
|
2250
|
+
sourceDirty,
|
|
2251
|
+
},
|
|
2252
|
+
redaction: {
|
|
2253
|
+
enabled: redact,
|
|
2254
|
+
values: redactedValues,
|
|
2255
|
+
files: redactedFiles.size,
|
|
2256
|
+
skippedFiles: info.secretFilesSkipped,
|
|
2257
|
+
},
|
|
1918
2258
|
};
|
|
1919
2259
|
}
|
|
1920
2260
|
function extractContent(result) {
|
|
@@ -2120,8 +2460,13 @@ function parseTriageItems(content) {
|
|
|
2120
2460
|
export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
2121
2461
|
const broadsideDir = broadsideDirFor(cwd);
|
|
2122
2462
|
const state = await loadBroadsideState(broadsideDir);
|
|
2123
|
-
const run = state.runs[state.runs.length - 1];
|
|
2463
|
+
const run = opts.runId ? state.runs.find((candidate) => candidate.id === opts.runId) : state.runs[state.runs.length - 1];
|
|
2124
2464
|
if (!run) {
|
|
2465
|
+
if (opts.runId) {
|
|
2466
|
+
const known = state.runs.map((candidate) => candidate.id);
|
|
2467
|
+
throw new Error(`No Broad-Side run with id ${opts.runId}. ` +
|
|
2468
|
+
(known.length > 0 ? `Recorded runs: ${known.join(", ")}.` : "No runs are recorded; call codecarto_broadside with action 'submit' first."));
|
|
2469
|
+
}
|
|
2125
2470
|
throw new Error("No Broad-Side run recorded. Call codecarto_broadside with action 'submit' first.");
|
|
2126
2471
|
}
|
|
2127
2472
|
const runDir = join(broadsideDir, run.outputDir);
|
|
@@ -2142,7 +2487,7 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2142
2487
|
lensOutcomes[lensId] = { status: entry?.status ?? "failed", resultCount: 0 };
|
|
2143
2488
|
continue;
|
|
2144
2489
|
}
|
|
2145
|
-
if (
|
|
2490
|
+
if (BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status)) {
|
|
2146
2491
|
totalCost += entry.cost ?? 0;
|
|
2147
2492
|
resultCount += entry.resultCount ?? 0;
|
|
2148
2493
|
lensOutcomes[lensId] = { status: entry.status, cost: entry.cost, resultCount: entry.resultCount };
|
|
@@ -2187,7 +2532,8 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2187
2532
|
// indistinguishable in the output from one that was never requested.
|
|
2188
2533
|
if (batch.error)
|
|
2189
2534
|
entry.error = batch.error;
|
|
2190
|
-
|
|
2535
|
+
const error = describeBatchError(batch.error);
|
|
2536
|
+
lensOutcomes[lensId] = { status, cost: entry.cost, resultCount: entry.resultCount, ...(error && { error }) };
|
|
2191
2537
|
}
|
|
2192
2538
|
await persistBroadsideRun(broadsideDir, run);
|
|
2193
2539
|
}
|
|
@@ -2281,7 +2627,7 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2281
2627
|
if ((wantSynthesis || wantTriage) && allLensResults.length > 0) {
|
|
2282
2628
|
const allTerminal = run.lenses.every((lensId) => {
|
|
2283
2629
|
const entry = run.batches[lensId];
|
|
2284
|
-
return entry &&
|
|
2630
|
+
return entry && BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status);
|
|
2285
2631
|
});
|
|
2286
2632
|
if (allTerminal && (postPassUnfinished(run.synthesis) || postPassUnfinished(run.triage))) {
|
|
2287
2633
|
const findingsText = allLensResults
|
|
@@ -2393,7 +2739,7 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2393
2739
|
}
|
|
2394
2740
|
const terminal = run.lenses.every((lensId) => {
|
|
2395
2741
|
const entry = run.batches[lensId];
|
|
2396
|
-
return entry &&
|
|
2742
|
+
return entry && BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status);
|
|
2397
2743
|
});
|
|
2398
2744
|
run.status = terminal ? (resultCount > 0 ? "completed" : "failed") : "partial";
|
|
2399
2745
|
run.totalCost = totalCost;
|
|
@@ -2534,6 +2880,18 @@ export function estimateSubmitText(result, lenses) {
|
|
|
2534
2880
|
const override = entry.model ? ` on ${entry.model}` : "";
|
|
2535
2881
|
lines.push(` ${lens.name}: ${status} (${entry.requests} request(s), ~$${entry.estimatedCost.toFixed(4)})${override}`);
|
|
2536
2882
|
}
|
|
2883
|
+
if (result.repo) {
|
|
2884
|
+
const head = result.repo.sourceHead ? ` at ${result.repo.sourceHead.slice(0, 8)}${result.repo.sourceDirty ? " (dirty)" : ""}` : "";
|
|
2885
|
+
const source = result.repo.snapshot === "working-tree" ? `working tree${head}` : "directory walk (not a git repository)";
|
|
2886
|
+
lines.push(`Scanned as ${result.repo.language}: ${result.repo.sourceFiles} source file(s) from the ${source}.`);
|
|
2887
|
+
}
|
|
2888
|
+
if (result.redaction) {
|
|
2889
|
+
const line = result.redaction.enabled
|
|
2890
|
+
? describeRedactions(result.redaction.values, result.redaction.files, result.redaction.skippedFiles)
|
|
2891
|
+
: "Before upload: secret redaction is OFF (redact_secrets: false in config.yaml); files were sent as they are.";
|
|
2892
|
+
if (line)
|
|
2893
|
+
lines.push(line);
|
|
2894
|
+
}
|
|
2537
2895
|
const incremental = result.incremental;
|
|
2538
2896
|
if (incremental?.requested) {
|
|
2539
2897
|
lines.push(incremental.applied
|
|
@@ -2586,6 +2944,25 @@ export function modelsText(entries, opts) {
|
|
|
2586
2944
|
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.");
|
|
2587
2945
|
return lines.join("\n");
|
|
2588
2946
|
}
|
|
2947
|
+
/** One line of a batch's error field, whatever shape the provider gave it. */
|
|
2948
|
+
function describeBatchError(error) {
|
|
2949
|
+
if (error === undefined || error === null || error === "")
|
|
2950
|
+
return null;
|
|
2951
|
+
if (typeof error === "string")
|
|
2952
|
+
return error.slice(0, 300);
|
|
2953
|
+
if (typeof error === "object") {
|
|
2954
|
+
const message = error.message;
|
|
2955
|
+
if (typeof message === "string" && message)
|
|
2956
|
+
return message.slice(0, 300);
|
|
2957
|
+
try {
|
|
2958
|
+
return JSON.stringify(error).slice(0, 300);
|
|
2959
|
+
}
|
|
2960
|
+
catch {
|
|
2961
|
+
return String(error);
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
return String(error);
|
|
2965
|
+
}
|
|
2589
2966
|
export function collectResultText(result) {
|
|
2590
2967
|
const lines = [
|
|
2591
2968
|
`Broad-Side run ${result.runId}: ${result.status}`,
|
|
@@ -2599,7 +2976,10 @@ export function collectResultText(result) {
|
|
|
2599
2976
|
lines.push(` ${lensId}: ${outcome.status}` +
|
|
2600
2977
|
(outcome.cost !== undefined ? `, $${outcome.cost.toFixed(6)}` : "") +
|
|
2601
2978
|
(outcome.resultCount !== undefined ? `, ${outcome.resultCount} result(s)` : "") +
|
|
2602
|
-
truncation
|
|
2979
|
+
truncation +
|
|
2980
|
+
// The reason a lens did not complete, when the poll recorded one:
|
|
2981
|
+
// an auth failure or a dead network used to read as a slow batch.
|
|
2982
|
+
(outcome.error ? ` — ${outcome.error}` : ""));
|
|
2603
2983
|
}
|
|
2604
2984
|
if (result.retriedCount > 0) {
|
|
2605
2985
|
lines.push(` ↻ ${result.retriedCount} truncated result(s) recovered by re-submission with a doubled output cap.`);
|
|
@@ -2639,6 +3019,12 @@ export function statusText(state) {
|
|
|
2639
3019
|
const lines = [];
|
|
2640
3020
|
for (const run of [...state.runs].reverse().slice(0, 3)) {
|
|
2641
3021
|
lines.push(`Run ${run.id} — ${run.status}`);
|
|
3022
|
+
// Recorded since #248; a run from an older version has neither field.
|
|
3023
|
+
if (run.language || run.snapshot) {
|
|
3024
|
+
const head = run.sourceHead ? ` at ${run.sourceHead.slice(0, 8)}${run.sourceDirty ? " (dirty)" : ""}` : "";
|
|
3025
|
+
const source = run.snapshot === "walk" ? "directory walk" : run.snapshot ? `working tree${head}` : "unknown source";
|
|
3026
|
+
lines.push(` scanned as ${run.language ?? "unknown"} from the ${source}`);
|
|
3027
|
+
}
|
|
2642
3028
|
for (const lensId of BROADSIDE_LENS_IDS) {
|
|
2643
3029
|
const entry = run.batches[lensId];
|
|
2644
3030
|
if (!entry)
|