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.
Files changed (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. package/skills/working-in-the-background.md +64 -0
@@ -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
+ }