codecartographer-pi 0.19.6 → 0.20.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 +1 -1
- package/.codecarto/broadside/SKILL.md +14 -0
- package/.codecarto/broadside/config.yaml +18 -0
- package/.codecarto/workflow/VALIDATE.md +2 -1
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/README.md +10 -6
- package/dist/core/broadside.d.ts +72 -2
- package/dist/core/broadside.js +348 -63
- package/dist/core/completion.js +81 -22
- 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 +4 -1
- package/dist/core/orchestrator-config.d.ts +32 -7
- package/dist/core/orchestrator-config.js +124 -44
- package/dist/core/pipeline.d.ts +37 -0
- package/dist/core/pipeline.js +80 -10
- package/dist/core/prompts.d.ts +20 -0
- package/dist/core/prompts.js +43 -10
- package/dist/core/secrets.d.ts +16 -0
- package/dist/core/secrets.js +98 -0
- package/dist/core/status.d.ts +8 -0
- package/dist/core/status.js +8 -3
- package/dist/core/synthesis.js +5 -2
- package/dist/core/workspace.d.ts +55 -8
- package/dist/core/workspace.js +115 -7
- package/dist/core/yaml.js +173 -14
- 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.js +10 -6
- 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 +65 -18
- package/dist/mcp-server/server.js +107 -49
- package/package.json +3 -2
package/dist/core/broadside.js
CHANGED
|
@@ -36,6 +36,7 @@ import { execFile } from "node:child_process";
|
|
|
36
36
|
import { promisify } from "node:util";
|
|
37
37
|
import { join, relative } from "node:path";
|
|
38
38
|
import { atomicWriteFile, pathExists, sleep } from "./utils.js";
|
|
39
|
+
import { describeRedactions, isSecretFile, redactSecrets } from "./secrets.js";
|
|
39
40
|
import { acquireLock } from "./status.js";
|
|
40
41
|
import { loadYamlFile } from "./yaml.js";
|
|
41
42
|
import { packagedWorkspaceDir } from "./workspace.js";
|
|
@@ -102,6 +103,23 @@ export const BROADSIDE_MIN_REASONING_TOKENS = 512;
|
|
|
102
103
|
export function defaultReasoningFor(maxTokens) {
|
|
103
104
|
return { max_tokens: Math.max(BROADSIDE_MIN_REASONING_TOKENS, Math.floor(maxTokens * BROADSIDE_REASONING_BUDGET_FRACTION)) };
|
|
104
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* OpenRouter rejected the API key (HTTP 401/403). Thrown from the catalog
|
|
108
|
+
* lookup rather than swallowed into "could not price" or a silent built-in
|
|
109
|
+
* fallback: a run that cannot authenticate cannot submit either, and the
|
|
110
|
+
* message that reaches the user has to say so (#251).
|
|
111
|
+
*/
|
|
112
|
+
export class BroadsideAuthError extends Error {
|
|
113
|
+
httpStatus;
|
|
114
|
+
detail;
|
|
115
|
+
constructor(httpStatus, detail) {
|
|
116
|
+
super(`OpenRouter rejected the API key (HTTP ${httpStatus}${detail ? `: ${detail}` : ""}). ` +
|
|
117
|
+
"Check OPENROUTER_API_KEY, the api_key parameter, or api_key in .codecarto/broadside/config.yaml. Nothing was submitted.");
|
|
118
|
+
this.name = "BroadsideAuthError";
|
|
119
|
+
this.httpStatus = httpStatus;
|
|
120
|
+
this.detail = detail;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
105
123
|
/** Thrown when a confirm hook declines a run. Nothing was submitted. */
|
|
106
124
|
export class BroadsideCancelledError extends Error {
|
|
107
125
|
constructor(message = "Broad-Side submission cancelled. Nothing was submitted.") {
|
|
@@ -799,14 +817,23 @@ const SKIP_FILE_EXTENSIONS = new Set([
|
|
|
799
817
|
".model",
|
|
800
818
|
".bpe",
|
|
801
819
|
]);
|
|
820
|
+
/**
|
|
821
|
+
* Manifest files and the languages each one can mean. `package.json` covers
|
|
822
|
+
* both TypeScript and JavaScript; which of the two a repository is comes from
|
|
823
|
+
* counting its source files, not from the manifest.
|
|
824
|
+
*/
|
|
802
825
|
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"],
|
|
826
|
+
["go.mod", ["go"]],
|
|
827
|
+
["package.json", ["typescript", "javascript"]],
|
|
828
|
+
["Cargo.toml", ["rust"]],
|
|
829
|
+
["pyproject.toml", ["python"]],
|
|
830
|
+
["setup.py", ["python"]],
|
|
831
|
+
["requirements.txt", ["python"]],
|
|
809
832
|
];
|
|
833
|
+
/** The languages Broad-Side can scan; anything else is refused at submit. */
|
|
834
|
+
export const BROADSIDE_LANGUAGES = ["go", "python", "rust", "typescript", "javascript"];
|
|
835
|
+
/** Chars of the entry-point file and the manifest that ride in the architecture prompt (#249). */
|
|
836
|
+
const REPO_INFO_FILE_CAP = 20_000;
|
|
810
837
|
const SOURCE_SPECS = {
|
|
811
838
|
go: { glob: "**/*.go", exts: [".go"] },
|
|
812
839
|
python: { glob: "**/*.py", exts: [".py"] },
|
|
@@ -814,16 +841,26 @@ const SOURCE_SPECS = {
|
|
|
814
841
|
typescript: { glob: "**/*.ts", exts: [".ts", ".tsx"] },
|
|
815
842
|
javascript: { glob: "**/*.js", exts: [".js", ".jsx"] },
|
|
816
843
|
};
|
|
844
|
+
/**
|
|
845
|
+
* The files a run scans, and where they came from. Contents are always read
|
|
846
|
+
* from the working tree, so the list is the working tree's too: tracked files
|
|
847
|
+
* plus untracked ones git does not ignore, minus files deleted on disk. The
|
|
848
|
+
* list used to come from `git ls-tree HEAD`, so a run mixed the committed
|
|
849
|
+
* file list with uncommitted contents and never saw an untracked file (#248).
|
|
850
|
+
* A target that is not a git repository gets a bounded walk.
|
|
851
|
+
*/
|
|
817
852
|
async function listRepoFiles(targetDir) {
|
|
818
|
-
// git ls-tree is the fast path; fall back to a bounded walk for non-git trees.
|
|
819
853
|
try {
|
|
820
|
-
const
|
|
854
|
+
const listed = await execFileAsync("git", ["-C", targetDir, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { maxBuffer: 64 * 1024 * 1024 });
|
|
855
|
+
const deleted = await execFileAsync("git", ["-C", targetDir, "ls-files", "-z", "--deleted"], {
|
|
821
856
|
maxBuffer: 64 * 1024 * 1024,
|
|
822
857
|
});
|
|
823
|
-
|
|
858
|
+
const gone = new Set(deleted.stdout.split("\0").filter(Boolean));
|
|
859
|
+
const files = listed.stdout.split("\0").filter((path) => path && !gone.has(path));
|
|
860
|
+
return { files, snapshot: "working-tree" };
|
|
824
861
|
}
|
|
825
862
|
catch {
|
|
826
|
-
return walkFiles(targetDir, targetDir, 0, 30_000);
|
|
863
|
+
return { files: await walkFiles(targetDir, targetDir, 0, 30_000), snapshot: "walk" };
|
|
827
864
|
}
|
|
828
865
|
}
|
|
829
866
|
async function gitHead(targetDir) {
|
|
@@ -892,25 +929,65 @@ async function walkFiles(rootDir, dir, depth, remaining) {
|
|
|
892
929
|
}
|
|
893
930
|
return out;
|
|
894
931
|
}
|
|
895
|
-
function
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
932
|
+
function sourceFileCount(language, fileCounts) {
|
|
933
|
+
return (SOURCE_SPECS[language]?.exts ?? []).reduce((sum, ext) => sum + (fileCounts[ext] ?? 0), 0);
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* The language the lenses scan as. The manifests present name the candidates
|
|
937
|
+
* (all of them, not the first one found: a Python service with a
|
|
938
|
+
* `package.json` for its docs tooling is not a TypeScript repository), and
|
|
939
|
+
* among candidates the one with the most source files wins; without a
|
|
940
|
+
* manifest, the language with the most source files; without any source
|
|
941
|
+
* file, `unknown` — which submit refuses rather than scanning nothing and
|
|
942
|
+
* paying for it (#250). Ties keep manifest order.
|
|
943
|
+
*/
|
|
944
|
+
function detectLanguage(fileCounts, manifestPaths) {
|
|
945
|
+
const candidates = [];
|
|
946
|
+
for (const [candidate, languages] of MANIFEST_CANDIDATES) {
|
|
947
|
+
if (!manifestPaths.includes(candidate))
|
|
948
|
+
continue;
|
|
949
|
+
for (const language of languages)
|
|
950
|
+
if (!candidates.includes(language))
|
|
951
|
+
candidates.push(language);
|
|
952
|
+
}
|
|
953
|
+
const pool = candidates.length > 0 ? candidates : [...BROADSIDE_LANGUAGES];
|
|
954
|
+
let best = null;
|
|
955
|
+
let bestCount = -1;
|
|
956
|
+
for (const language of pool) {
|
|
957
|
+
const count = sourceFileCount(language, fileCounts);
|
|
958
|
+
if (count > bestCount) {
|
|
959
|
+
best = language;
|
|
960
|
+
bestCount = count;
|
|
900
961
|
}
|
|
901
962
|
}
|
|
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";
|
|
963
|
+
if (bestCount > 0)
|
|
964
|
+
return best;
|
|
965
|
+
// A manifest with no source files behind it still names the language;
|
|
966
|
+
// submit reports the empty count. No manifest and no source: unknown.
|
|
967
|
+
return candidates[0] ?? "unknown";
|
|
911
968
|
}
|
|
912
|
-
|
|
913
|
-
|
|
969
|
+
/** Cut a file that rides whole in a prompt down to the cap, saying so (#249). */
|
|
970
|
+
function capForPrompt(content, cap) {
|
|
971
|
+
if (content.length <= cap)
|
|
972
|
+
return content;
|
|
973
|
+
return `${content.slice(0, cap)}\n… [truncated: ${cap.toLocaleString()} of ${content.length.toLocaleString()} chars shown]\n`;
|
|
974
|
+
}
|
|
975
|
+
export async function collectRepoInfo(targetDir, opts = {}) {
|
|
976
|
+
const redact = opts.redact ?? true;
|
|
977
|
+
const { files: allFiles, snapshot } = await listRepoFiles(targetDir);
|
|
978
|
+
// Named credential stores are out of every lens (isSlurpable); listed here
|
|
979
|
+
// so the submit report can say so.
|
|
980
|
+
const secretFilesSkipped = allFiles.filter((path) => isSecretFile(path)).sort();
|
|
981
|
+
let redactedValues = 0;
|
|
982
|
+
// The entry point, manifest, and README ride in the architecture prompt
|
|
983
|
+
// as text, so they get the same pass the slices do (#252).
|
|
984
|
+
const clean = (text) => {
|
|
985
|
+
if (!redact)
|
|
986
|
+
return text;
|
|
987
|
+
const redaction = redactSecrets(text);
|
|
988
|
+
redactedValues += redaction.count;
|
|
989
|
+
return redaction.text;
|
|
990
|
+
};
|
|
914
991
|
const fileCounts = {};
|
|
915
992
|
for (const f of allFiles) {
|
|
916
993
|
const slash = f.lastIndexOf("/");
|
|
@@ -923,25 +1000,37 @@ export async function collectRepoInfo(targetDir) {
|
|
|
923
1000
|
for (const [ext, n] of Object.entries(fileCounts).sort((a, b) => b[1] - a[1])) {
|
|
924
1001
|
sortedCounts[ext] = n;
|
|
925
1002
|
}
|
|
926
|
-
|
|
1003
|
+
// Every manifest present counts toward language detection; the first one
|
|
1004
|
+
// found is the one the architecture prompt shows.
|
|
1005
|
+
const manifestPaths = [];
|
|
927
1006
|
for (const [candidate] of MANIFEST_CANDIDATES) {
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
1007
|
+
if (await pathExists(join(targetDir, candidate)))
|
|
1008
|
+
manifestPaths.push(candidate);
|
|
1009
|
+
}
|
|
1010
|
+
const language = detectLanguage(sortedCounts, manifestPaths);
|
|
1011
|
+
// Show the manifest that belongs to the detected language when there is
|
|
1012
|
+
// one, so a polyglot repo's prompt does not open with the other stack's file.
|
|
1013
|
+
const manifestPath = manifestPaths.find((path) => MANIFEST_CANDIDATES.find(([candidate]) => candidate === path)?.[1].includes(language))
|
|
1014
|
+
?? manifestPaths[0]
|
|
1015
|
+
?? null;
|
|
1016
|
+
let manifest = null;
|
|
1017
|
+
if (manifestPath) {
|
|
1018
|
+
try {
|
|
1019
|
+
manifest = { path: manifestPath, content: capForPrompt(clean(await readFile(join(targetDir, manifestPath), "utf8")), REPO_INFO_FILE_CAP) };
|
|
1020
|
+
}
|
|
1021
|
+
catch {
|
|
1022
|
+
manifest = null;
|
|
937
1023
|
}
|
|
938
1024
|
}
|
|
1025
|
+
// Read whole and unbounded before, and then estimated at a flat 6,000
|
|
1026
|
+
// chars: a large entry point shipped in full while the cap was checked
|
|
1027
|
+
// against a number that had nothing to do with it (#249).
|
|
939
1028
|
let mainFile = "";
|
|
940
|
-
for (const candidate of ["main.go", "main.py", "src/main.rs", "src/index.ts", "index.ts"]) {
|
|
1029
|
+
for (const candidate of ["main.go", "main.py", "src/main.rs", "src/index.ts", "index.ts", "src/index.js", "index.js"]) {
|
|
941
1030
|
const p = join(targetDir, candidate);
|
|
942
1031
|
if (await pathExists(p)) {
|
|
943
1032
|
try {
|
|
944
|
-
mainFile = await readFile(p, "utf8");
|
|
1033
|
+
mainFile = capForPrompt(clean(await readFile(p, "utf8")), REPO_INFO_FILE_CAP);
|
|
945
1034
|
}
|
|
946
1035
|
catch {
|
|
947
1036
|
mainFile = "";
|
|
@@ -953,16 +1042,18 @@ export async function collectRepoInfo(targetDir) {
|
|
|
953
1042
|
const readmePath = join(targetDir, "README.md");
|
|
954
1043
|
if (await pathExists(readmePath)) {
|
|
955
1044
|
try {
|
|
956
|
-
readmeFirst = (await readFile(readmePath, "utf8")).slice(0, 4000);
|
|
1045
|
+
readmeFirst = clean((await readFile(readmePath, "utf8")).slice(0, 4000));
|
|
957
1046
|
}
|
|
958
1047
|
catch {
|
|
959
1048
|
readmeFirst = "";
|
|
960
1049
|
}
|
|
961
1050
|
}
|
|
962
1051
|
const fileTree = buildFileTree(allFiles);
|
|
963
|
-
|
|
964
|
-
|
|
1052
|
+
// An unknown language used to fall through to Go's globs, so the code
|
|
1053
|
+
// lenses matched nothing and the run paid for empty batches (#250).
|
|
1054
|
+
const sourceSpec = SOURCE_SPECS[language] ?? { glob: "", exts: [] };
|
|
965
1055
|
const name = targetDir.split(/[\\/]/).filter(Boolean).pop() ?? "repo";
|
|
1056
|
+
const sourceFiles = allFiles.filter((path) => isSlurpable(path) && sourceSpec.exts.some((ext) => path.toLowerCase().endsWith(ext))).length;
|
|
966
1057
|
return {
|
|
967
1058
|
name,
|
|
968
1059
|
path: targetDir,
|
|
@@ -974,6 +1065,10 @@ export async function collectRepoInfo(targetDir) {
|
|
|
974
1065
|
fileCounts: sortedCounts,
|
|
975
1066
|
sourceGlob: sourceSpec.glob,
|
|
976
1067
|
sourceExts: sourceSpec.exts,
|
|
1068
|
+
sourceFileCount: sourceFiles,
|
|
1069
|
+
snapshot,
|
|
1070
|
+
secretFilesSkipped,
|
|
1071
|
+
redactedValues,
|
|
977
1072
|
};
|
|
978
1073
|
}
|
|
979
1074
|
function buildFileTree(allFiles, maxDepth = 3, maxLines = 200) {
|
|
@@ -1034,6 +1129,9 @@ function matchesAnyGlob(path, globs) {
|
|
|
1034
1129
|
return false;
|
|
1035
1130
|
}
|
|
1036
1131
|
function isSlurpable(relPath) {
|
|
1132
|
+
// A credential store is never a lens input, whatever its globs say (#252).
|
|
1133
|
+
if (isSecretFile(relPath))
|
|
1134
|
+
return false;
|
|
1037
1135
|
const segments = relPath.split("/");
|
|
1038
1136
|
for (const seg of segments) {
|
|
1039
1137
|
if (SKIP_DIR_NAMES.has(seg))
|
|
@@ -1070,7 +1168,7 @@ function resolveSliceMode(lens, files, totalChars) {
|
|
|
1070
1168
|
return totalChars > lens.maxChars ? "directory" : "none";
|
|
1071
1169
|
}
|
|
1072
1170
|
function collectLensFiles(allFiles, lens, info) {
|
|
1073
|
-
const globs = lens.globsFor(info);
|
|
1171
|
+
const globs = lens.globsFor(info).filter(Boolean);
|
|
1074
1172
|
if (globs.length === 0)
|
|
1075
1173
|
return [];
|
|
1076
1174
|
const out = [];
|
|
@@ -1085,13 +1183,15 @@ function collectLensFiles(allFiles, lens, info) {
|
|
|
1085
1183
|
}
|
|
1086
1184
|
return out;
|
|
1087
1185
|
}
|
|
1088
|
-
async function slurpFileList(targetDir, files, maxChars) {
|
|
1186
|
+
async function slurpFileList(targetDir, files, maxChars, redact = true) {
|
|
1089
1187
|
const slices = [];
|
|
1090
1188
|
let currentModule = "";
|
|
1091
1189
|
let parts = [];
|
|
1092
1190
|
let running = 0;
|
|
1093
1191
|
let fileCount = 0;
|
|
1094
1192
|
let filePaths = [];
|
|
1193
|
+
let redactedValues = 0;
|
|
1194
|
+
let redactedFiles = [];
|
|
1095
1195
|
const flush = () => {
|
|
1096
1196
|
if (parts.length === 0)
|
|
1097
1197
|
return;
|
|
@@ -1101,20 +1201,38 @@ async function slurpFileList(targetDir, files, maxChars) {
|
|
|
1101
1201
|
fileCount,
|
|
1102
1202
|
chars: running,
|
|
1103
1203
|
files: filePaths,
|
|
1204
|
+
redactedValues,
|
|
1205
|
+
redactedFiles,
|
|
1104
1206
|
});
|
|
1105
1207
|
parts = [];
|
|
1106
1208
|
running = 0;
|
|
1107
1209
|
fileCount = 0;
|
|
1108
1210
|
filePaths = [];
|
|
1211
|
+
redactedValues = 0;
|
|
1212
|
+
redactedFiles = [];
|
|
1109
1213
|
};
|
|
1110
1214
|
for (const file of files) {
|
|
1111
1215
|
let content = "";
|
|
1112
1216
|
try {
|
|
1113
1217
|
content = await readFile(join(targetDir, file.relPath), "utf8");
|
|
1114
1218
|
}
|
|
1115
|
-
catch {
|
|
1219
|
+
catch (error) {
|
|
1220
|
+
// The listing is the working tree's, so this is a race with a
|
|
1221
|
+
// concurrent delete rather than a listed-but-deleted file; skip it.
|
|
1222
|
+
if (error.code === "ENOENT")
|
|
1223
|
+
continue;
|
|
1116
1224
|
content = "[BINARY or UNREADABLE]";
|
|
1117
1225
|
}
|
|
1226
|
+
if (redact) {
|
|
1227
|
+
// Before the slice is built, so the count and the chars the estimate
|
|
1228
|
+
// sees are of what is actually sent (#252).
|
|
1229
|
+
const redaction = redactSecrets(content);
|
|
1230
|
+
if (redaction.count > 0) {
|
|
1231
|
+
content = redaction.text;
|
|
1232
|
+
redactedValues += redaction.count;
|
|
1233
|
+
redactedFiles.push(file.relPath);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1118
1236
|
const block = `=== ${file.relPath} ===\n${content}\n`;
|
|
1119
1237
|
if (file.moduleName !== currentModule && parts.length > 0) {
|
|
1120
1238
|
flush();
|
|
@@ -1134,12 +1252,13 @@ async function slurpFileList(targetDir, files, maxChars) {
|
|
|
1134
1252
|
flush();
|
|
1135
1253
|
return slices;
|
|
1136
1254
|
}
|
|
1137
|
-
export async function gatherSlices(targetDir, lens, info) {
|
|
1255
|
+
export async function gatherSlices(targetDir, lens, info, opts = {}) {
|
|
1256
|
+
const redact = opts.redact ?? true;
|
|
1138
1257
|
if (lens.sliceBy === "none" && lens.globsFor(info).length === 0) {
|
|
1139
1258
|
// Repo-info lens (architecture): the prompt is built from info alone.
|
|
1140
1259
|
return [{ moduleName: "root", content: "", fileCount: 0, chars: 0, files: [] }];
|
|
1141
1260
|
}
|
|
1142
|
-
const allFiles = await listRepoFiles(targetDir);
|
|
1261
|
+
const { files: allFiles } = await listRepoFiles(targetDir);
|
|
1143
1262
|
const files = collectLensFiles(allFiles, lens, info);
|
|
1144
1263
|
const totalChars = await sumFileSizes(targetDir, files);
|
|
1145
1264
|
const mode = resolveSliceMode(lens, files, totalChars);
|
|
@@ -1147,9 +1266,9 @@ export async function gatherSlices(targetDir, lens, info) {
|
|
|
1147
1266
|
// Whole-repo slice: one module named after the repo, so a small
|
|
1148
1267
|
// repo produces a single request instead of one per directory.
|
|
1149
1268
|
const single = files.map((f) => ({ ...f, moduleName: info.name }));
|
|
1150
|
-
return slurpFileList(targetDir, single, lens.maxChars);
|
|
1269
|
+
return slurpFileList(targetDir, single, lens.maxChars, redact);
|
|
1151
1270
|
}
|
|
1152
|
-
return slurpFileList(targetDir, files, lens.maxChars);
|
|
1271
|
+
return slurpFileList(targetDir, files, lens.maxChars, redact);
|
|
1153
1272
|
}
|
|
1154
1273
|
async function sumFileSizes(targetDir, files) {
|
|
1155
1274
|
let total = 0;
|
|
@@ -1199,7 +1318,12 @@ export function buildBatchRequest(lens, info, slice, index, sliceCount, model =
|
|
|
1199
1318
|
* estimate covers slice content only, which is what the old signature did.
|
|
1200
1319
|
*/
|
|
1201
1320
|
export function estimateCost(lens, slices, pricing, maxTokensOverride, info) {
|
|
1202
|
-
|
|
1321
|
+
// With repo info, size each request from the user prompt that would be
|
|
1322
|
+
// sent, which is what the architecture lens is made of: it used to be
|
|
1323
|
+
// estimated at a flat 6,000 chars while the entry point, manifest, README
|
|
1324
|
+
// excerpt and file tree it carries ran to whatever they ran to (#249).
|
|
1325
|
+
// Without info, the slice content alone is what the old signature covered.
|
|
1326
|
+
const sliceChars = slices.reduce((sum, s) => sum + (info ? lens.userPrompt(info, s.content, s.moduleName).length : lens.maxChars === 0 ? 6000 : s.chars), 0);
|
|
1203
1327
|
// The system prompt and the response schema ride on every request, so they
|
|
1204
1328
|
// are paid once per slice rather than once per lens.
|
|
1205
1329
|
const perRequestOverhead = info
|
|
@@ -1393,6 +1517,7 @@ export async function loadBroadsideConfig(broadsideDir) {
|
|
|
1393
1517
|
includeSynthesis: flag("include_synthesis", true),
|
|
1394
1518
|
includeTriage: flag("include_triage", true),
|
|
1395
1519
|
waitSeconds: typeof raw.wait_seconds === "number" && raw.wait_seconds > 0 ? raw.wait_seconds : 0,
|
|
1520
|
+
redactSecrets: flag("redact_secrets", true),
|
|
1396
1521
|
};
|
|
1397
1522
|
}
|
|
1398
1523
|
async function readCatalogCache(broadsideDir) {
|
|
@@ -1491,20 +1616,37 @@ export async function resolveCatalogEntry(broadsideDir, config, model, apiKey, f
|
|
|
1491
1616
|
if (cached && Date.now() - new Date(cache.fetched_at).getTime() < BROADSIDE_CATALOG_CACHE_TTL_MS) {
|
|
1492
1617
|
return { model, source: "cache", entry: cached };
|
|
1493
1618
|
}
|
|
1619
|
+
// What went wrong when the live lookup produced nothing, for the error
|
|
1620
|
+
// below: a 401 and a dead network used to read the same — "could not
|
|
1621
|
+
// resolve per-token pricing" — or, for the default model, nothing at all.
|
|
1494
1622
|
let live = null;
|
|
1623
|
+
let catalogFailure = null;
|
|
1495
1624
|
try {
|
|
1496
1625
|
const resp = await fetcher(BROADSIDE_MODELS_URL, {
|
|
1497
1626
|
method: "GET",
|
|
1498
1627
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
1499
1628
|
signal: AbortSignal.timeout(30_000),
|
|
1500
1629
|
});
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1630
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
1631
|
+
throw new BroadsideAuthError(resp.status, await responseDetail(resp));
|
|
1632
|
+
}
|
|
1633
|
+
if (resp.ok === false) {
|
|
1634
|
+
catalogFailure = `the model catalog request failed (HTTP ${resp.status}${await responseDetail(resp).then((d) => (d ? `: ${d}` : ""))})`;
|
|
1635
|
+
}
|
|
1636
|
+
else {
|
|
1637
|
+
const data = (await resp.json());
|
|
1638
|
+
const hit = (data.data ?? []).find((m) => String(m.id) === model);
|
|
1639
|
+
if (hit)
|
|
1640
|
+
live = parseCatalogEntry(hit);
|
|
1641
|
+
else
|
|
1642
|
+
catalogFailure = `the model catalog has no entry for "${model}"`;
|
|
1643
|
+
}
|
|
1505
1644
|
}
|
|
1506
|
-
catch {
|
|
1645
|
+
catch (error) {
|
|
1646
|
+
if (error instanceof BroadsideAuthError)
|
|
1647
|
+
throw error;
|
|
1507
1648
|
live = null;
|
|
1649
|
+
catalogFailure = `the model catalog could not be fetched (${error instanceof Error ? error.message : String(error)})`;
|
|
1508
1650
|
}
|
|
1509
1651
|
if (live) {
|
|
1510
1652
|
const updated = {
|
|
@@ -1521,10 +1663,37 @@ export async function resolveCatalogEntry(broadsideDir, config, model, apiKey, f
|
|
|
1521
1663
|
const builtIn = builtInCatalogEntry(model);
|
|
1522
1664
|
if (builtIn)
|
|
1523
1665
|
return { model, source: "built-in", entry: builtIn };
|
|
1524
|
-
throw new Error(`Could not resolve per-token pricing for batch model "${model}". ` +
|
|
1666
|
+
throw new Error(`Could not resolve per-token pricing for batch model "${model}": ${catalogFailure ?? "no catalog entry"}. ` +
|
|
1525
1667
|
"Set pricing.input_per_m and pricing.output_per_m in .codecarto/broadside/config.yaml " +
|
|
1526
1668
|
"(USD per million tokens), or check the model id against https://openrouter.ai/models?variant=batch.");
|
|
1527
1669
|
}
|
|
1670
|
+
/** A short, safe excerpt of an error response body for a message. */
|
|
1671
|
+
async function responseDetail(resp) {
|
|
1672
|
+
try {
|
|
1673
|
+
if (typeof resp.text === "function") {
|
|
1674
|
+
const text = (await resp.text()).trim();
|
|
1675
|
+
try {
|
|
1676
|
+
const parsed = JSON.parse(text);
|
|
1677
|
+
const message = typeof parsed?.error === "string" ? parsed.error : parsed?.error?.message;
|
|
1678
|
+
if (typeof message === "string" && message)
|
|
1679
|
+
return message.slice(0, 200);
|
|
1680
|
+
}
|
|
1681
|
+
catch {
|
|
1682
|
+
// not JSON; fall through to the raw excerpt
|
|
1683
|
+
}
|
|
1684
|
+
return text.replace(/\s+/g, " ").slice(0, 200);
|
|
1685
|
+
}
|
|
1686
|
+
if (typeof resp.json === "function") {
|
|
1687
|
+
const parsed = (await resp.json());
|
|
1688
|
+
const message = typeof parsed?.error === "string" ? parsed.error : parsed?.error?.message;
|
|
1689
|
+
return typeof message === "string" ? message.slice(0, 200) : "";
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
catch {
|
|
1693
|
+
// an unreadable body adds nothing to the message
|
|
1694
|
+
}
|
|
1695
|
+
return "";
|
|
1696
|
+
}
|
|
1528
1697
|
export async function resolveModelPricing(broadsideDir, config, model, apiKey, fetcher = fetch) {
|
|
1529
1698
|
const { source, entry } = await resolveCatalogEntry(broadsideDir, config, model, apiKey, fetcher);
|
|
1530
1699
|
if (!entry)
|
|
@@ -1618,7 +1787,17 @@ export async function fetchBatch(batchId, apiKey, fetcher = fetch) {
|
|
|
1618
1787
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
1619
1788
|
signal: AbortSignal.timeout(30_000),
|
|
1620
1789
|
});
|
|
1621
|
-
|
|
1790
|
+
let data;
|
|
1791
|
+
try {
|
|
1792
|
+
data = (await resp.json());
|
|
1793
|
+
}
|
|
1794
|
+
catch (error) {
|
|
1795
|
+
// A gateway error page is not JSON. It used to throw out of here and
|
|
1796
|
+
// be retried as if the network were down; keep the status instead.
|
|
1797
|
+
data = { error: `non-JSON response (${error instanceof Error ? error.message : String(error)})` };
|
|
1798
|
+
}
|
|
1799
|
+
if (!data || typeof data !== "object")
|
|
1800
|
+
data = { error: "empty response" };
|
|
1622
1801
|
// Surface the HTTP status so the poller can bail fast on auth expiry
|
|
1623
1802
|
// instead of retrying a dead key for the whole budget.
|
|
1624
1803
|
data.http_status = resp.status;
|
|
@@ -1636,14 +1815,26 @@ export async function pollBatchUntilTerminal(batchId, apiKey, opts = {}) {
|
|
|
1636
1815
|
const deadline = Date.now() + (opts.deadlineMs ?? BROADSIDE_DEFAULT_POLL_BUDGET_MS);
|
|
1637
1816
|
const intervalMs = opts.pollIntervalMs ?? BROADSIDE_POLL_INTERVAL_MS;
|
|
1638
1817
|
const fetcher = opts.fetcher ?? fetch;
|
|
1818
|
+
// A poll that runs out of budget without one good response is not a slow
|
|
1819
|
+
// batch. The last thing that went wrong rides on the timeout so the report
|
|
1820
|
+
// can tell a dead network or a failing gateway from a batch still running.
|
|
1821
|
+
let lastError = null;
|
|
1822
|
+
let sawBatch = false;
|
|
1823
|
+
const timedOut = () => ({
|
|
1824
|
+
id: batchId,
|
|
1825
|
+
status: "timeout",
|
|
1826
|
+
...(lastError && !sawBatch && { error: `no successful poll response; last error: ${lastError}` }),
|
|
1827
|
+
...(lastError && sawBatch && { last_error: lastError }),
|
|
1828
|
+
});
|
|
1639
1829
|
for (;;) {
|
|
1640
1830
|
let batch;
|
|
1641
1831
|
try {
|
|
1642
1832
|
batch = await fetchBatch(batchId, apiKey, fetcher);
|
|
1643
1833
|
}
|
|
1644
|
-
catch {
|
|
1834
|
+
catch (error) {
|
|
1835
|
+
lastError = `fetch failed (${error instanceof Error ? error.message : String(error)})`;
|
|
1645
1836
|
if (Date.now() >= deadline)
|
|
1646
|
-
return
|
|
1837
|
+
return timedOut();
|
|
1647
1838
|
await sleep(intervalMs);
|
|
1648
1839
|
continue;
|
|
1649
1840
|
}
|
|
@@ -1651,13 +1842,23 @@ export async function pollBatchUntilTerminal(batchId, apiKey, opts = {}) {
|
|
|
1651
1842
|
if (httpStatus === 401 || httpStatus === 403) {
|
|
1652
1843
|
return { id: batchId, status: "auth-failed", error: batch.error ?? batch };
|
|
1653
1844
|
}
|
|
1845
|
+
if (httpStatus >= 400) {
|
|
1846
|
+
// A gateway or server error: retry within the budget, remembered.
|
|
1847
|
+
const detail = typeof batch.error === "string" ? batch.error : JSON.stringify(batch.error ?? "");
|
|
1848
|
+
lastError = `HTTP ${httpStatus}${detail ? ` (${detail.slice(0, 200)})` : ""}`;
|
|
1849
|
+
if (Date.now() >= deadline)
|
|
1850
|
+
return timedOut();
|
|
1851
|
+
await sleep(intervalMs);
|
|
1852
|
+
continue;
|
|
1853
|
+
}
|
|
1854
|
+
sawBatch = true;
|
|
1654
1855
|
const status = String(batch.status ?? "unknown");
|
|
1655
1856
|
const counts = (batch.request_counts ?? {});
|
|
1656
1857
|
opts.onStatus?.(status, counts);
|
|
1657
1858
|
if (status === "completed" || BROADSIDE_DEAD_BATCH_STATUSES.includes(status))
|
|
1658
1859
|
return batch;
|
|
1659
1860
|
if (Date.now() >= deadline)
|
|
1660
|
-
return
|
|
1861
|
+
return timedOut();
|
|
1661
1862
|
await sleep(intervalMs);
|
|
1662
1863
|
}
|
|
1663
1864
|
}
|
|
@@ -1684,7 +1885,6 @@ export async function pollBatchesConcurrently(entries, apiKey, opts = {}) {
|
|
|
1684
1885
|
}
|
|
1685
1886
|
// ---------- run orchestration ----------
|
|
1686
1887
|
export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
1687
|
-
const info = await collectRepoInfo(cwd);
|
|
1688
1888
|
const lensIds = opts.lenses ?? BROADSIDE_LENS_IDS;
|
|
1689
1889
|
const broadsideDir = broadsideDirFor(cwd);
|
|
1690
1890
|
const model = opts.model ?? BROADSIDE_MODEL;
|
|
@@ -1693,6 +1893,19 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
|
1693
1893
|
// structured-output support that not all batch models offer. Lenses may run
|
|
1694
1894
|
// on different models (config `lens_models`), so each one is pre-flighted.
|
|
1695
1895
|
const config = await loadBroadsideConfig(broadsideDir);
|
|
1896
|
+
const redact = config.redactSecrets;
|
|
1897
|
+
const info = await collectRepoInfo(cwd, { redact });
|
|
1898
|
+
// Before pricing, before the network, before any state write: a run on a
|
|
1899
|
+
// language the lenses cannot scan used to submit empty batches and pay for
|
|
1900
|
+
// them (#250).
|
|
1901
|
+
if (info.language === "unknown") {
|
|
1902
|
+
throw new Error(`Broad-Side could not tell what language this repository is: no ${MANIFEST_CANDIDATES.map(([candidate]) => candidate).join(", ")} ` +
|
|
1903
|
+
`and no source files in a language the lenses can scan (${BROADSIDE_LANGUAGES.join(", ")}). Nothing was submitted.`);
|
|
1904
|
+
}
|
|
1905
|
+
if (info.sourceFileCount === 0) {
|
|
1906
|
+
throw new Error(`Broad-Side found no ${info.language} source files to scan (detected from ${info.manifest?.path ?? "the file counts"}; ` +
|
|
1907
|
+
`the lenses look for ${info.sourceExts.join(", ")}). Nothing was submitted.`);
|
|
1908
|
+
}
|
|
1696
1909
|
const modelForLens = (lensId) => config.lensModels[lensId] ?? model;
|
|
1697
1910
|
const resolved = new Map();
|
|
1698
1911
|
for (const candidate of new Set([model, ...lensIds.map(modelForLens)])) {
|
|
@@ -1758,9 +1971,19 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
|
1758
1971
|
let estimatedOutputTokens = 0;
|
|
1759
1972
|
let estimatedTotalCost = 0;
|
|
1760
1973
|
const perLensEstimate = [];
|
|
1974
|
+
// What the redaction pass did across every lens's slices, for the run
|
|
1975
|
+
// record and the report: a value in a file shared by two lenses counts
|
|
1976
|
+
// once per lens it was sent in, files once each.
|
|
1977
|
+
let redactedValues = info.redactedValues;
|
|
1978
|
+
const redactedFiles = new Set();
|
|
1761
1979
|
for (const lensId of lensIds) {
|
|
1762
1980
|
const lens = getLens(lensId);
|
|
1763
|
-
let slices = await gatherSlices(cwd, lens, info);
|
|
1981
|
+
let slices = await gatherSlices(cwd, lens, info, { redact });
|
|
1982
|
+
for (const slice of slices) {
|
|
1983
|
+
redactedValues += slice.redactedValues ?? 0;
|
|
1984
|
+
for (const file of slice.redactedFiles ?? [])
|
|
1985
|
+
redactedFiles.add(file);
|
|
1986
|
+
}
|
|
1764
1987
|
if (changed) {
|
|
1765
1988
|
// Repo-info slices (empty files, e.g. architecture) always run;
|
|
1766
1989
|
// file-backed slices run only when one of their files changed.
|
|
@@ -1837,6 +2060,14 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
|
1837
2060
|
sourceHead,
|
|
1838
2061
|
sourceDirty,
|
|
1839
2062
|
baseHead,
|
|
2063
|
+
snapshot: info.snapshot,
|
|
2064
|
+
language: info.language,
|
|
2065
|
+
redaction: {
|
|
2066
|
+
enabled: redact,
|
|
2067
|
+
values: redactedValues,
|
|
2068
|
+
files: redactedFiles.size,
|
|
2069
|
+
skippedFiles: info.secretFilesSkipped.length,
|
|
2070
|
+
},
|
|
1840
2071
|
};
|
|
1841
2072
|
state.runs.push(run);
|
|
1842
2073
|
await persistBroadsideRun(broadsideDir, run);
|
|
@@ -1915,6 +2146,19 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
|
|
|
1915
2146
|
expirationDate: defaultEntry.expirationDate ?? null,
|
|
1916
2147
|
},
|
|
1917
2148
|
incremental: incrementalOutcome,
|
|
2149
|
+
repo: {
|
|
2150
|
+
language: info.language,
|
|
2151
|
+
sourceFiles: info.sourceFileCount,
|
|
2152
|
+
snapshot: info.snapshot,
|
|
2153
|
+
sourceHead,
|
|
2154
|
+
sourceDirty,
|
|
2155
|
+
},
|
|
2156
|
+
redaction: {
|
|
2157
|
+
enabled: redact,
|
|
2158
|
+
values: redactedValues,
|
|
2159
|
+
files: redactedFiles.size,
|
|
2160
|
+
skippedFiles: info.secretFilesSkipped,
|
|
2161
|
+
},
|
|
1918
2162
|
};
|
|
1919
2163
|
}
|
|
1920
2164
|
function extractContent(result) {
|
|
@@ -2187,7 +2431,8 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2187
2431
|
// indistinguishable in the output from one that was never requested.
|
|
2188
2432
|
if (batch.error)
|
|
2189
2433
|
entry.error = batch.error;
|
|
2190
|
-
|
|
2434
|
+
const error = describeBatchError(batch.error);
|
|
2435
|
+
lensOutcomes[lensId] = { status, cost: entry.cost, resultCount: entry.resultCount, ...(error && { error }) };
|
|
2191
2436
|
}
|
|
2192
2437
|
await persistBroadsideRun(broadsideDir, run);
|
|
2193
2438
|
}
|
|
@@ -2534,6 +2779,18 @@ export function estimateSubmitText(result, lenses) {
|
|
|
2534
2779
|
const override = entry.model ? ` on ${entry.model}` : "";
|
|
2535
2780
|
lines.push(` ${lens.name}: ${status} (${entry.requests} request(s), ~$${entry.estimatedCost.toFixed(4)})${override}`);
|
|
2536
2781
|
}
|
|
2782
|
+
if (result.repo) {
|
|
2783
|
+
const head = result.repo.sourceHead ? ` at ${result.repo.sourceHead.slice(0, 8)}${result.repo.sourceDirty ? " (dirty)" : ""}` : "";
|
|
2784
|
+
const source = result.repo.snapshot === "working-tree" ? `working tree${head}` : "directory walk (not a git repository)";
|
|
2785
|
+
lines.push(`Scanned as ${result.repo.language}: ${result.repo.sourceFiles} source file(s) from the ${source}.`);
|
|
2786
|
+
}
|
|
2787
|
+
if (result.redaction) {
|
|
2788
|
+
const line = result.redaction.enabled
|
|
2789
|
+
? describeRedactions(result.redaction.values, result.redaction.files, result.redaction.skippedFiles)
|
|
2790
|
+
: "Before upload: secret redaction is OFF (redact_secrets: false in config.yaml); files were sent as they are.";
|
|
2791
|
+
if (line)
|
|
2792
|
+
lines.push(line);
|
|
2793
|
+
}
|
|
2537
2794
|
const incremental = result.incremental;
|
|
2538
2795
|
if (incremental?.requested) {
|
|
2539
2796
|
lines.push(incremental.applied
|
|
@@ -2586,6 +2843,25 @@ export function modelsText(entries, opts) {
|
|
|
2586
2843
|
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
2844
|
return lines.join("\n");
|
|
2588
2845
|
}
|
|
2846
|
+
/** One line of a batch's error field, whatever shape the provider gave it. */
|
|
2847
|
+
function describeBatchError(error) {
|
|
2848
|
+
if (error === undefined || error === null || error === "")
|
|
2849
|
+
return null;
|
|
2850
|
+
if (typeof error === "string")
|
|
2851
|
+
return error.slice(0, 300);
|
|
2852
|
+
if (typeof error === "object") {
|
|
2853
|
+
const message = error.message;
|
|
2854
|
+
if (typeof message === "string" && message)
|
|
2855
|
+
return message.slice(0, 300);
|
|
2856
|
+
try {
|
|
2857
|
+
return JSON.stringify(error).slice(0, 300);
|
|
2858
|
+
}
|
|
2859
|
+
catch {
|
|
2860
|
+
return String(error);
|
|
2861
|
+
}
|
|
2862
|
+
}
|
|
2863
|
+
return String(error);
|
|
2864
|
+
}
|
|
2589
2865
|
export function collectResultText(result) {
|
|
2590
2866
|
const lines = [
|
|
2591
2867
|
`Broad-Side run ${result.runId}: ${result.status}`,
|
|
@@ -2599,7 +2875,10 @@ export function collectResultText(result) {
|
|
|
2599
2875
|
lines.push(` ${lensId}: ${outcome.status}` +
|
|
2600
2876
|
(outcome.cost !== undefined ? `, $${outcome.cost.toFixed(6)}` : "") +
|
|
2601
2877
|
(outcome.resultCount !== undefined ? `, ${outcome.resultCount} result(s)` : "") +
|
|
2602
|
-
truncation
|
|
2878
|
+
truncation +
|
|
2879
|
+
// The reason a lens did not complete, when the poll recorded one:
|
|
2880
|
+
// an auth failure or a dead network used to read as a slow batch.
|
|
2881
|
+
(outcome.error ? ` — ${outcome.error}` : ""));
|
|
2603
2882
|
}
|
|
2604
2883
|
if (result.retriedCount > 0) {
|
|
2605
2884
|
lines.push(` ↻ ${result.retriedCount} truncated result(s) recovered by re-submission with a doubled output cap.`);
|
|
@@ -2639,6 +2918,12 @@ export function statusText(state) {
|
|
|
2639
2918
|
const lines = [];
|
|
2640
2919
|
for (const run of [...state.runs].reverse().slice(0, 3)) {
|
|
2641
2920
|
lines.push(`Run ${run.id} — ${run.status}`);
|
|
2921
|
+
// Recorded since #248; a run from an older version has neither field.
|
|
2922
|
+
if (run.language || run.snapshot) {
|
|
2923
|
+
const head = run.sourceHead ? ` at ${run.sourceHead.slice(0, 8)}${run.sourceDirty ? " (dirty)" : ""}` : "";
|
|
2924
|
+
const source = run.snapshot === "walk" ? "directory walk" : run.snapshot ? `working tree${head}` : "unknown source";
|
|
2925
|
+
lines.push(` scanned as ${run.language ?? "unknown"} from the ${source}`);
|
|
2926
|
+
}
|
|
2642
2927
|
for (const lensId of BROADSIDE_LENS_IDS) {
|
|
2643
2928
|
const entry = run.batches[lensId];
|
|
2644
2929
|
if (!entry)
|