acuvo-code 0.2.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/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
package/lib/report.mjs
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐ MACHINE-READABLE OUTPUT, AND A DIFF OF WHAT ACTUALLY CHANGED ──────────
|
|
3
|
+
*
|
|
4
|
+
* Two gaps the MVP plan named, and they are the same gap seen twice: the CLI
|
|
5
|
+
* tells you WHICH files it touched and never WHAT it did to them, and it says
|
|
6
|
+
* so only in prose a script cannot read.
|
|
7
|
+
*
|
|
8
|
+
* ── ⚠️ WHY "3 files written" IS NOT ENOUGH ──────────────────────────────────
|
|
9
|
+
* It is the difference between trusting the agent and verifying it. A user who
|
|
10
|
+
* has to open three files and read them has not been given a report; they have
|
|
11
|
+
* been given homework. And the one place this matters most is the case where
|
|
12
|
+
* the agent quietly deleted something while making an unrelated change —
|
|
13
|
+
* exactly what `edit_file` exists to prevent and exactly what a file list
|
|
14
|
+
* cannot show.
|
|
15
|
+
*
|
|
16
|
+
* ⭐ So: line counts per file, and for a REPLACED file, what it looked like
|
|
17
|
+
* before. Not a full unified diff — a terminal report that runs off the screen
|
|
18
|
+
* is one nobody reads — but enough that "it rewrote 200 lines of a 210-line
|
|
19
|
+
* file" is visible at a glance, because that is usually a mistake.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Count lines the way an editor does: a trailing newline is not a line. */
|
|
23
|
+
function lineCount(text) {
|
|
24
|
+
if (!text) return 0;
|
|
25
|
+
const n = String(text).split('\n').length;
|
|
26
|
+
return String(text).endsWith('\n') ? n - 1 : n;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Summarise one write.
|
|
31
|
+
*
|
|
32
|
+
* ⚠️ WORKS FROM THE RESULT OBJECT, NOT FROM DISK. Re-reading the file here
|
|
33
|
+
* would report whatever it looks like NOW — including changes a later round, or
|
|
34
|
+
* another parallel task, made afterwards. The result is what this write did.
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* ── ⭐⭐ ONE RECORD CAN NAME MANY FILES, AND THIS FILE ASSUMED IT NAMED ONE ──
|
|
38
|
+
*
|
|
39
|
+
* `describeChange` is one-record-in, one-change-out, and that was true when the
|
|
40
|
+
* only writers were `write_file` and `edit_file`. It stopped being true twice:
|
|
41
|
+
*
|
|
42
|
+
* · `write_files` reports `written[{path,bytes,created}]` and has NO
|
|
43
|
+
* top-level `path`. MEASURED — `describeChange` on a 2-file bulk write
|
|
44
|
+
* returns `{"tool":"write_files","bytes":0,"previousBytes":0,
|
|
45
|
+
* "kind":"replaced"}`: no path at all, and the summary printed a line
|
|
46
|
+
* naming nothing.
|
|
47
|
+
* · `delegate` with `write: true` reports the same shape, and a real run
|
|
48
|
+
* printed `replaced src/calc.test.mjs (0 bytes)` for a file CREATED at
|
|
49
|
+
* 510 bytes.
|
|
50
|
+
*
|
|
51
|
+
* ⭐ SO THE PLURAL IS THE FRONT DOOR NOW. `describeChange` is kept, unchanged
|
|
52
|
+
* in behaviour for every single-file shape, because it is exported and pinned
|
|
53
|
+
* by seven existing assertions — rewriting those to fit a new signature would
|
|
54
|
+
* be changing the tests to suit the change, which is how a real guarantee gets
|
|
55
|
+
* quietly relaxed.
|
|
56
|
+
*
|
|
57
|
+
* @param {any} record
|
|
58
|
+
* @returns {any[]} one entry per file this record actually touched
|
|
59
|
+
*/
|
|
60
|
+
export function describeChanges(record) {
|
|
61
|
+
const many = record?.result?.written;
|
|
62
|
+
if (Array.isArray(many) && many.length > 0) {
|
|
63
|
+
return many.map((f) => ({
|
|
64
|
+
path: f.path,
|
|
65
|
+
tool: record.name,
|
|
66
|
+
bytes: f.bytes ?? 0,
|
|
67
|
+
previousBytes: f.previousBytes ?? 0,
|
|
68
|
+
/**
|
|
69
|
+
* ⚠️ `deleted` FIRST. A delegated build can remove a file, and a
|
|
70
|
+
* deletion reported as a "replaced with 0 bytes" is the summary telling
|
|
71
|
+
* someone their file was BLANKED — the exact wording `tools.mjs:980`
|
|
72
|
+
* records as having frightened a reader once already.
|
|
73
|
+
*/
|
|
74
|
+
kind: f.deleted === true ? 'deleted' : (f.created === true ? 'created' : 'replaced'),
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
return [describeChange(record)];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function describeChange(record) {
|
|
81
|
+
const r = record?.result ?? {};
|
|
82
|
+
/**
|
|
83
|
+
* ⚠️ `lines` IS OMITTED WHEN WE DO NOT HAVE THE CONTENT, and the first version
|
|
84
|
+
* reported `lines: 0` for every write — because `write_file`'s result carries
|
|
85
|
+
* a byte count and a path, not the text. A confidently wrong zero in a
|
|
86
|
+
* machine-readable document is worse than an absent field: a script can check
|
|
87
|
+
* for `undefined`, and it cannot know that a 0 is a lie.
|
|
88
|
+
*/
|
|
89
|
+
const after = r.content === undefined ? null : lineCount(r.content);
|
|
90
|
+
const base = {
|
|
91
|
+
// ⚠️ `mutatedPath` WINS. A tool whose subject is not the file it wrote —
|
|
92
|
+
// `see_page` reads a page and writes a screenshot — otherwise reports a
|
|
93
|
+
// write against the file it only looked at. See the note in tools.mjs.
|
|
94
|
+
path: record.mutatedPath ?? r.path,
|
|
95
|
+
tool: record.name,
|
|
96
|
+
bytes: r.bytes ?? r.screenshotBytes ?? 0,
|
|
97
|
+
previousBytes: r.previousBytes ?? 0,
|
|
98
|
+
};
|
|
99
|
+
if (record.name === 'delete_file') return { ...base, kind: 'deleted' };
|
|
100
|
+
// A screenshot is always a new file — never a replacement of the page it shows.
|
|
101
|
+
if (record.name === 'see_page') return { ...base, kind: 'created' };
|
|
102
|
+
if (r.created) return { ...base, kind: 'created', ...(after === null ? {} : { lines: after }) };
|
|
103
|
+
if (record.name === 'edit_file') {
|
|
104
|
+
return {
|
|
105
|
+
...base,
|
|
106
|
+
kind: 'edited',
|
|
107
|
+
// ⭐ The proportion is the signal. `replacedChars` against `fileChars`
|
|
108
|
+
// answers "was this surgery or a rewrite" without printing the file.
|
|
109
|
+
replacedChars: r.replacedChars ?? 0,
|
|
110
|
+
fileChars: r.fileChars ?? 0,
|
|
111
|
+
share: r.fileChars ? Math.min(1, (r.replacedChars ?? 0) / r.fileChars) : 0,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return { ...base, kind: 'replaced', ...(after === null ? {} : { lines: after }) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* ⚠️ THE LINE THAT SHOULD MAKE SOMEONE LOOK. A whole-file rewrite of an
|
|
119
|
+
* existing file is how code silently disappears — the model re-emits what it
|
|
120
|
+
* remembers and drops what it did not think to include. The file still parses,
|
|
121
|
+
* the tests may still pass, and nobody notices until the missing thing was
|
|
122
|
+
* load-bearing.
|
|
123
|
+
*/
|
|
124
|
+
export function rewriteWarnings(changes) {
|
|
125
|
+
return changes
|
|
126
|
+
.filter((c) => c.kind === 'replaced' && c.previousBytes > 400 && c.bytes < c.previousBytes * 0.6)
|
|
127
|
+
.map((c) => `${c.path} shrank from ${c.previousBytes} to ${c.bytes} bytes — check nothing was dropped`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Render the change list for a human. */
|
|
131
|
+
export function formatChanges(changes, { paint = null } = {}) {
|
|
132
|
+
if (changes.length === 0) return [];
|
|
133
|
+
const p = paint ?? { gold: (t) => t, dim: (t) => t, red: (t) => t };
|
|
134
|
+
const lines = [];
|
|
135
|
+
for (const c of changes) {
|
|
136
|
+
if (c.kind === 'deleted') { lines.push(` ${p.gold('deleted ')} ${c.path} ${p.dim(`(${c.bytes} bytes)`)}`); continue; }
|
|
137
|
+
if (c.kind === 'edited') {
|
|
138
|
+
const pct = Math.round(c.share * 100);
|
|
139
|
+
lines.push(` ${p.gold('edited ')} ${c.path} ${p.dim(`(${c.replacedChars} of ${c.fileChars} chars · ${pct}%)`)}`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const delta = c.kind === 'replaced' && c.previousBytes
|
|
143
|
+
? ` · was ${c.previousBytes}`
|
|
144
|
+
: '';
|
|
145
|
+
const size = c.lines === undefined ? `${c.bytes} bytes${delta}` : `${c.lines} lines, ${c.bytes} bytes${delta}`;
|
|
146
|
+
lines.push(` ${p.gold(`${c.kind === 'created' ? 'created ' : 'replaced '}`)} ${c.path} ${p.dim(`(${size})`)}`);
|
|
147
|
+
}
|
|
148
|
+
for (const w of rewriteWarnings(changes)) lines.push(` ${p.red('⚠')} ${w}`);
|
|
149
|
+
return lines;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* ── ⭐ `--json`: ONE OBJECT, ON STDOUT, AND NOTHING ELSE ────────────────────
|
|
154
|
+
*
|
|
155
|
+
* ⚠️ THE CONTRACT IS THE WHOLE VALUE. A script that has to grep prose is a
|
|
156
|
+
* script that breaks the next time we improve a sentence — and we have improved
|
|
157
|
+
* several today. So this shape is stable, and every human-facing line goes to
|
|
158
|
+
* stderr when `--json` is on, leaving stdout parseable by `jq` with no flags.
|
|
159
|
+
*
|
|
160
|
+
* ⚠️ AND IT MUST NEVER CARRY COLOUR. An escape code inside a JSON string is
|
|
161
|
+
* valid JSON and completely useless — the same class of bug that once had the
|
|
162
|
+
* bench parse a cost of $7.50 out of a fixture's test name.
|
|
163
|
+
*/
|
|
164
|
+
export function toJson(outcome, { changes = [], task = null } = {}) {
|
|
165
|
+
const v = outcome?.verification ?? {};
|
|
166
|
+
return {
|
|
167
|
+
ok: outcome?.ok !== false,
|
|
168
|
+
task,
|
|
169
|
+
model: outcome?.model ?? null,
|
|
170
|
+
rounds: outcome?.roundsUsed ?? 0,
|
|
171
|
+
stoppedBecause: outcome?.stoppedBecause ?? null,
|
|
172
|
+
// ⭐ `ran` and `passed` stay SEPARATE, as everywhere else in this codebase.
|
|
173
|
+
// Collapsing them is how a loop reports success on a failing test.
|
|
174
|
+
verification: {
|
|
175
|
+
ran: v.ran === true,
|
|
176
|
+
passed: v.passed === true,
|
|
177
|
+
command: v.command ?? null,
|
|
178
|
+
exitCode: v.exitCode ?? null,
|
|
179
|
+
attempts: v.attempts ?? 0,
|
|
180
|
+
},
|
|
181
|
+
/**
|
|
182
|
+
* ── ⚠️⚠️ THE CRITERION THE USER NAMED, OR THE DOCUMENT LIES BY OMISSION ──
|
|
183
|
+
*
|
|
184
|
+
* `verification` answers "did SOMETHING this process ran exit 0". Acceptance
|
|
185
|
+
* answers "was it the thing you asked for", and until this field existed the
|
|
186
|
+
* second answer never left the terminal. Measured: a run whose declared
|
|
187
|
+
* `npm test` was unmet emitted `verification.passed:true` with nothing in
|
|
188
|
+
* the document to say a named criterion had been missed — and the pipeline
|
|
189
|
+
* acceptance.mjs's own header calls out, `acuvo --json | jq
|
|
190
|
+
* '.verification.passed'`, read green.
|
|
191
|
+
*
|
|
192
|
+
* ⚠️ `gating` IS PART OF THE SHAPE, not a footnote. A DECLARED criterion
|
|
193
|
+
* decides the exit code; a DERIVED one is this runner's reading of the
|
|
194
|
+
* user's prose and reports only. A consumer that cannot tell them apart
|
|
195
|
+
* either ignores both or trusts both, and both are wrong.
|
|
196
|
+
*
|
|
197
|
+
* ⭐ `null` WHEN NOBODY NAMED ONE, which is most runs — an always-present
|
|
198
|
+
* object with empty fields would make "no criterion" and "a criterion that
|
|
199
|
+
* found nothing" the same document.
|
|
200
|
+
*/
|
|
201
|
+
acceptance: outcome?.acceptance
|
|
202
|
+
? {
|
|
203
|
+
source: outcome.acceptance.source ?? null,
|
|
204
|
+
gating: outcome.acceptance.gating === true,
|
|
205
|
+
verdict: outcome.acceptance.verdict?.verdict ?? null,
|
|
206
|
+
unmet: (outcome.acceptance.verdict?.unmet ?? []).map((u) => ({
|
|
207
|
+
command: u?.command ?? null,
|
|
208
|
+
why: u?.why ?? null,
|
|
209
|
+
})),
|
|
210
|
+
}
|
|
211
|
+
: null,
|
|
212
|
+
changes,
|
|
213
|
+
/**
|
|
214
|
+
* ── ⚠️⚠️ THE ANSWER ITSELF WAS NEVER IN THE MACHINE-READABLE DOCUMENT ────
|
|
215
|
+
*
|
|
216
|
+
* `note` is what the agent actually SAID — the reply a person reads to learn
|
|
217
|
+
* what happened. It reached the terminal and stopped there, so anything
|
|
218
|
+
* driving this tool through `--json` got the changes, the cost and the
|
|
219
|
+
* verdict, and never the answer. Measured 2026-08-14: the field was absent
|
|
220
|
+
* from the document on EVERY stop reason, not just the truncated ones.
|
|
221
|
+
*
|
|
222
|
+
* ⭐ This is the same defect as the cache reading and the compaction count
|
|
223
|
+
* before it: computed on every run, returned on the outcome, and dropped at
|
|
224
|
+
* the one line where it would have become visible. The pattern is that the
|
|
225
|
+
* human summary is rich and the machine document is thin — which is the
|
|
226
|
+
* wrong way round for a tool sold on being scriptable.
|
|
227
|
+
*/
|
|
228
|
+
note: outcome?.note ?? null,
|
|
229
|
+
/**
|
|
230
|
+
* ⚠️ THE FILES THE REPLY CLAIMED AND THE DISK DOES NOT HAVE. The human
|
|
231
|
+
* summary prints this in bold; a script could not see it at all. It is the
|
|
232
|
+
* difference between "the agent says it wrote your migration" and "your
|
|
233
|
+
* migration exists", and a gate that cannot read it has to trust prose.
|
|
234
|
+
*/
|
|
235
|
+
promisedButMissing: outcome?.promisedButMissing ?? [],
|
|
236
|
+
/**
|
|
237
|
+
* ⭐ WITHOUT THE DENOMINATOR, `rounds` CANNOT DETECT A TRUNCATED RUN.
|
|
238
|
+
* `rounds: 2` reads as a tidy little session; `rounds: 2, maxRounds: 2`
|
|
239
|
+
* means it was cut off with work outstanding. The document already carries
|
|
240
|
+
* `stoppedBecause: 'round-cap'`, but a consumer should not have to know our
|
|
241
|
+
* internal vocabulary to spot the most common way a run ends early.
|
|
242
|
+
*/
|
|
243
|
+
maxRounds: outcome?.maxRounds ?? null,
|
|
244
|
+
/**
|
|
245
|
+
* ⚠️ THE PROVIDER'S OWN STOP REASON, which is not ours. `length` means the
|
|
246
|
+
* model was cut off mid-sentence — a truncated answer that our own
|
|
247
|
+
* `stoppedBecause` will happily call `no-tool-calls`.
|
|
248
|
+
*/
|
|
249
|
+
finishReason: outcome?.finishReason ?? null,
|
|
250
|
+
/**
|
|
251
|
+
* ⭐ Context for reading `verification.ran: false`. A run that COULD not
|
|
252
|
+
* execute anything and a run that CHOSE not to verify are different facts,
|
|
253
|
+
* and without this they look identical from outside.
|
|
254
|
+
*/
|
|
255
|
+
allowRun: outcome?.allowRun ?? null,
|
|
256
|
+
// A number a caller can budget against, not a sentence about money.
|
|
257
|
+
costUsd: outcome?.usage?.cost ?? null,
|
|
258
|
+
tokens: outcome?.usage?.total_tokens ?? null,
|
|
259
|
+
/**
|
|
260
|
+
* ── ⭐ THE HIT RATE, COMPUTED ON EVERY RUN AND NEVER ALLOWED OUT ──────────
|
|
261
|
+
*
|
|
262
|
+
* `aggregateCache` has run on every session since it was written and its
|
|
263
|
+
* result was dropped right here, so the one number that explains why two
|
|
264
|
+
* identical-looking runs cost 3x apart could not be read from outside the
|
|
265
|
+
* process. Every caching claim this project has made was therefore belief
|
|
266
|
+
* rather than evidence — including the ones in its own documentation.
|
|
267
|
+
*
|
|
268
|
+
* ⚠️ `null` WHEN NO ROUND REPORTED, never a zeroed object. "The provider
|
|
269
|
+
* said nothing about caching" and "the provider cached nothing" are
|
|
270
|
+
* different facts, and a consumer reading `hitRate: 0` cannot tell them
|
|
271
|
+
* apart. `roundsUnknown` travels for the same reason: a rate measured over
|
|
272
|
+
* 2 of 6 rounds is partial, and the reader has to be able to see that.
|
|
273
|
+
*/
|
|
274
|
+
/**
|
|
275
|
+
* ⭐⭐ `firstRound` IS THE FLOOR, AND IT IS THE ONE THE PRICING IS SIZED ON.
|
|
276
|
+
* `lib/plan.mjs` states the cache rate *is* the margin and assumes a 90%
|
|
277
|
+
* floor; the floor is a claim about a FRESH invocation's round 1, which is
|
|
278
|
+
* the only round our prefix discipline controls. The session `hitRate` above
|
|
279
|
+
* blends it with rounds 2+, whose misses are appended-and-therefore-new
|
|
280
|
+
* tokens — arithmetic, not a defect. MEASURED 2026-08-16 on two live runs in
|
|
281
|
+
* one workspace: 72.0% over 4 rounds and 49.2% over 2, neither of which says
|
|
282
|
+
* anything about whether the second invocation inherited the first one's
|
|
283
|
+
* prefix. `aggregateCache` carries the full argument.
|
|
284
|
+
*/
|
|
285
|
+
cache: outcome?.usage?.cache
|
|
286
|
+
? {
|
|
287
|
+
promptTokens: outcome.usage.cache.promptTokens,
|
|
288
|
+
cachedTokens: outcome.usage.cache.cachedTokens,
|
|
289
|
+
hitRate: outcome.usage.cache.hitRate,
|
|
290
|
+
firstRound: outcome.usage.cache.firstRound ?? null,
|
|
291
|
+
roundsReported: outcome.usage.cache.roundsReported,
|
|
292
|
+
roundsUnknown: outcome.usage.cache.roundsUnknown,
|
|
293
|
+
}
|
|
294
|
+
: null,
|
|
295
|
+
/**
|
|
296
|
+
* ── ⭐⭐ WHICH UPSTREAM SERVED THE ROUNDS, AND DID THE PIN TAKE ───────────
|
|
297
|
+
*
|
|
298
|
+
* ⚠️ WITHOUT THIS, `cache.hitRate` IS RECORDED AND NOT DIAGNOSABLE. Measured
|
|
299
|
+
* 2026-08-14: `deepseek-v4-flash-0731` has **28 upstream endpoints**, a
|
|
300
|
+
* prompt cache lives on ONE of them, and the same 4-round task measured
|
|
301
|
+
* 46.7% hit rate unpinned against 95.8% pinned. A durable 47% and a durable
|
|
302
|
+
* 95% therefore sit in the audit log with nothing to say whether routing or
|
|
303
|
+
* our own prefix moved — two problems with completely different fixes.
|
|
304
|
+
*
|
|
305
|
+
* ⚠️ `served` IS A COUNT PER PROVIDER, NOT A SINGLE NAME. A session
|
|
306
|
+
* legitimately reaches several upstreams and that scatter IS the finding;
|
|
307
|
+
* emitting one value would report the last round and hide the cold ones
|
|
308
|
+
* before it.
|
|
309
|
+
*
|
|
310
|
+
* ⚠️ `pinMissed > 0` IS THE ONE THAT COSTS MONEY. `allow_fallbacks` is true
|
|
311
|
+
* by design, and OpenRouter answers an unhonourable `order` list by ignoring
|
|
312
|
+
* it rather than refusing it — so a pin that never takes has no symptom
|
|
313
|
+
* except a worse bill. This field is that symptom.
|
|
314
|
+
*
|
|
315
|
+
* `null` when nothing was pinned and no round named a provider — the same
|
|
316
|
+
* rule `cache` follows: silence is unknown, never zero.
|
|
317
|
+
*/
|
|
318
|
+
providers: outcome?.providers
|
|
319
|
+
? {
|
|
320
|
+
pin: outcome.providers.pin ?? null,
|
|
321
|
+
served: outcome.providers.served ?? {},
|
|
322
|
+
roundsUnknown: outcome.providers.roundsUnknown ?? 0,
|
|
323
|
+
/**
|
|
324
|
+
* ⚠️⚠️ `pinTook` MEANS THE **FIRST** NAME IN THE LIST, and it did not
|
|
325
|
+
* used to. Any name counted as a success, which hid the one routing
|
|
326
|
+
* event that costs money: a prompt cache lives on ONE upstream, so a
|
|
327
|
+
* round served by the pin's SECOND name is available, billed, and
|
|
328
|
+
* stone cold. Measured 2026-08-16, one byte-identical 46,171-byte
|
|
329
|
+
* payload — StreamLake 98.3% cached at $0.000172, Baidu 0.0% cached
|
|
330
|
+
* at $0.000791, **4.6×** — reported as `pinTook: 1, pinMissed: 0`.
|
|
331
|
+
*
|
|
332
|
+
* ⭐ `pinFellBack` is that round. The three counters are disjoint and
|
|
333
|
+
* sum to the rounds that named a provider; `pinMissed` keeps its old
|
|
334
|
+
* meaning (nobody in the list served it) and stays rare, because a
|
|
335
|
+
* three-name list nearly always contains whoever answered.
|
|
336
|
+
*/
|
|
337
|
+
pinTook: outcome.providers.pinTook ?? 0,
|
|
338
|
+
pinFellBack: outcome.providers.pinFellBack ?? 0,
|
|
339
|
+
pinMissed: outcome.providers.pinMissed ?? 0,
|
|
340
|
+
}
|
|
341
|
+
: null,
|
|
342
|
+
/**
|
|
343
|
+
* ⭐ THE DIRECT CAUSE OF A COLLAPSED `cache.hitRate`, shipped alongside the
|
|
344
|
+
* effect. Compaction rewrites earlier messages and voids the prompt-prefix
|
|
345
|
+
* cache from the first rewritten message onward, so emitting the hit rate
|
|
346
|
+
* without the rewrite count makes the hit rate un-diagnosable from outside
|
|
347
|
+
* the process — the same defect the `cache` block above was added to fix,
|
|
348
|
+
* one layer down. Always a number: `0` is a fact about this run, not a
|
|
349
|
+
* default standing in for "unknown".
|
|
350
|
+
*/
|
|
351
|
+
compactions: outcome?.compactions ?? 0,
|
|
352
|
+
// Refusals are data: a script retrying a run wants to know it asked for
|
|
353
|
+
// something impossible rather than that the model was merely unlucky.
|
|
354
|
+
refusals: (outcome?.executed ?? [])
|
|
355
|
+
.filter((e) => e?.result?.ok === false)
|
|
356
|
+
.map((e) => ({ tool: e.name, error: String(e.result.error ?? '').slice(0, 300) })),
|
|
357
|
+
error: outcome?.ok === false ? (outcome.error ?? null) : null,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* ── ⭐ A PATH SHORT ENOUGH TO READ ───────────────────────────────────────────
|
|
363
|
+
*
|
|
364
|
+
* The banner printed the absolute workspace root on every run — routinely 100+
|
|
365
|
+
* characters, wrapping the one line that is supposed to orient you before
|
|
366
|
+
* anything has happened. It is the first thing a new user sees and it looked
|
|
367
|
+
* like a stack trace.
|
|
368
|
+
*
|
|
369
|
+
* ⚠️ SHORTENING A PATH IS LYING UNLESS THE LIE IS MARKED. `~` is universally
|
|
370
|
+
* understood and reversible. An elision in the middle is NOT, so it carries a
|
|
371
|
+
* `…` — a path silently missing three segments is a path that sends someone to
|
|
372
|
+
* the wrong directory.
|
|
373
|
+
*
|
|
374
|
+
* ⚠️ AND THE LAST SEGMENT IS NEVER DROPPED. It is the segment that says WHICH
|
|
375
|
+
* workspace this is; every parent above it is context. A shortener that keeps
|
|
376
|
+
* the head and eats the tail has thrown away the only part being read.
|
|
377
|
+
*/
|
|
378
|
+
export function shortenRoot(root, home = process.env.HOME || process.env.USERPROFILE, max = 44) {
|
|
379
|
+
const raw = typeof root === 'string' ? root : '';
|
|
380
|
+
if (!raw) return raw;
|
|
381
|
+
|
|
382
|
+
const sep = raw.includes('\\') ? '\\' : '/';
|
|
383
|
+
const trimEnd = (s) => s.replace(/[\\/]+$/, '');
|
|
384
|
+
let out = raw;
|
|
385
|
+
|
|
386
|
+
// ⭐ `~` first: it is both the shortest form and the only lossless one.
|
|
387
|
+
if (typeof home === 'string' && home.length > 1) {
|
|
388
|
+
// ⚠️ Windows paths are case-insensitive; comparing them case-sensitively
|
|
389
|
+
// misses the `~` on exactly the platform with the longest paths.
|
|
390
|
+
const norm = (s) => (process.platform === 'win32' ? trimEnd(s).toLowerCase() : trimEnd(s));
|
|
391
|
+
const h = norm(home);
|
|
392
|
+
const r = norm(raw);
|
|
393
|
+
if (r === h) out = '~';
|
|
394
|
+
else if (r.startsWith(`${h}/`) || r.startsWith(`${h}\\`)) {
|
|
395
|
+
out = `~${sep}${raw.slice(trimEnd(home).length + 1)}`;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (out.length <= max) return out;
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* ⚠️ ELIDE FROM THE MIDDLE, keeping the root marker and the deepest segments.
|
|
403
|
+
* Losing "C:" or the leading "/" turns an absolute path into a relative one,
|
|
404
|
+
* which is a different path, not a shorter one.
|
|
405
|
+
*/
|
|
406
|
+
const parts = out.split(/[\\/]/).filter((p, i) => p !== '' || i === 0);
|
|
407
|
+
if (parts.length <= 2) return out;
|
|
408
|
+
|
|
409
|
+
const head = parts[0] === '' ? sep : parts[0];
|
|
410
|
+
const tail = [];
|
|
411
|
+
let width = head.length + 2; // the head plus the "…" segment
|
|
412
|
+
for (let i = parts.length - 1; i >= 1; i -= 1) {
|
|
413
|
+
if (tail.length > 0 && width + parts[i].length + 1 > max) break;
|
|
414
|
+
tail.unshift(parts[i]);
|
|
415
|
+
width += parts[i].length + 1;
|
|
416
|
+
}
|
|
417
|
+
if (tail.length >= parts.length - 1) return out;
|
|
418
|
+
return [head === sep ? '' : head, '…', ...tail].join(sep);
|
|
419
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ RANK THE PAGE. `search_text` HAD NO RELEVANCE AT ALL. ───────────────
|
|
3
|
+
*
|
|
4
|
+
* Measured 2026-08-18: `searchText` pushes matches in WALK ORDER — raw
|
|
5
|
+
* filesystem traversal — and caps at `MAX_MATCHES = 60`. So a model hunting a
|
|
6
|
+
* symbol got the first sixty files the directory walk happened to reach, and
|
|
7
|
+
* read them top-down. Whether the DEFINITION was among them was luck; whether it
|
|
8
|
+
* was near the top was pure luck.
|
|
9
|
+
*
|
|
10
|
+
* ⭐ THE SIGNAL THAT MATTERS IS "DEFINITION OR MENTION". A symbol has one
|
|
11
|
+
* definition and many callers, and the model almost always wants the one.
|
|
12
|
+
* `function x` / `class x` / `const x =` / `export` are cheap, high-precision
|
|
13
|
+
* marks of that, and they cost one regex over a line that is already in memory.
|
|
14
|
+
*
|
|
15
|
+
* ── ⚠️ IT REORDERS THE PAGE. IT DOES NOT CHANGE WHICH FILES ARE ON IT. ──────
|
|
16
|
+
* `total`, `unseen`, `nextOffset` and the offset window are all computed before
|
|
17
|
+
* this runs and are untouched. Re-ranking ACROSS pages would mean collecting
|
|
18
|
+
* every match before returning any, which breaks the bounded-scan contract
|
|
19
|
+
* `search.mjs` is built around — and that contract is the reason a search on a
|
|
20
|
+
* large repo does not read the whole thing. A better first page is worth having
|
|
21
|
+
* without it.
|
|
22
|
+
*
|
|
23
|
+
* ⚠️ STABLE BY CONSTRUCTION. Equal scores keep walk order, so output stays
|
|
24
|
+
* deterministic for the same tree. A search whose order shuffled between runs
|
|
25
|
+
* would void the prompt cache on every round that ran one — the CLI's own
|
|
26
|
+
* `repo-map.mjs` sorts by code point for exactly this reason.
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ ITS OWN MODULE, NOT A FUNCTION INSIDE `search.mjs`. Pure, no filesystem,
|
|
29
|
+
* no walk state — so the scoring is testable on plain objects rather than
|
|
30
|
+
* needing a tree on disk.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Regex-escape a user/model-supplied identifier. */
|
|
34
|
+
function escapeRe(s) {
|
|
35
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The bare identifier a pattern is most likely hunting for.
|
|
40
|
+
*
|
|
41
|
+
* ⚠️ THE LAST WORD, NOT THE FIRST. A model searches `export function assemble`
|
|
42
|
+
* or `class\s+Workspace` — the thing it wants is at the end, and the leading
|
|
43
|
+
* words are the qualifier.
|
|
44
|
+
*/
|
|
45
|
+
export function identifierFrom(pattern) {
|
|
46
|
+
const words = String(pattern || '')
|
|
47
|
+
.replace(/[^A-Za-z0-9_$]+/g, ' ')
|
|
48
|
+
.trim()
|
|
49
|
+
.split(/\s+/)
|
|
50
|
+
.filter(Boolean);
|
|
51
|
+
return words.length ? words[words.length - 1] : '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const DEFINING_KEYWORDS = 'function|class|const|let|var|export|def|interface|type|struct|fn';
|
|
55
|
+
|
|
56
|
+
/** Points for one match. Higher is more likely to be what was asked for. */
|
|
57
|
+
export function scoreMatch(match, ident) {
|
|
58
|
+
const line = String(match?.line ?? match?.text ?? '');
|
|
59
|
+
const path = String(match?.path ?? match?.file ?? '');
|
|
60
|
+
let n = 0;
|
|
61
|
+
|
|
62
|
+
if (ident) {
|
|
63
|
+
const esc = escapeRe(ident);
|
|
64
|
+
// A definition beats every mention of the same name.
|
|
65
|
+
if (new RegExp(`(?:${DEFINING_KEYWORDS})\\s+${esc}\\b`, 'i').test(line)) n += 100;
|
|
66
|
+
// A file NAMED for the symbol is usually its home.
|
|
67
|
+
if (new RegExp(`(?:^|[/\\\\])${esc}\\b`, 'i').test(path)) n += 40;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// An exported thing is the one other files use.
|
|
71
|
+
if (/\bexport\b/.test(line)) n += 20;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* ⚠️ TESTS ARE DEMOTED, NOT EXCLUDED. A test file mentions a symbol
|
|
75
|
+
* constantly and defines it never, so it crowds out the definition — but it
|
|
76
|
+
* is often exactly what someone asked for ("where is this tested"). A
|
|
77
|
+
* penalty lets a strong signal still win; a filter would not.
|
|
78
|
+
*/
|
|
79
|
+
if (/\.(test|spec)\.[a-z]+$/i.test(path)) n -= 30;
|
|
80
|
+
|
|
81
|
+
// Shallow paths are source; deep ones are usually fixtures or vendored.
|
|
82
|
+
n -= Math.min(10, (path.match(/[/\\]/g) || []).length);
|
|
83
|
+
return n;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Sort one page of matches by relevance, stably.
|
|
88
|
+
*
|
|
89
|
+
* ⚠️ Returns the SAME array contents — this may never add, drop or alter a
|
|
90
|
+
* match. Anything else would make `total` and `nextOffset` lie.
|
|
91
|
+
*/
|
|
92
|
+
export function rankMatches(matches, pattern) {
|
|
93
|
+
if (!Array.isArray(matches) || matches.length < 2) return matches;
|
|
94
|
+
const ident = identifierFrom(pattern);
|
|
95
|
+
return matches
|
|
96
|
+
.map((m, i) => ({ m, i, s: scoreMatch(m, ident) }))
|
|
97
|
+
.sort((a, b) => (b.s - a.s) || (a.i - b.i))
|
|
98
|
+
.map((x) => x.m);
|
|
99
|
+
}
|