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/dropped.mjs
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ DRAG A FILE INTO THE TERMINAL AND HAVE IT MEAN SOMETHING ────────────
|
|
3
|
+
*
|
|
4
|
+
* Roman, 2026-08-16: *"since our CLI will have Qwen for interpretation, people
|
|
5
|
+
* should be able to drop images, files, etc into a terminal — that's another
|
|
6
|
+
* feature you don't have."*
|
|
7
|
+
*
|
|
8
|
+
* ⭐ THE CAPABILITY WAS NEVER THE MISSING PART. `read_image` (vision.mjs),
|
|
9
|
+
* `read_document` and `read_table` all ship. What was missing is the ONE STEP
|
|
10
|
+
* a user actually performs: dragging a file onto a terminal window does not
|
|
11
|
+
* attach anything — it pastes a **path string** into the command line. Nothing
|
|
12
|
+
* in this program looked at that string and noticed it named a real file, so a
|
|
13
|
+
* dropped screenshot arrived as an unremarkable sentence and the model, which
|
|
14
|
+
* cannot see, answered about a filename.
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ SO THIS IS A PARSER, NOT A FEATURE. It turns "what a terminal does when
|
|
17
|
+
* you drop a file" into "what the model is told it has".
|
|
18
|
+
*
|
|
19
|
+
* ── ⚠️ THE FOUR SHAPES A TERMINAL ACTUALLY PASTES ────────────────────────────
|
|
20
|
+
*
|
|
21
|
+
* Measured against the conventions, not guessed at from one of them:
|
|
22
|
+
*
|
|
23
|
+
* · Windows Terminal / cmd → "C:\Users\me\shot.png" (double-quoted)
|
|
24
|
+
* · PowerShell → 'C:\Users\me\my shot.png' (single-quoted)
|
|
25
|
+
* · macOS Terminal / iTerm → /Users/me/my\ shot.png (backslash-escaped)
|
|
26
|
+
* · everything, no spaces → /home/me/shot.png (bare)
|
|
27
|
+
*
|
|
28
|
+
* A parser that handles only the bare case works on every path without a space
|
|
29
|
+
* in it, which is most paths a developer tests with and almost no path a user
|
|
30
|
+
* drops from a Downloads folder.
|
|
31
|
+
*
|
|
32
|
+
* ── ⚠️⚠️ EXISTENCE ON DISK IS THE WHOLE FILTER ──────────────────────────────
|
|
33
|
+
*
|
|
34
|
+
* The hard problem is not finding path-shaped text, it is NOT finding it
|
|
35
|
+
* everywhere. "fix the bug in the login flow" contains no file; "update
|
|
36
|
+
* README.md" contains a word that looks like one. ⭐ The rule that separates a
|
|
37
|
+
* dropped file from prose about a file is that **a dropped file is on the
|
|
38
|
+
* disk**, so nothing is attached unless it can be stat'd. That single check
|
|
39
|
+
* removes the entire class of false positives without a vocabulary to maintain.
|
|
40
|
+
*
|
|
41
|
+
* ── ⚠️ AND ONLY *MEDIA* IS ATTACHED. Deliberately. ──────────────────────────
|
|
42
|
+
*
|
|
43
|
+
* The agent can already open a `.ts` file whenever it decides to — `read_file`
|
|
44
|
+
* exists and it has the repo map. Auto-attaching source would spend tokens on a
|
|
45
|
+
* decision the model is better placed to make, and would fire on any sentence
|
|
46
|
+
* naming a tracked file. But it CANNOT open a PNG by deciding to: reading an
|
|
47
|
+
* image costs a vision call the model must be told is worth making. ⭐ So the
|
|
48
|
+
* split is capability-based, not preference-based — attach what the model
|
|
49
|
+
* cannot reach on its own, mention what it can.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { existsSync, statSync } from 'node:fs';
|
|
53
|
+
import { isAbsolute, resolve, extname, basename } from 'node:path';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* ⚠️ LOWERCASE, AND COMPARED AGAINST A LOWERCASED EXTENSION. A file dropped
|
|
57
|
+
* from a phone is routinely `IMG_0421.JPG`, and a case-sensitive table would
|
|
58
|
+
* classify the single commonest real-world drop as `unknown`.
|
|
59
|
+
*/
|
|
60
|
+
const IMAGE = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tif', '.tiff', '.avif']);
|
|
61
|
+
const DOCUMENT = new Set(['.pdf', '.docx', '.doc', '.odt', '.rtf', '.pptx']);
|
|
62
|
+
const TABLE = new Set(['.csv', '.tsv', '.xlsx', '.xls', '.ods']);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* ⚠️ A CEILING, BECAUSE A FOLDER DROP IS ONE GESTURE AND FORTY PATHS. Selecting
|
|
66
|
+
* a directory's contents and dragging them in is normal behaviour, and forty
|
|
67
|
+
* vision calls is real money spent on one careless flick of the wrist. The
|
|
68
|
+
* overflow is REPORTED rather than silently dropped — see `describeDropped`.
|
|
69
|
+
*/
|
|
70
|
+
export const MAX_DROPPED = 8;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* ⚠️ 64MB. Large enough for any screenshot or scanned PDF, small enough that a
|
|
74
|
+
* dropped video or disk image is refused rather than read into memory. Reported,
|
|
75
|
+
* not skipped in silence: "I ignored your file" must never be something the user
|
|
76
|
+
* has to infer from a wrong answer.
|
|
77
|
+
*/
|
|
78
|
+
export const MAX_DROPPED_BYTES = 64 * 1024 * 1024;
|
|
79
|
+
|
|
80
|
+
/** What the model can do with a given extension, or null if we should not care. */
|
|
81
|
+
export function classifyDropped(path) {
|
|
82
|
+
const ext = extname(String(path ?? '')).toLowerCase();
|
|
83
|
+
if (IMAGE.has(ext)) return { kind: 'image', tool: 'read_image' };
|
|
84
|
+
if (DOCUMENT.has(ext)) return { kind: 'document', tool: 'read_document' };
|
|
85
|
+
if (TABLE.has(ext)) return { kind: 'table', tool: 'read_table' };
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Every path-shaped token in a line, in the four shapes a terminal pastes.
|
|
91
|
+
*
|
|
92
|
+
* ⚠️ QUOTED FORMS ARE TAKEN FIRST AND THEIR SPAN REMOVED, because a bare scan
|
|
93
|
+
* run first would split `"my shot.png"` at the space and find neither half.
|
|
94
|
+
* Order is load-bearing here, not stylistic.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} text
|
|
97
|
+
* @returns {string[]} raw candidates, still unresolved and unverified
|
|
98
|
+
*/
|
|
99
|
+
export function candidatePaths(text) {
|
|
100
|
+
let rest = String(text ?? '');
|
|
101
|
+
const found = [];
|
|
102
|
+
|
|
103
|
+
// 1 + 2. "double quoted" and 'single quoted'.
|
|
104
|
+
for (const re of [/"([^"]+)"/g, /'([^']+)'/g]) {
|
|
105
|
+
rest = rest.replace(re, (_all, inner) => {
|
|
106
|
+
found.push(inner);
|
|
107
|
+
// ⚠️ Replaced with spaces, NOT removed: deleting the span would join the
|
|
108
|
+
// words either side into one token that never existed.
|
|
109
|
+
return ' '.repeat(String(_all).length);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 3 + 4. Backslash-escaped spaces, and bare runs.
|
|
114
|
+
// `/Users/me/my\ shot.png` must survive as ONE token, so an escaped space is
|
|
115
|
+
// part of the run. On Windows a backslash is a separator rather than an
|
|
116
|
+
// escape, but `\ ` (backslash-space) is not a legal Windows path fragment,
|
|
117
|
+
// so accepting it costs nothing there.
|
|
118
|
+
for (const m of rest.matchAll(/(?:[^\s\\]|\\ |\\(?![\s]))+/g)) {
|
|
119
|
+
const token = m[0];
|
|
120
|
+
if (!/[\\/]/.test(token) && !extname(token)) continue; // not path-shaped at all
|
|
121
|
+
found.push(token.replace(/\\ /g, ' '));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return found;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* ⭐ THE FILES A USER DROPPED INTO THIS COMMAND, resolved and verified.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} text the task as typed
|
|
131
|
+
* @param {{ root?: string, max?: number, maxBytes?: number }} [options]
|
|
132
|
+
* @returns {{ attached: Array<{path: string, name: string, kind: string, tool: string, bytes: number}>,
|
|
133
|
+
* skipped: Array<{path: string, why: string}>, overflow: number }}
|
|
134
|
+
*/
|
|
135
|
+
export function findDropped(text, { root = process.cwd(), max = MAX_DROPPED, maxBytes = MAX_DROPPED_BYTES } = {}) {
|
|
136
|
+
const attached = [];
|
|
137
|
+
const skipped = [];
|
|
138
|
+
const seen = new Set();
|
|
139
|
+
let overflow = 0;
|
|
140
|
+
|
|
141
|
+
for (const raw of candidatePaths(text)) {
|
|
142
|
+
const cleaned = raw.trim().replace(/[),.;:]+$/, ''); // trailing sentence punctuation
|
|
143
|
+
if (!cleaned) continue;
|
|
144
|
+
|
|
145
|
+
const kind = classifyDropped(cleaned);
|
|
146
|
+
if (!kind) continue; // source files are the model's business, not ours
|
|
147
|
+
|
|
148
|
+
const full = isAbsolute(cleaned) ? cleaned : resolve(root, cleaned);
|
|
149
|
+
if (seen.has(full)) continue;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* ⚠️⚠️ EXISTENCE IS CHECKED BEFORE ANYTHING ELSE IS BELIEVED. This is the
|
|
153
|
+
* line that separates a dropped file from a sentence mentioning one, and
|
|
154
|
+
* it is why no keyword list is needed.
|
|
155
|
+
*/
|
|
156
|
+
if (!existsSync(full)) continue;
|
|
157
|
+
|
|
158
|
+
let stat;
|
|
159
|
+
try {
|
|
160
|
+
stat = statSync(full);
|
|
161
|
+
} catch {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
// ⚠️ A directory matches every check above and is not a file.
|
|
165
|
+
if (!stat.isFile()) continue;
|
|
166
|
+
|
|
167
|
+
seen.add(full);
|
|
168
|
+
|
|
169
|
+
if (stat.size > maxBytes) {
|
|
170
|
+
skipped.push({ path: full, why: `${(stat.size / 1e6).toFixed(1)}MB is over the ${(maxBytes / 1e6).toFixed(0)}MB limit` });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (attached.length >= max) {
|
|
174
|
+
overflow += 1;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
attached.push({ path: full, name: basename(full), kind: kind.kind, tool: kind.tool, bytes: stat.size });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { attached, skipped, overflow };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The sentence the model is told, or null when nothing was dropped.
|
|
186
|
+
*
|
|
187
|
+
* ⚠️ IT NAMES THE TOOL FOR EACH FILE. Telling a model "there is an image at
|
|
188
|
+
* /tmp/a.png" and leaving it to work out that `read_image` is how one looks at
|
|
189
|
+
* an image wastes a round on rediscovering our own API — and a model that never
|
|
190
|
+
* makes the connection answers about the filename, which is the exact failure
|
|
191
|
+
* this module exists to remove.
|
|
192
|
+
*
|
|
193
|
+
* ⚠️ AND THE SKIPS ARE SAID OUT LOUD. A file the user watched themselves drop,
|
|
194
|
+
* silently ignored, is indistinguishable from a broken program.
|
|
195
|
+
*/
|
|
196
|
+
export function describeDropped(result) {
|
|
197
|
+
if (!result || (result.attached.length === 0 && result.skipped.length === 0 && !result.overflow)) return null;
|
|
198
|
+
|
|
199
|
+
const lines = [];
|
|
200
|
+
if (result.attached.length > 0) {
|
|
201
|
+
lines.push(
|
|
202
|
+
result.attached.length === 1
|
|
203
|
+
? 'The user dropped a file into this command. Look at it before answering:'
|
|
204
|
+
: `The user dropped ${result.attached.length} files into this command. Look at them before answering:`,
|
|
205
|
+
);
|
|
206
|
+
for (const f of result.attached) {
|
|
207
|
+
lines.push(` · ${f.path} — a ${f.kind}. Call ${f.tool} with that exact path.`);
|
|
208
|
+
}
|
|
209
|
+
lines.push('Do not describe these from their filenames; you cannot see them until you call the tool.');
|
|
210
|
+
}
|
|
211
|
+
for (const s of result.skipped) lines.push(` ⚠ ${s.path} was NOT attached: ${s.why}.`);
|
|
212
|
+
if (result.overflow > 0) {
|
|
213
|
+
lines.push(` ⚠ ${result.overflow} further dropped file(s) were not attached — the limit is ${MAX_DROPPED} per command. Ask for the rest by path if you need them.`);
|
|
214
|
+
}
|
|
215
|
+
return lines.join('\n');
|
|
216
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐⭐ THE COMPILER WAS RUNNING AND NOBODY WAS LISTENING ──────────────────
|
|
3
|
+
*
|
|
4
|
+
* `lib/lsp.mjs` already starts typescript-language-server, pyright,
|
|
5
|
+
* rust-analyzer and gopls, and already exports `diagnostics(root, file)`. Until
|
|
6
|
+
* now the model heard from them ONLY if it chose to call `check_types` — and
|
|
7
|
+
* measured across agents, models reach for symbol tools **0–6% of the time**.
|
|
8
|
+
* So on most edits the language server knew the file was broken and said
|
|
9
|
+
* nothing.
|
|
10
|
+
*
|
|
11
|
+
* ⭐ WHY THIS IS THE HIGHEST-VALUE CHANGE ON THE BOARD, with evidence:
|
|
12
|
+
*
|
|
13
|
+
* · Self-critique with NO external signal is measured to make things WORSE —
|
|
14
|
+
* six settings out of six down or flat, and one benchmark lost 37.7 points
|
|
15
|
+
* in a single round (arXiv:2310.01798). This is why a `--refute` pass that
|
|
16
|
+
* consults nothing but the model is not free.
|
|
17
|
+
* · With an EXTERNAL signal it works: replacing the model's own feedback with
|
|
18
|
+
* real feedback moved repaired-and-passing 33.3% → 52.6% (arXiv:2306.09896).
|
|
19
|
+
* · SWE-agent's ablation puts the linter-on-edits mechanism at **+3.0 points**
|
|
20
|
+
* — larger than removing ALL search (−2.3).
|
|
21
|
+
*
|
|
22
|
+
* A compiler error is that external signal, it arrives without a model call, and
|
|
23
|
+
* we were already paying to compute it.
|
|
24
|
+
*
|
|
25
|
+
* ⚠️ ERRORS ONLY, NEVER STYLE. Aider's linter is deliberately narrow — syntax
|
|
26
|
+
* errors and undefined names, no formatting — because style warnings make the
|
|
27
|
+
* model chase noise instead of the defect it just introduced.
|
|
28
|
+
*
|
|
29
|
+
* ⚠️⚠️ AND IT MAY NEVER FAIL A WRITE. The file is already on disk. Turning "the
|
|
30
|
+
* language server did not answer" into a failed edit would make correct work
|
|
31
|
+
* look broken, which this repo has paid for four times in one day.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { languageForFile, diagnostics as lspDiagnostics } from './lsp.mjs';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* ⭐ TWENTY, NOT ALL OF THEM. One bad import can produce hundreds of errors, and
|
|
38
|
+
* pasting them all back spends the context the model needs to FIX it. The first
|
|
39
|
+
* twenty in file order carry the cause; the rest are consequences of it.
|
|
40
|
+
*/
|
|
41
|
+
export const MAX_DIAGNOSTICS_PER_FILE = 20;
|
|
42
|
+
|
|
43
|
+
/** How long a language server gets before we give up and stay quiet. */
|
|
44
|
+
export const DIAGNOSTICS_BUDGET_MS = 4_000;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Which files a tool call actually put bytes into.
|
|
48
|
+
*
|
|
49
|
+
* ⚠️ DERIVED FROM THE RESULT, NOT THE ARGUMENTS. The arguments are what the
|
|
50
|
+
* model ASKED for; the result is what landed. A refused write, a dry run, or a
|
|
51
|
+
* batch where 44 of 45 files were written all differ, and asking a language
|
|
52
|
+
* server about a file that was never written produces a diagnostic about the
|
|
53
|
+
* version already on disk — which reads as "your edit broke this" when the edit
|
|
54
|
+
* never happened.
|
|
55
|
+
*
|
|
56
|
+
* Pure.
|
|
57
|
+
*/
|
|
58
|
+
export function writtenPathsOf(record) {
|
|
59
|
+
const { name, result } = record ?? {};
|
|
60
|
+
if (!result || result.ok !== true || result.dryRun === true) return [];
|
|
61
|
+
switch (name) {
|
|
62
|
+
case 'write_file':
|
|
63
|
+
case 'edit_file':
|
|
64
|
+
return typeof result.path === 'string' && result.path ? [result.path] : [];
|
|
65
|
+
case 'move_file':
|
|
66
|
+
// The destination holds the bytes now; the source no longer exists.
|
|
67
|
+
return typeof result.to === 'string' && result.to ? [result.to] : [];
|
|
68
|
+
case 'write_files':
|
|
69
|
+
return (Array.isArray(result.written) ? result.written : [])
|
|
70
|
+
.map((w) => (typeof w === 'string' ? w : w?.path))
|
|
71
|
+
.filter((p) => typeof p === 'string' && p);
|
|
72
|
+
default:
|
|
73
|
+
/**
|
|
74
|
+
* ⚠️ A SHELL COMMAND CAN WRITE ANYTHING, and we do not know what. Guessing
|
|
75
|
+
* would mean either probing the whole tree or saying nothing useful, so
|
|
76
|
+
* this stays scoped to the verbs whose result names its own files.
|
|
77
|
+
*/
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* ── ⚠️⚠️⚠️ NEVER BLAME THE MODEL FOR BREAKAGE THAT WAS ALREADY THERE ─────────
|
|
84
|
+
*
|
|
85
|
+
* The first version of this module reported EVERY error in the file after a
|
|
86
|
+
* write. In a repo that already has type errors — which is most real repos — a
|
|
87
|
+
* model that wrote a perfectly correct file gets handed a list of someone else's
|
|
88
|
+
* bugs and told "fix these before continuing". It will: a whole round, at full
|
|
89
|
+
* price, producing a diff nobody asked for.
|
|
90
|
+
*
|
|
91
|
+
* SWE-agent's edit guard runs the linter before and after and DIFFS the error
|
|
92
|
+
* sets for exactly this reason. This is that, with the baseline captured when
|
|
93
|
+
* the file is READ (OpenCode's trick — the read tool warms the language server
|
|
94
|
+
* fire-and-forget) so a write does not pay for two round-trips.
|
|
95
|
+
*
|
|
96
|
+
* ⚠️ FINGERPRINTED BY MESSAGE, NOT BY LINE. An edit shifts every line below it,
|
|
97
|
+
* so a line-keyed baseline would report the entire tail of the file as new.
|
|
98
|
+
* Keyed by message WITH A COUNT: three instances before and four after means
|
|
99
|
+
* exactly one new one, which is the fact the model needs. Message-only would let
|
|
100
|
+
* a model add four more of an error that already existed once and hear nothing.
|
|
101
|
+
*/
|
|
102
|
+
const baselines = new Map();
|
|
103
|
+
|
|
104
|
+
const fingerprint = (d) => String(d?.message ?? '').replace(/\s+/g, ' ').trim().slice(0, 300);
|
|
105
|
+
|
|
106
|
+
function countByMessage(items) {
|
|
107
|
+
const counts = new Map();
|
|
108
|
+
for (const d of items) {
|
|
109
|
+
const k = fingerprint(d);
|
|
110
|
+
counts.set(k, (counts.get(k) ?? 0) + 1);
|
|
111
|
+
}
|
|
112
|
+
return counts;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Record what a file's errors looked like BEFORE the model touched it. */
|
|
116
|
+
export function rememberBaseline(file, items) {
|
|
117
|
+
const errors = (Array.isArray(items) ? items : []).filter((d) => Number(d?.severity) === 1);
|
|
118
|
+
baselines.set(file, countByMessage(errors));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** ⚠️ Tests only — the store is process-wide and a leaked baseline hides a real error. */
|
|
122
|
+
export function resetBaselines() {
|
|
123
|
+
baselines.clear();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The errors that were not already there.
|
|
128
|
+
*
|
|
129
|
+
* ⚠️ NO BASELINE MEANS REPORT EVERYTHING. If we never saw the file before we
|
|
130
|
+
* cannot know what we broke, and staying silent about real errors to avoid a
|
|
131
|
+
* false accusation is the worse trade — a missed error ships.
|
|
132
|
+
*/
|
|
133
|
+
export function newErrorsOnly(file, items) {
|
|
134
|
+
const errors = (Array.isArray(items) ? items : []).filter((d) => Number(d?.severity) === 1);
|
|
135
|
+
const before = baselines.get(file);
|
|
136
|
+
if (!before) return errors;
|
|
137
|
+
|
|
138
|
+
const remaining = new Map(before);
|
|
139
|
+
const fresh = [];
|
|
140
|
+
for (const d of errors) {
|
|
141
|
+
const k = fingerprint(d);
|
|
142
|
+
const left = remaining.get(k) ?? 0;
|
|
143
|
+
if (left > 0) remaining.set(k, left - 1);
|
|
144
|
+
else fresh.push(d);
|
|
145
|
+
}
|
|
146
|
+
return fresh;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** One error line, clamped — a single diagnostic can carry a whole type. */
|
|
150
|
+
function line(d) {
|
|
151
|
+
const at = Number.isFinite(d?.line) ? `${d.line}${Number.isFinite(d?.column) ? `:${d.column}` : ''}` : '?';
|
|
152
|
+
const message = String(d?.message ?? '').replace(/\s+/g, ' ').trim().slice(0, 300);
|
|
153
|
+
return ` ${at} ${message}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The block for one file, or null when there is nothing worth the tokens.
|
|
158
|
+
*
|
|
159
|
+
* ⚠️ TAGGED, NOT LOOSE PROSE. Tool results are the first thing compaction
|
|
160
|
+
* clamps, and an unlabelled paragraph of compiler output looks like any other
|
|
161
|
+
* long result. A named block can be found, superseded by a later one for the
|
|
162
|
+
* same file, and dropped as a unit.
|
|
163
|
+
*/
|
|
164
|
+
export function formatDiagnosticsBlock(file, items) {
|
|
165
|
+
const errors = (Array.isArray(items) ? items : []).filter((d) => Number(d?.severity) === 1);
|
|
166
|
+
if (errors.length === 0) return null;
|
|
167
|
+
|
|
168
|
+
const shown = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE);
|
|
169
|
+
const hidden = errors.length - shown.length;
|
|
170
|
+
const body = shown.map(line).join('\n');
|
|
171
|
+
/**
|
|
172
|
+
* ⚠️ A SILENT TRUNCATION IS A LIE ABOUT THE STATE OF THE FILE. A model told
|
|
173
|
+
* about 20 errors that has 50 will believe it is 20 fixes from green.
|
|
174
|
+
*/
|
|
175
|
+
const tail = hidden > 0 ? `\n … and ${hidden} more error${hidden === 1 ? '' : 's'} in this file` : '';
|
|
176
|
+
return `<diagnostics file="${file}">\n${body}${tail}\n</diagnostics>`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Diagnostics for the files a tool call just wrote, as one string to append to
|
|
181
|
+
* the tool result — or null when there is nothing to say.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} root
|
|
184
|
+
* @param {readonly string[]} paths
|
|
185
|
+
* @param {{ diagnosticsImpl?: Function, timeoutMs?: number }} [opts]
|
|
186
|
+
*/
|
|
187
|
+
export async function diagnosticsAfterWrite(root, paths, opts = {}) {
|
|
188
|
+
const impl = opts.diagnosticsImpl ?? lspDiagnostics;
|
|
189
|
+
const budget = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DIAGNOSTICS_BUDGET_MS;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* ⚠️ FILTERED BEFORE ANY SERVER IS TOUCHED. Asking about `README.md` would
|
|
193
|
+
* start a language server for a file no server handles — cost with no
|
|
194
|
+
* possible answer.
|
|
195
|
+
*/
|
|
196
|
+
const candidates = [...new Set((paths ?? []).filter((p) => typeof p === 'string' && p))]
|
|
197
|
+
.filter((p) => {
|
|
198
|
+
try { return Boolean(languageForFile(p)); } catch { return false; }
|
|
199
|
+
});
|
|
200
|
+
if (candidates.length === 0) return null;
|
|
201
|
+
|
|
202
|
+
const blocks = [];
|
|
203
|
+
for (const file of candidates) {
|
|
204
|
+
/**
|
|
205
|
+
* ⚠️⚠️ EVERY FAILURE MODE IS SILENCE. Server missing, server throwing,
|
|
206
|
+
* server never answering — none of them may turn a landed write into a
|
|
207
|
+
* reported failure, and none may hold the turn open.
|
|
208
|
+
*/
|
|
209
|
+
let res = null;
|
|
210
|
+
let timer = null;
|
|
211
|
+
try {
|
|
212
|
+
/**
|
|
213
|
+
* ⚠️ THE TIMER IS CLEARED, NOT UNREF'D. `unref()` lets the event loop exit
|
|
214
|
+
* while the race is still pending, so a caller that awaits this can be
|
|
215
|
+
* abandoned mid-flight — which is exactly how the first version of this
|
|
216
|
+
* failed its own timeout test ("Promise resolution is still pending but
|
|
217
|
+
* the event loop has already resolved"). Keep the loop alive for the
|
|
218
|
+
* budget, then release it.
|
|
219
|
+
*/
|
|
220
|
+
res = await Promise.race([
|
|
221
|
+
Promise.resolve(impl(root, file)),
|
|
222
|
+
new Promise((resolve) => { timer = setTimeout(() => resolve(null), budget); }),
|
|
223
|
+
]);
|
|
224
|
+
} catch {
|
|
225
|
+
res = null;
|
|
226
|
+
} finally {
|
|
227
|
+
if (timer) clearTimeout(timer);
|
|
228
|
+
}
|
|
229
|
+
if (!res || res.ok !== true) continue;
|
|
230
|
+
const all = res.items ?? res.diagnostics ?? [];
|
|
231
|
+
const fresh = newErrorsOnly(file, all);
|
|
232
|
+
/**
|
|
233
|
+
* ⭐ THE BASELINE MOVES TO THE CURRENT STATE, and that is what stops a loop.
|
|
234
|
+
* An error the model has already been told about is not re-announced on its
|
|
235
|
+
* next write — being told twice about something you are already fixing is
|
|
236
|
+
* how a model starts oscillating.
|
|
237
|
+
*/
|
|
238
|
+
rememberBaseline(file, all);
|
|
239
|
+
const block = formatDiagnosticsBlock(file, fresh);
|
|
240
|
+
if (block) blocks.push(block);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (blocks.length === 0) return null;
|
|
244
|
+
/**
|
|
245
|
+
* ⭐ PHRASED AS AN INSTRUCTION, because this string is handed straight to the
|
|
246
|
+
* model. "Diagnostics:" is a label; naming what to do with them is the thing
|
|
247
|
+
* that turns a signal into a repair.
|
|
248
|
+
*/
|
|
249
|
+
return `\n\nThe language server reports errors in what you just wrote. Fix these before continuing:\n${blocks.join('\n')}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* ── ⭐ WARM THE SERVER AND SNAPSHOT, WHEN THE MODEL READS A FILE ─────────────
|
|
254
|
+
*
|
|
255
|
+
* OpenCode's trick, and it buys two things at once. The language server is slow
|
|
256
|
+
* only on its FIRST request for a project, so doing that work while the model is
|
|
257
|
+
* reading — not while it is waiting for a write to return — hides the latency.
|
|
258
|
+
* And it gives us the BEFORE picture, which is the whole basis for not blaming
|
|
259
|
+
* the model for breakage that was already there.
|
|
260
|
+
*
|
|
261
|
+
* ⚠️ FIRE AND FORGET, ALWAYS. This must never delay a read, never fail one, and
|
|
262
|
+
* never surface anything to the model. A read is the cheapest, most frequent
|
|
263
|
+
* call in the loop; making it wait on a language server handshake would be felt
|
|
264
|
+
* on every single turn.
|
|
265
|
+
*/
|
|
266
|
+
export function warmBaseline(root, file, opts = {}) {
|
|
267
|
+
const impl = opts.diagnosticsImpl ?? lspDiagnostics;
|
|
268
|
+
try {
|
|
269
|
+
if (!languageForFile(file)) return;
|
|
270
|
+
if (baselines.has(file)) return;
|
|
271
|
+
} catch { return; }
|
|
272
|
+
try {
|
|
273
|
+
Promise.resolve(impl(root, file))
|
|
274
|
+
.then((res) => { if (res && res.ok === true) rememberBaseline(file, res.items ?? res.diagnostics ?? []); })
|
|
275
|
+
.catch(() => {});
|
|
276
|
+
} catch { /* a snapshot may never break a read */ }
|
|
277
|
+
}
|