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,1052 @@
1
+ /**
2
+ * ── ⭐⭐ THE AGENT COULD START A SERVER. IT COULD NOT WATCH ONE ──────────────
3
+ *
4
+ * `background.mjs` starts long-running processes and keeps the last
5
+ * `MAX_LOG_CHARS` (16,000) of their output in a ring. `check_process` hands that
6
+ * ring back. That is enough to answer "did it boot?" and it is NOT enough to
7
+ * debug anything, because debugging a server is a three-step loop:
8
+ *
9
+ * 1. start it 2. make a request 3. read what appeared BETWEEN
10
+ *
11
+ * Step 3 was missing. There was no way to ask "what is new since I last looked",
12
+ * no way to say "only the lines with ERROR in them", and no way to wait for a
13
+ * line to appear — so the only strategy available was to re-read the whole ring
14
+ * every round and diff it by eye.
15
+ *
16
+ * ── ⚠️⚠️ AND THAT IS NOT A CONVENIENCE PROBLEM, IT IS A BUDGET FIRE ─────────
17
+ *
18
+ * Re-reading a saturated ring costs ~16,000 characters ≈ 4,000 tokens EVERY
19
+ * ROUND, and almost all of it is text the model has already read. Ten rounds of
20
+ * watching a dev server is ~40,000 tokens spent re-reading the same startup
21
+ * banner, and it is worse than merely expensive: the new three lines that
22
+ * actually matter arrive buried in 200 identical ones, which is how a model
23
+ * looks straight past the stack trace it asked for.
24
+ *
25
+ * ⭐ THE CURSOR IS THE ENTIRE POINT OF THIS MODULE. Everything else here —
26
+ * filters, waiting, summarising — is a refinement. `tailSince` returns what is
27
+ * new and a token to ask again with; asking again with that token and nothing
28
+ * having happened returns ZERO lines and ZERO characters. The suite asserts
29
+ * exactly that, because a cursor that quietly re-delivers is worse than no
30
+ * cursor at all: it costs the same and it looks like it is working.
31
+ *
32
+ * ── ⚠️ WHY THE CURSOR IS NOT A NUMBER, WHICH IS WHAT I WROTE FIRST ─────────
33
+ *
34
+ * The obvious cursor is an offset into the buffer. It is wrong, and it is wrong
35
+ * in the one case that matters: the buffer is a RING. Once a chatty server has
36
+ * printed more than 16,000 characters, `record()` in `background.mjs` slices the
37
+ * FRONT off, so every existing offset now points 200 characters further into the
38
+ * text than it did a moment ago. A plain offset does not fail loudly there — it
39
+ * silently skips whatever slid past it, which is precisely the output you were
40
+ * watching for.
41
+ *
42
+ * ⭐ So the cursor anchors on CONTENT: `acv1:<seen>:<hash>` where the hash is of
43
+ * the last `ANCHOR_CHARS` characters already delivered. Resolving it tries the
44
+ * recorded position first (the common case, one hash), and only if the ring has
45
+ * shifted does it walk backwards looking for the anchor. If the anchor is gone
46
+ * entirely, that is reported as `evicted: true` — the honest answer, "output was
47
+ * dropped before you read it", never a silent jump.
48
+ *
49
+ * ⚠️ THE HASH IS FNV-1a/32, NOT A CRYPTOGRAPHIC DIGEST, and a scan over a 16,000
50
+ * character ring gives roughly a 4-in-a-million chance of landing on a wrong
51
+ * anchor. Named rather than hidden: the exact-position check runs first and does
52
+ * not scan at all, so the scan only happens on a ring that has actually shifted.
53
+ * The failure mode if it ever fires is a few repeated or skipped lines, not
54
+ * corruption — and the alternative (carrying 32 raw characters through every
55
+ * tool call) costs the model tokens on every single round forever.
56
+ *
57
+ * ── ⚠️ PARTIAL LINES, AND WHY THE CURSOR STOPS SHORT OF THEM ───────────────
58
+ *
59
+ * A process is writing while you read. The tail of the buffer is routinely half
60
+ * a line. Deliver it and advance past it and the line arrives split in two,
61
+ * across two rounds, matching no filter and reading as two different events. So
62
+ * a trailing line with no newline is HELD BACK: reported separately as
63
+ * `partial`, never counted as a line, and the cursor stops before it. `flush`
64
+ * overrides that, and `waitFor` sets it, because a point-in-time question about
65
+ * a line that may never get its newline is a different question.
66
+ *
67
+ * ── ⚠️ THE REGEX FILTER, AND THE DoS IT WOULD HAVE BEEN ────────────────────
68
+ *
69
+ * `search.mjs` already says it: handing a model's string to `new RegExp` is a
70
+ * catastrophic-backtracking hang in one call. `(a+)+$` against a long line does
71
+ * not return. This module takes patterns from the same place, so it does not
72
+ * accept arbitrary regex. `validateLogRegex` enforces a restricted grammar, and
73
+ * the guarantee comes from THREE layers, in decreasing strength:
74
+ *
75
+ * 1. **A quantifier may not be applied to a group.** `(x+)+`, `(a|a)*`,
76
+ * `(?:ab)+` are refused. This is structural, not a heuristic: exponential
77
+ * backtracking needs a quantified group containing an ambiguous quantifier
78
+ * or alternation, and you cannot write one in this grammar at all.
79
+ * 2. **Unbounded quantifiers are capped and must be separated.** At most
80
+ * `MAX_UNBOUNDED_QUANTIFIERS`, and two of them may not sit adjacent with no
81
+ * concrete atom between (`.*.*`, `\s*\s*` are refused) — that adjacency is
82
+ * what produces polynomial blowup once the exponential road is closed.
83
+ * 3. **The input is clipped.** Matching happens per line against at most
84
+ * `MATCH_LINE_CHARS` characters, and the whole filter runs under a wall
85
+ * clock (`FILTER_BUDGET_MS`) checked every `TIME_CHECK_EVERY` lines.
86
+ *
87
+ * ⚠️ HONESTLY: (1) is a proof, (2) and (3) are bounds. A hostile pattern at the
88
+ * cap can still be slower than a nice one. It cannot hang, and if it is slow the
89
+ * result says `budgetExceeded` with the line it reached rather than pretending
90
+ * it searched everything — which is the failure `search.mjs` had to fix too
91
+ * ("everything was looked at and the string is not there", when it was not).
92
+ *
93
+ * ⭐ AND `contains` NEEDS NONE OF THAT. Plain substring matching is first-class
94
+ * here, not a fallback: it is what a caller wants ~90% of the time, it cannot
95
+ * backtrack, and the regex door only opens when someone explicitly asks for it.
96
+ *
97
+ * ── PURE ON PURPOSE ────────────────────────────────────────────────────────
98
+ *
99
+ * Nothing here imports `background.mjs` or touches its registry. Every function
100
+ * takes the buffer as a STRING (or, for `waitFor`, an injected `read()` that
101
+ * returns one). So the whole module tests with no processes, no ports, no
102
+ * network and no timers — and the lead can point it at any log source at all.
103
+ */
104
+
105
+ /** Characters of already-delivered text the cursor anchors on. */
106
+ export const ANCHOR_CHARS = 32;
107
+
108
+ /** Cursor token version tag. Bump it if the format changes; old tokens then read as invalid rather than as garbage. */
109
+ export const CURSOR_PREFIX = 'acv1';
110
+
111
+ /** Longest filter pattern accepted. A 200-character log filter is a mistake, not a need. */
112
+ export const MAX_PATTERN_CHARS = 200;
113
+
114
+ /** ⚠️ See layer 2 above. Four is generous — `^\s*at .*\(.*\)` uses three. */
115
+ export const MAX_UNBOUNDED_QUANTIFIERS = 4;
116
+
117
+ /** Largest `{n,m}` repeat accepted; `a{5000}` is a slow way to say nothing. */
118
+ export const MAX_REPEAT = 1_000;
119
+
120
+ /** Nested groups beyond this are refused — depth is complexity nobody needs in a log filter. */
121
+ export const MAX_GROUP_DEPTH = 5;
122
+
123
+ /** ⚠️ Layer 3: how much of a line the matcher may look at. Bounds the input, not the pattern. */
124
+ export const MATCH_LINE_CHARS = 512;
125
+
126
+ /**
127
+ * ── ⚠️⚠️ HOW LONG A PARTIAL LINE MAY BE HELD, AND THE BUG THAT PUT IT HERE ──
128
+ *
129
+ * Holding back the trailing partial line (so it is never split across two reads)
130
+ * is right — until the process does not emit newlines at all. FOUND BY THE
131
+ * MEASUREMENT TEST, which fed a 16,000-character buffer with no `\n` in it: the
132
+ * whole buffer was one partial line, so `deliveredChars` was ZERO, the cursor
133
+ * never moved, and ten rounds re-delivered all 16,000 characters ten times.
134
+ *
135
+ * ⭐ That is the precise failure this module exists to prevent, reintroduced by
136
+ * its own politeness. A progress bar redrawing with `\r`, a minified bundle
137
+ * printed in one write, or a process that flushes without newlines all produce
138
+ * it. So past this length the text stops being "a line in progress" and is
139
+ * delivered — split, and SAID to be split, which is strictly better than
140
+ * charging for it again every round forever.
141
+ */
142
+ export const PARTIAL_HOLD_CHARS = 2_000;
143
+
144
+ /** How long a whole filter pass may take before it reports back unfinished. */
145
+ export const FILTER_BUDGET_MS = 250;
146
+
147
+ /** The clock is read every N lines rather than every line — reading it is not free. */
148
+ export const TIME_CHECK_EVERY = 256;
149
+
150
+ /** Default / ceiling on lines returned by one tail. */
151
+ export const DEFAULT_TAIL_LINES = 200;
152
+ export const MAX_TAIL_LINES = 2_000;
153
+
154
+ /** waitFor: defaults and the ceiling. A wait longer than this is a hung tool call. */
155
+ export const DEFAULT_WAIT_MS = 10_000;
156
+ export const MAX_WAIT_MS = 120_000;
157
+ export const DEFAULT_POLL_MS = 200;
158
+
159
+ /** How many lines a timeout message quotes back. Enough to recognise where it got to. */
160
+ export const TIMEOUT_SAMPLE_LINES = 12;
161
+
162
+ /** Summary shape. */
163
+ export const DEFAULT_SUMMARY_TAIL = 40;
164
+ export const DEFAULT_SUMMARY_ERRORS = 20;
165
+
166
+ /**
167
+ * The lines a summary pulls to the front. OURS, not the caller's — compiled
168
+ * once, no nested quantifiers, so layer 1 above is satisfied by construction.
169
+ */
170
+ export const NOTABLE_PATTERNS = Object.freeze({
171
+ error: /(?:^|[^a-z])(?:error|fatal|exception|traceback|panic|unhandled|failed|failure|refused|denied|EADDRINUSE|ECONNREFUSED|ENOENT|MODULE_NOT_FOUND)(?:[^a-z]|$)/i,
172
+ warn: /(?:^|[^a-z])(?:warn|warning|deprecated|deprecation)(?:[^a-z]|$)/i,
173
+ trace: /^\s+at\s/,
174
+ });
175
+
176
+ /* ────────────────────────────── the cursor ────────────────────────────── */
177
+
178
+ /**
179
+ * FNV-1a, 32-bit. Inline because this package has zero dependencies and
180
+ * `node:crypto` would be a heavier hammer for a non-security hash.
181
+ * @param {string} str
182
+ * @returns {number} unsigned 32-bit
183
+ */
184
+ function fnv1a32(str) {
185
+ let h = 0x811c9dc5;
186
+ for (let i = 0; i < str.length; i += 1) {
187
+ h ^= str.charCodeAt(i);
188
+ // ⚠️ The shift-and-add form of `h * 16777619` — a plain multiply overflows
189
+ // the double's integer range and quietly stops being FNV.
190
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
191
+ }
192
+ return h >>> 0;
193
+ }
194
+
195
+ /**
196
+ * Build the opaque cursor token for "everything up to `end` has been delivered".
197
+ * @param {string} text the buffer as it was read
198
+ * @param {number} end absolute index in `text` up to which content was delivered
199
+ * @returns {string}
200
+ */
201
+ export function formatCursor(text, end) {
202
+ const safeEnd = Math.max(0, Math.min(Number.isFinite(end) ? end : 0, text.length));
203
+ const anchorLen = Math.min(safeEnd, ANCHOR_CHARS);
204
+ const anchor = text.slice(safeEnd - anchorLen, safeEnd);
205
+ return `${CURSOR_PREFIX}:${safeEnd}:${fnv1a32(anchor).toString(16)}`;
206
+ }
207
+
208
+ /**
209
+ * @param {unknown} token
210
+ * @returns {{seen: number, hash: number}|null} null when it is not one of ours
211
+ */
212
+ export function parseCursor(token) {
213
+ if (typeof token !== 'string') return null;
214
+ const parts = token.split(':');
215
+ if (parts.length !== 3 || parts[0] !== CURSOR_PREFIX) return null;
216
+ const seen = Number(parts[1]);
217
+ const hash = Number.parseInt(parts[2], 16);
218
+ if (!Number.isInteger(seen) || seen < 0 || !Number.isInteger(hash)) return null;
219
+ return { seen, hash };
220
+ }
221
+
222
+ /**
223
+ * Where in `text` the caller's "new content" begins.
224
+ *
225
+ * ⚠️ THREE OUTCOMES, AND THEY ARE DIFFERENT FACTS:
226
+ * · found at the recorded position — nothing was evicted, the fast path;
227
+ * · found earlier — the ring dropped text the caller had ALREADY seen, which
228
+ * is harmless and reported as `shiftedChars`;
229
+ * · not found — the ring dropped text the caller had NOT seen. That is data
230
+ * loss and it is reported as `evicted`, never smoothed over.
231
+ *
232
+ * @param {string} text
233
+ * @param {string|null|undefined} token
234
+ * @returns {{start:number, fresh?:boolean, invalid?:boolean, evicted?:boolean, shiftedChars?:number}}
235
+ */
236
+ export function resolveCursor(text, token) {
237
+ if (token === null || token === undefined || token === '') return { start: 0, fresh: true };
238
+ const parsed = parseCursor(token);
239
+ if (!parsed) return { start: 0, invalid: true };
240
+
241
+ const anchorLen = Math.min(parsed.seen, ANCHOR_CHARS);
242
+
243
+ // Fast path: the buffer has not shifted, so the anchor is exactly where we left it.
244
+ if (parsed.seen <= text.length
245
+ && fnv1a32(text.slice(parsed.seen - anchorLen, parsed.seen)) === parsed.hash) {
246
+ return { start: parsed.seen };
247
+ }
248
+
249
+ // The ring evicted from the front, so the anchor can only have moved LEFT.
250
+ const from = Math.min(parsed.seen, text.length);
251
+ for (let end = from; end >= anchorLen; end -= 1) {
252
+ if (fnv1a32(text.slice(end - anchorLen, end)) === parsed.hash) {
253
+ return { start: end, shiftedChars: parsed.seen - end };
254
+ }
255
+ }
256
+
257
+ return { start: 0, evicted: true };
258
+ }
259
+
260
+ /* ────────────────────────────── the filter ────────────────────────────── */
261
+
262
+ /**
263
+ * Is this pattern safe to compile? See the three layers in the file header.
264
+ *
265
+ * ⚠️ THIS IS A RESTRICTION, NOT A PARSER. `new RegExp` remains the authority on
266
+ * whether the syntax is valid — `compileFilter` still compiles inside a
267
+ * try/catch. This function only decides whether a VALID pattern is one we are
268
+ * willing to run against model-supplied input.
269
+ *
270
+ * @param {string} source
271
+ * @returns {{ok:true, unbounded:number}|{ok:false, error:string}}
272
+ */
273
+ export function validateLogRegex(source) {
274
+ if (typeof source !== 'string' || source === '') {
275
+ return { ok: false, error: 'a regex filter needs a pattern, e.g. "ERROR|WARN". For plain text use "contains" instead — it needs no escaping and cannot be refused.' };
276
+ }
277
+ if (source.length > MAX_PATTERN_CHARS) {
278
+ return { ok: false, error: `that pattern is ${source.length} characters and the limit is ${MAX_PATTERN_CHARS}. A log filter this long is almost always several filters — run them one at a time, or use "contains" with the distinctive substring.` };
279
+ }
280
+
281
+ const quantifierRefusal = (what) => ({
282
+ ok: false,
283
+ error: `"${what}" applies a quantifier to a group, which is refused here: a quantified group is how a regex backtracks exponentially and hangs the process. Quantify a single character or a character class instead — "(?:ab)+" becomes "(?:ab)(?:ab)?" or just "contains":"ab".`,
284
+ });
285
+
286
+ let unbounded = 0;
287
+ let depth = 0;
288
+ /** Has a concrete atom appeared since the last unbounded quantifier? See layer 2. */
289
+ let separated = true;
290
+ /** An atom that has been consumed but not yet claimed by a quantifier. */
291
+ let atomPending = false;
292
+ /** 'none' | 'char' | 'group' | 'quant' | 'anchor' | 'alt' | 'open' */
293
+ let prev = 'none';
294
+ let i = 0;
295
+
296
+ const takeAtom = () => {
297
+ if (atomPending) separated = true; // the previous atom went unquantified — it is a separator
298
+ atomPending = true;
299
+ prev = 'char';
300
+ };
301
+
302
+ while (i < source.length) {
303
+ const c = source[i];
304
+
305
+ if (c === '\\') {
306
+ const n = source[i + 1];
307
+ if (n === undefined) return { ok: false, error: 'the pattern ends with a lone backslash, so it escapes nothing. Drop it, or double it ("\\\\") if you meant a literal backslash.' };
308
+ if (n >= '1' && n <= '9') return { ok: false, error: 'back-references like "\\1" are refused: matching a group against itself is the other way a regex backtracks exponentially. Write the text out, or use "contains".' };
309
+ if (n === 'k') return { ok: false, error: 'named back-references ("\\k<name>") are refused for the same reason as "\\1" — they make the match ambiguous and the backtracking unbounded.' };
310
+ takeAtom();
311
+ i += 2;
312
+ continue;
313
+ }
314
+
315
+ if (c === '[') {
316
+ let j = i + 1;
317
+ if (source[j] === '^') j += 1;
318
+ if (source[j] === ']') j += 1; // a `]` first in the class is a literal
319
+ while (j < source.length && source[j] !== ']') {
320
+ if (source[j] === '\\') j += 1;
321
+ j += 1;
322
+ }
323
+ if (j >= source.length) return { ok: false, error: 'unclosed "[" — a character class needs its "]". Escape it as "\\[" if you meant a literal bracket.' };
324
+ takeAtom();
325
+ i = j + 1;
326
+ continue;
327
+ }
328
+
329
+ if (c === '(') {
330
+ if (source.startsWith('(?', i)) {
331
+ if (!source.startsWith('(?:', i)) {
332
+ return { ok: false, error: 'look-ahead and look-behind ("(?=", "(?!", "(?<=") are refused here: they re-scan the same text and are the classic source of a slow filter. Only the non-capturing group "(?:" is allowed.' };
333
+ }
334
+ i += 3;
335
+ } else {
336
+ i += 1;
337
+ }
338
+ depth += 1;
339
+ if (depth > MAX_GROUP_DEPTH) return { ok: false, error: `groups nested more than ${MAX_GROUP_DEPTH} deep are refused. A log filter that deep is a program — match something simpler and filter the result again.` };
340
+ if (atomPending) separated = true;
341
+ atomPending = false;
342
+ prev = 'open';
343
+ continue;
344
+ }
345
+
346
+ if (c === ')') {
347
+ if (depth === 0) return { ok: false, error: 'unbalanced ")" — escape it as "\\)" if you meant a literal parenthesis.' };
348
+ depth -= 1;
349
+ if (atomPending) separated = true;
350
+ atomPending = false;
351
+ prev = 'group';
352
+ i += 1;
353
+ continue;
354
+ }
355
+
356
+ if (c === '|') {
357
+ if (atomPending) separated = true;
358
+ atomPending = false;
359
+ // ⚠️ Branches do not chain, so an unbounded quantifier in the next branch
360
+ // is not "adjacent" to one in this branch. `(?:a*|b*)` is fine.
361
+ separated = true;
362
+ prev = 'alt';
363
+ i += 1;
364
+ continue;
365
+ }
366
+
367
+ if (c === '^' || c === '$') {
368
+ if (atomPending) separated = true;
369
+ atomPending = false;
370
+ prev = 'anchor';
371
+ i += 1;
372
+ continue;
373
+ }
374
+
375
+ if (c === '*' || c === '+' || c === '?') {
376
+ // `??`, `*?`, `+?` — the lazy modifier on a quantifier we already counted.
377
+ if (c === '?' && prev === 'quant') { prev = 'lazy'; i += 1; continue; }
378
+ if (prev !== 'char') return quantifierRefusal(`${prev === 'group' ? ')' : source[i - 1] ?? ''}${c}`);
379
+ atomPending = false;
380
+ if (c === '?') {
381
+ separated = true; // bounded: it can match at most one, no ambiguity to chain
382
+ } else {
383
+ if (!separated) {
384
+ return { ok: false, error: `two unbounded quantifiers sit together in "${source}" (like ".*.*" or "\\\\s*\\\\s*"). Each one can claim the other's text, so the match has to try every split — put a literal between them, or use just one.` };
385
+ }
386
+ unbounded += 1;
387
+ separated = false;
388
+ }
389
+ prev = 'quant';
390
+ i += 1;
391
+ continue;
392
+ }
393
+
394
+ if (c === '{') {
395
+ const m = /^\{(\d+)(,(\d*))?\}/.exec(source.slice(i));
396
+ if (!m) { takeAtom(); i += 1; continue; } // a literal `{`
397
+ if (prev !== 'char') return quantifierRefusal(`)${m[0]}`);
398
+ const min = Number(m[1]);
399
+ const openEnded = m[2] !== undefined && (m[3] === undefined || m[3] === '');
400
+ const max = openEnded ? Infinity : (m[3] ? Number(m[3]) : min);
401
+ if (Number.isFinite(max) && max > MAX_REPEAT) {
402
+ return { ok: false, error: `"${m[0]}" repeats more than ${MAX_REPEAT} times. Nothing in a log line is that long — did you mean "+"?` };
403
+ }
404
+ if (Number.isFinite(max) && max < min) {
405
+ return { ok: false, error: `"${m[0]}" has a maximum below its minimum, so it can never match.` };
406
+ }
407
+ atomPending = false;
408
+ if (openEnded) {
409
+ if (!separated) {
410
+ return { ok: false, error: `two unbounded quantifiers sit together in "${source}". Put a literal between them, or use just one.` };
411
+ }
412
+ unbounded += 1;
413
+ separated = false;
414
+ } else {
415
+ separated = true;
416
+ }
417
+ i += m[0].length;
418
+ prev = 'quant';
419
+ continue;
420
+ }
421
+
422
+ // `.` and every ordinary literal.
423
+ takeAtom();
424
+ i += 1;
425
+ }
426
+
427
+ if (depth !== 0) return { ok: false, error: 'unclosed "(" — every group needs its ")". Escape it as "\\(" if you meant a literal parenthesis.' };
428
+ if (unbounded > MAX_UNBOUNDED_QUANTIFIERS) {
429
+ return { ok: false, error: `that pattern has ${unbounded} unbounded quantifiers ("*", "+", "{n,}") and the limit is ${MAX_UNBOUNDED_QUANTIFIERS}. Each one multiplies the work on a line that nearly matches. Anchor it with more literal text, or filter twice.` };
430
+ }
431
+ return { ok: true, unbounded };
432
+ }
433
+
434
+ /** Coerce a `contains`/`exclude` argument into an array of non-empty strings. */
435
+ function toTerms(value) {
436
+ if (value === null || value === undefined || value === '') return [];
437
+ const list = Array.isArray(value) ? value : [value];
438
+ return list.map((v) => String(v)).filter((v) => v !== '');
439
+ }
440
+
441
+ /**
442
+ * Build a line predicate from plain substrings and/or a bounded regex.
443
+ *
444
+ * ⚠️ NO FILTER IS A VALID FILTER. `compileFilter({})` returns a predicate that
445
+ * accepts everything, and `active:false` so the caller can say so. A guard that
446
+ * refused an empty filter would fail the most common correct call there is.
447
+ *
448
+ * @param {{contains?:string|string[], exclude?:string|string[], matches?:string, ignoreCase?:boolean}} spec
449
+ * @returns {{ok:true, test:(line:string)=>boolean, active:boolean, describe:string}|{ok:false, error:string}}
450
+ */
451
+ export function compileFilter({ contains, exclude, matches, ignoreCase = false } = {}) {
452
+ const includes = toTerms(contains);
453
+ const excludes = toTerms(exclude);
454
+ let rx = null;
455
+
456
+ if (matches !== undefined && matches !== null && matches !== '') {
457
+ const source = String(matches);
458
+ const check = validateLogRegex(source);
459
+ if (!check.ok) return check;
460
+ try {
461
+ rx = new RegExp(source, ignoreCase ? 'i' : '');
462
+ } catch (err) {
463
+ return { ok: false, error: `not a valid regular expression: ${String(err?.message ?? err)}. If you only want to find text, use "contains" — it takes the characters literally.` };
464
+ }
465
+ }
466
+
467
+ const fold = (s) => (ignoreCase ? s.toLowerCase() : s);
468
+ const inc = includes.map(fold);
469
+ const exc = excludes.map(fold);
470
+ const active = inc.length > 0 || exc.length > 0 || rx !== null;
471
+
472
+ const parts = [];
473
+ if (inc.length) parts.push(`contains ${inc.map((t) => JSON.stringify(t)).join(' or ')}`);
474
+ if (rx) parts.push(`matches /${String(matches)}/${ignoreCase ? 'i' : ''}`);
475
+ if (exc.length) parts.push(`and not ${exc.map((t) => JSON.stringify(t)).join(' or ')}`);
476
+
477
+ return {
478
+ ok: true,
479
+ active,
480
+ describe: parts.length ? parts.join(', ') : 'no filter (every line)',
481
+ test(line) {
482
+ // ⚠️ Layer 3: the matcher never sees more than MATCH_LINE_CHARS. A minified
483
+ // bundle printed as one 400KB "line" would otherwise be handed whole to a
484
+ // regex, which is exactly the input that turns a slow pattern into a hang.
485
+ const probe = line.length > MATCH_LINE_CHARS ? line.slice(0, MATCH_LINE_CHARS) : line;
486
+ const folded = fold(probe);
487
+ if (exc.length && exc.some((t) => folded.includes(t))) return false;
488
+ if (!inc.length && !rx) return true;
489
+ if (inc.length && inc.some((t) => folded.includes(t))) return true;
490
+ if (rx && rx.test(probe)) return true;
491
+ return false;
492
+ },
493
+ };
494
+ }
495
+
496
+ /* ────────────────────────────── the tail ──────────────────────────────── */
497
+
498
+ /**
499
+ * Split a chunk into complete lines plus the trailing partial line.
500
+ * ⚠️ `\r\n` is normalised on the LINE, not on the buffer — the offsets the
501
+ * cursor records must stay indices into the original text.
502
+ */
503
+ function splitChunk(chunk) {
504
+ const nl = chunk.lastIndexOf('\n');
505
+ const complete = nl === -1 ? '' : chunk.slice(0, nl + 1);
506
+ const partial = nl === -1 ? chunk : chunk.slice(nl + 1);
507
+ const lines = complete === '' ? [] : complete.slice(0, -1).split('\n').map((l) => (l.endsWith('\r') ? l.slice(0, -1) : l));
508
+ return { lines, partial, completeChars: complete.length };
509
+ }
510
+
511
+ /**
512
+ * What has appeared since the caller's cursor.
513
+ *
514
+ * @param {string} text the whole buffer, as read right now
515
+ * @param {object} [opts]
516
+ * @param {string|null} [opts.cursor] the token from a previous call; omit for a first read
517
+ * @param {boolean} [opts.flush] deliver the trailing partial line too (use when the process has exited)
518
+ * @param {number} [opts.maxLines]
519
+ * @param {string|string[]} [opts.contains]
520
+ * @param {string|string[]} [opts.exclude]
521
+ * @param {string} [opts.matches]
522
+ * @param {boolean} [opts.ignoreCase]
523
+ * @param {() => number} [opts.now]
524
+ * @param {number} [opts.budgetMs]
525
+ * @returns {object}
526
+ */
527
+ export function tailSince(text, opts = {}) {
528
+ const src = typeof text === 'string' ? text : '';
529
+ const {
530
+ cursor = null,
531
+ flush = false,
532
+ maxLines = DEFAULT_TAIL_LINES,
533
+ now = Date.now,
534
+ budgetMs = FILTER_BUDGET_MS,
535
+ } = opts;
536
+
537
+ const filter = compileFilter(opts);
538
+ if (!filter.ok) return filter;
539
+
540
+ const cap = Math.max(1, Math.min(Number.isFinite(maxLines) ? Math.floor(maxLines) : DEFAULT_TAIL_LINES, MAX_TAIL_LINES));
541
+ const at = resolveCursor(src, cursor);
542
+ const chunk = src.slice(at.start);
543
+ const split = splitChunk(chunk);
544
+
545
+ /**
546
+ * ⚠️ See PARTIAL_HOLD_CHARS: a partial line is held only while it is
547
+ * plausibly a line still being written. Past that it is delivered, because a
548
+ * cursor that never advances is the bug this module was built to fix.
549
+ */
550
+ const overlongPartial = !flush && split.partial.length > PARTIAL_HOLD_CHARS;
551
+ const holdPartial = !flush && !overlongPartial;
552
+ const deliveredChars = holdPartial ? split.completeChars : chunk.length;
553
+ const candidates = !holdPartial && split.partial !== ''
554
+ ? [...split.lines, split.partial]
555
+ : split.lines;
556
+
557
+ /**
558
+ * ⚠️ THE FILTER RUNS UNDER A CLOCK AND SAYS SO WHEN IT RUNS OUT. Reporting
559
+ * `matched: 3` after looking at 40 of 9,000 lines would be the same lie
560
+ * `search.mjs` had to fix: a claim of completeness the caller cannot check.
561
+ */
562
+ const deadline = now() + Math.max(1, budgetMs);
563
+ const kept = [];
564
+ let scanned = 0;
565
+ let budgetExceeded = false;
566
+ for (const line of candidates) {
567
+ if (scanned % TIME_CHECK_EVERY === TIME_CHECK_EVERY - 1 && now() > deadline) { budgetExceeded = true; break; }
568
+ scanned += 1;
569
+ if (filter.test(line)) kept.push(line);
570
+ }
571
+
572
+ // Newest lines win: a tail is a question about what just happened.
573
+ const droppedOlderLines = Math.max(0, kept.length - cap);
574
+ const lines = droppedOlderLines > 0 ? kept.slice(-cap) : kept;
575
+
576
+ const out = {
577
+ ok: true,
578
+ lines,
579
+ text: lines.join('\n'),
580
+ cursor: formatCursor(src, at.start + deliveredChars),
581
+ newChars: deliveredChars,
582
+ newLines: candidates.length,
583
+ scannedLines: scanned,
584
+ matched: kept.length,
585
+ filteredOut: scanned - kept.length,
586
+ droppedOlderLines,
587
+ partial: holdPartial ? split.partial : '',
588
+ bufferChars: src.length,
589
+ filter: filter.describe,
590
+ };
591
+
592
+ const notes = [];
593
+ if (at.fresh) notes.push('first read: this is everything the buffer holds. Pass the cursor back next time to get only what is new.');
594
+ if (at.invalid) notes.push('that cursor was not one of mine, so this is a full read. Use the "cursor" value from a previous read_log reply verbatim.');
595
+ if (at.evicted) {
596
+ out.evicted = true;
597
+ notes.push('⚠️ output was DROPPED before you read it: the process printed more than the buffer holds, and the point your cursor marked is gone. What follows is everything still in the buffer, but there is a hole before it — read more often, or filter so you can afford to.');
598
+ }
599
+ if (at.shiftedChars) {
600
+ out.shiftedChars = at.shiftedChars;
601
+ notes.push(`the buffer dropped ${at.shiftedChars} characters you had already read; nothing new was lost.`);
602
+ }
603
+ if (budgetExceeded) {
604
+ out.budgetExceeded = true;
605
+ notes.push(`⚠️ the filter ran out of its ${budgetMs}ms budget after ${scanned} of ${candidates.length} lines, so lines after that were NOT examined. This count is a floor, not a total.`);
606
+ }
607
+ if (droppedOlderLines > 0) notes.push(`${droppedOlderLines} older matching lines were dropped to fit maxLines=${cap}; these are the newest.`);
608
+ if (holdPartial && split.partial !== '') notes.push('the process is mid-line; that partial line is in "partial" and is NOT counted as a line — it arrives whole on your next read.');
609
+ if (overlongPartial) {
610
+ out.splitLine = true;
611
+ notes.push(`⚠️ the last "line" is ${split.partial.length} characters with no newline yet, past the ${PARTIAL_HOLD_CHARS}-character hold, so it was delivered UNFINISHED and the rest will arrive as a separate line. Holding it instead would mean re-sending it to you every single round.`);
612
+ }
613
+ if (candidates.length === 0 && !at.fresh) notes.push('nothing new since your last read.');
614
+ if (candidates.length > 0 && lines.length === 0 && filter.active) notes.push(`${candidates.length} new lines appeared and none matched the filter (${filter.describe}). The output is there — the filter is what is hiding it.`);
615
+
616
+ out.note = notes.join(' ');
617
+ return out;
618
+ }
619
+
620
+ /* ────────────────────────────── waiting ───────────────────────────────── */
621
+
622
+ /** Accept either a plain string or a `{text, running, exitCode}` record from `read()`. */
623
+ function normaliseRead(value) {
624
+ if (typeof value === 'string') return { ok: true, text: value, running: undefined };
625
+ if (value && typeof value === 'object') {
626
+ if (value.ok === false) return { ok: false, error: String(value.error ?? 'the log could not be read') };
627
+ const text = typeof value.text === 'string' ? value.text
628
+ : (typeof value.output === 'string' ? value.output
629
+ : (typeof value.log === 'string' ? value.log : null));
630
+ if (text === null) return { ok: false, error: 'the log source returned no text (expected a string, or an object with "text").' };
631
+ return { ok: true, text, running: value.running, exitCode: value.exitCode ?? null };
632
+ }
633
+ return { ok: false, error: 'the log source returned nothing readable.' };
634
+ }
635
+
636
+ const realSleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
637
+
638
+ /**
639
+ * Wait until a line appears, or say what it DID see.
640
+ *
641
+ * ⚠️⚠️ "TIMED OUT" ALONE IS USELESS, AND IT IS THE DEFAULT EVERY WAIT HELPER
642
+ * SHIPS. A model told only that its wait expired has three indistinguishable
643
+ * explanations — the pattern is wrong, the process is dead, or it is genuinely
644
+ * still starting — and no way to choose, so it waits again. Every failure here
645
+ * carries: how long it waited, how many lines appeared, whether output was still
646
+ * arriving, and the last `sampleLines` lines verbatim.
647
+ *
648
+ * ⭐ AND IT STOPS EARLY WHEN THE PROCESS IS GONE. `background.mjs` already
649
+ * records the cost of not knowing this: "a model that cannot tell them apart
650
+ * waits politely forever for a dead process." If `read()` reports
651
+ * `running:false`, the wait ends immediately — AFTER one last scan, because the
652
+ * line you are waiting for is very often the last thing a process prints before
653
+ * it dies.
654
+ *
655
+ * @param {object} opts
656
+ * @param {() => (string|object|Promise<string|object>)} opts.read the log source
657
+ * @returns {Promise<object>}
658
+ */
659
+ export async function waitFor(opts = {}) {
660
+ const {
661
+ read,
662
+ cursor = null,
663
+ timeoutMs = DEFAULT_WAIT_MS,
664
+ pollMs = DEFAULT_POLL_MS,
665
+ sampleLines = TIMEOUT_SAMPLE_LINES,
666
+ now = Date.now,
667
+ sleep = realSleep,
668
+ signal = null,
669
+ } = opts;
670
+
671
+ if (typeof read !== 'function') {
672
+ return { ok: false, reason: 'unwired', error: 'waitFor needs a read() that returns the current log text. Nothing here reaches a process on its own — that is the caller\'s job.' };
673
+ }
674
+
675
+ const filter = compileFilter(opts);
676
+ if (!filter.ok) return { ok: false, reason: 'bad-filter', error: filter.error };
677
+ if (!filter.active) {
678
+ return { ok: false, reason: 'bad-filter', error: 'waitFor needs something to wait FOR: "contains" (plain text, e.g. "Ready in") or "matches" (a bounded regex). Without one it would return on the first line of anything.' };
679
+ }
680
+
681
+ const requested = Number.isFinite(timeoutMs) ? Math.max(0, Math.floor(timeoutMs)) : DEFAULT_WAIT_MS;
682
+ // ⚠️ CLAMPED AND SAID SO. Silently honouring a 10-minute wait blocks the whole
683
+ // run; silently refusing it fails correct work. Clamp, then report the number
684
+ // actually used so the caller can see what happened.
685
+ const budget = Math.min(requested, MAX_WAIT_MS);
686
+ const poll = Math.max(1, Math.min(Number.isFinite(pollMs) ? Math.floor(pollMs) : DEFAULT_POLL_MS, 5_000));
687
+ const started = now();
688
+ const deadline = started + budget;
689
+
690
+ let polls = 0;
691
+ let last = { lines: [], newLines: 0, newChars: 0, cursor, evicted: false };
692
+ let firstChars = null;
693
+ let lastChars = 0;
694
+
695
+ for (;;) {
696
+ if (signal?.aborted) {
697
+ return {
698
+ ok: false,
699
+ reason: 'aborted',
700
+ waitedMs: now() - started,
701
+ polls,
702
+ error: `the wait was cancelled after ${now() - started}ms, before ${filter.describe} appeared.`,
703
+ sawLines: last.newLines,
704
+ tail: last.lines.slice(-sampleLines),
705
+ };
706
+ }
707
+
708
+ const got = normaliseRead(await read());
709
+ if (!got.ok) return { ok: false, reason: 'unreadable', error: got.error, waitedMs: now() - started, polls };
710
+ if (firstChars === null) firstChars = got.text.length;
711
+ lastChars = got.text.length;
712
+ polls += 1;
713
+
714
+ /**
715
+ * ⚠️ RE-RESOLVED FROM THE ORIGINAL CURSOR EVERY POLL, NOT ADVANCED. The
716
+ * cursor is content-anchored, so re-resolving is correct even after the ring
717
+ * shifts — whereas advancing it each poll would step past a partial line and
718
+ * split the very line being waited for across two polls, which is the one
719
+ * line that must not split.
720
+ */
721
+ /**
722
+ * ⭐ ONE unfiltered pass, then the filter applied here. Calling `tailSince`
723
+ * twice (once filtered, once not) would re-split the same buffer every poll
724
+ * for nothing, and a poll loop is the last place to do work twice.
725
+ */
726
+ const unfiltered = tailSince(got.text, { cursor, flush: true, maxLines: MAX_TAIL_LINES, now });
727
+ last = {
728
+ lines: unfiltered.lines,
729
+ newLines: unfiltered.newLines,
730
+ newChars: unfiltered.newChars,
731
+ cursor: unfiltered.cursor,
732
+ evicted: unfiltered.evicted === true,
733
+ };
734
+ const hits = unfiltered.lines.filter((l) => filter.test(l));
735
+
736
+ if (hits.length > 0) {
737
+ return {
738
+ ok: true,
739
+ matched: hits[0],
740
+ matchedAll: hits,
741
+ matchCount: hits.length,
742
+ waitedMs: now() - started,
743
+ polls,
744
+ sawLines: last.newLines,
745
+ /**
746
+ * ⚠️ A NON-FLUSHED cursor, deliberately. The caller continues reading
747
+ * from here, and handing back a cursor that sits mid-line would split
748
+ * the next line in two. The flush above was only for MATCHING.
749
+ */
750
+ cursor: tailSince(got.text, { cursor, flush: false, maxLines: 1, now }).cursor,
751
+ note: `matched after ${now() - started}ms: ${JSON.stringify(hits[0])}`,
752
+ };
753
+ }
754
+
755
+ // ⭐ Scanned FIRST, then noticed it is dead — see the note above.
756
+ if (got.running === false) {
757
+ return {
758
+ ok: false,
759
+ reason: 'exited',
760
+ waitedMs: now() - started,
761
+ polls,
762
+ sawLines: last.newLines,
763
+ exitCode: got.exitCode ?? null,
764
+ tail: last.lines.slice(-sampleLines),
765
+ cursor: last.cursor,
766
+ error: `the process is NOT running any more (exit code ${got.exitCode ?? 'unknown'}) and ${filter.describe} never appeared in the ${last.newLines} lines it printed. `
767
+ + 'This is a failure to fix, not a slow start — the tail below is why. Waiting again cannot help; a stopped process prints nothing.',
768
+ };
769
+ }
770
+
771
+ const remaining = deadline - now();
772
+ if (remaining <= 0) break;
773
+ await sleep(Math.min(poll, remaining));
774
+ }
775
+
776
+ const waitedMs = now() - started;
777
+ const growing = firstChars !== null && lastChars > firstChars;
778
+ const clamped = requested > budget;
779
+ return {
780
+ ok: false,
781
+ reason: 'timeout',
782
+ waitedMs,
783
+ timeoutMs: budget,
784
+ polls,
785
+ sawLines: last.newLines,
786
+ tail: last.lines.slice(-sampleLines),
787
+ cursor: last.cursor,
788
+ evicted: last.evicted,
789
+ error: [
790
+ `waited ${waitedMs}ms (${polls} polls) and ${filter.describe} never appeared.`,
791
+ clamped ? `⚠️ the timeout was clamped from ${requested}ms to the ${MAX_WAIT_MS}ms ceiling.` : '',
792
+ `In that time ${last.newLines} lines appeared and the log ${growing ? `GREW from ${firstChars} to ${lastChars} characters — it is alive and working, so waiting longer may be right` : 'did NOT grow at all — the process is idle, so waiting longer will probably not help; check the pattern, or that this is the process you think it is'}.`,
793
+ last.evicted ? '⚠️ output was dropped from the ring before it could be read, so the line may have appeared and been evicted.' : '',
794
+ last.lines.length ? `The last ${Math.min(sampleLines, last.lines.length)} lines it DID print are in "tail".` : 'It printed nothing at all.',
795
+ ].filter(Boolean).join(' '),
796
+ };
797
+ }
798
+
799
+ /* ───────────────────────────── summarising ────────────────────────────── */
800
+
801
+ /** Clip one line for display, and say how much was clipped. Never a bare cut. */
802
+ function clipLine(line, max = MATCH_LINE_CHARS) {
803
+ if (line.length <= max) return line;
804
+ return `${line.slice(0, max)}… (+${line.length - max} more characters on this line)`;
805
+ }
806
+
807
+ /**
808
+ * Fold a big log down to what is worth reading: the notable lines first, the end
809
+ * of the log second, and an explicit count of everything left out.
810
+ *
811
+ * ⚠️ NEVER SILENTLY TRUNCATE. `droppedLines` is always present and always exact,
812
+ * because a summary that omits without saying so is indistinguishable from a log
813
+ * where the error genuinely is not there — and that is the mistake that makes a
814
+ * model stop looking.
815
+ *
816
+ * ⭐ ERRORS FIRST, AND DEDUPED AGAINST THE TAIL. A crash is usually the last
817
+ * thing printed, so a naive "errors + tail" prints the same stack twice and
818
+ * charges for it. Lines already in the tail are counted, not repeated.
819
+ *
820
+ * @param {string} text
821
+ * @param {{tailLines?:number, errorLines?:number, includeWarnings?:boolean, maxLineChars?:number}} [opts]
822
+ * @returns {object}
823
+ */
824
+ export function summariseLog(text, opts = {}) {
825
+ const src = typeof text === 'string' ? text : '';
826
+ const {
827
+ tailLines = DEFAULT_SUMMARY_TAIL,
828
+ errorLines = DEFAULT_SUMMARY_ERRORS,
829
+ includeWarnings = true,
830
+ maxLineChars = MATCH_LINE_CHARS,
831
+ } = opts;
832
+
833
+ const tailCap = Math.max(0, Math.min(Math.floor(tailLines) || 0, MAX_TAIL_LINES));
834
+ const errCap = Math.max(0, Math.min(Math.floor(errorLines) || 0, MAX_TAIL_LINES));
835
+
836
+ const all = src === '' ? [] : src.split('\n').map((l) => (l.endsWith('\r') ? l.slice(0, -1) : l));
837
+ // A trailing newline produces one empty element; it is not a line.
838
+ if (all.length && all[all.length - 1] === '' && src.endsWith('\n')) all.pop();
839
+
840
+ const total = all.length;
841
+ const notable = [];
842
+ let errorCount = 0;
843
+ let warnCount = 0;
844
+ for (let i = 0; i < total; i += 1) {
845
+ const line = all[i];
846
+ const probe = line.length > MATCH_LINE_CHARS ? line.slice(0, MATCH_LINE_CHARS) : line;
847
+ let kind = null;
848
+ if (NOTABLE_PATTERNS.error.test(probe)) kind = 'error';
849
+ else if (includeWarnings && NOTABLE_PATTERNS.warn.test(probe)) kind = 'warn';
850
+ else if (NOTABLE_PATTERNS.trace.test(probe)) kind = 'trace';
851
+ if (!kind) continue;
852
+ if (kind === 'error') errorCount += 1;
853
+ if (kind === 'warn') warnCount += 1;
854
+ notable.push({ line: i + 1, kind, text: line });
855
+ }
856
+
857
+ const tailStart = Math.max(0, total - tailCap);
858
+ const tail = [];
859
+ for (let i = tailStart; i < total; i += 1) tail.push({ line: i + 1, text: all[i] });
860
+
861
+ // ⭐ Only the notable lines the tail does NOT already show, newest first —
862
+ // a crash is usually at the end, and printing it twice is pure waste.
863
+ const inTail = (n) => n > tailStart;
864
+ const notableOutsideTail = notable.filter((n) => !inTail(n.line));
865
+ const shownNotable = notableOutsideTail.slice(-errCap);
866
+ const notableDropped = notableOutsideTail.length - shownNotable.length;
867
+ const alsoInTail = notable.length - notableOutsideTail.length;
868
+
869
+ const shownNumbers = new Set([...shownNotable.map((n) => n.line), ...tail.map((t) => t.line)]);
870
+ const droppedLines = total - shownNumbers.size;
871
+
872
+ const blocks = [];
873
+ blocks.push(`log summary: ${total} lines, ${src.length} characters. ${errorCount} look like errors, ${warnCount} like warnings.`);
874
+ if (shownNotable.length) {
875
+ blocks.push(`── ${shownNotable.length} notable line${shownNotable.length === 1 ? '' : 's'} from earlier in the log ──`);
876
+ for (const n of shownNotable) blocks.push(`${n.line}: ${clipLine(n.text, maxLineChars)}`);
877
+ if (notableDropped > 0) blocks.push(`(${notableDropped} more notable lines were left out to fit errorLines=${errCap} — raise it or filter with read_log to see them.)`);
878
+ }
879
+ if (tail.length) {
880
+ blocks.push(`── the last ${tail.length} line${tail.length === 1 ? '' : 's'} ──`);
881
+ for (const t of tail) blocks.push(`${t.line}: ${clipLine(t.text, maxLineChars)}`);
882
+ }
883
+ if (droppedLines > 0) {
884
+ blocks.push(`── ${droppedLines} line${droppedLines === 1 ? '' : 's'} not shown ──`);
885
+ blocks.push(`They were neither notable nor near the end. Nothing was cut silently: total is ${total}, shown is ${shownNumbers.size}. Use read_log with "contains" to reach them.`);
886
+ }
887
+
888
+ return {
889
+ ok: true,
890
+ totalLines: total,
891
+ totalChars: src.length,
892
+ errorCount,
893
+ warnCount,
894
+ notable: shownNotable,
895
+ notableDropped,
896
+ notableAlsoInTail: alsoInTail,
897
+ tail,
898
+ shownLines: shownNumbers.size,
899
+ droppedLines,
900
+ text: blocks.join('\n'),
901
+ };
902
+ }
903
+
904
+ /* ─────────────────────────────── the tools ────────────────────────────── */
905
+
906
+ export const LOG_TAIL_TOOL_NAMES = Object.freeze(['read_log', 'wait_for_output', 'summarize_log']);
907
+
908
+ export function logTailToolSchemas() {
909
+ return [
910
+ {
911
+ type: 'function',
912
+ function: {
913
+ name: 'read_log',
914
+ description: [
915
+ 'Read what a background process has printed SINCE YOU LAST LOOKED. This is the tool for watching',
916
+ 'a running server: start it, make a request, then call this to see only the lines that appeared in between.',
917
+ 'Pass back the "cursor" from the previous reply and you get ONLY new output — calling it twice with',
918
+ 'nothing happening returns zero lines. Omit the cursor for a full read.',
919
+ 'Prefer this over re-reading the whole log with check_process: the log buffer holds up to 16,000',
920
+ 'characters and re-reading it every round spends your context on text you have already seen.',
921
+ 'Filter with "contains" (plain text, no escaping, always safe) or "matches" (a restricted regex:',
922
+ 'no quantified groups, no look-ahead, no back-references — those are refused because they can hang).',
923
+ 'Use "exclude" to drop noise like access-log lines you do not care about.',
924
+ ].join(' '),
925
+ parameters: {
926
+ type: 'object',
927
+ properties: {
928
+ id: { type: 'string', description: 'The background process id from start_process, e.g. "bg1".' },
929
+ cursor: { type: 'string', description: 'The "cursor" string from your previous read_log reply, verbatim. Leave it out on the first read.' },
930
+ contains: { type: 'string', description: 'Only lines containing this text (literal — no escaping needed). The safe, preferred filter.' },
931
+ exclude: { type: 'string', description: 'Drop lines containing this text. Use it to silence repetitive noise.' },
932
+ matches: { type: 'string', description: 'Only lines matching this regex, e.g. "ERROR|WARN". Restricted: a quantifier may not be applied to a group. Use "contains" unless you need alternation.' },
933
+ ignoreCase: { type: 'boolean', description: 'Match case-insensitively. Default false.' },
934
+ maxLines: { type: 'number', description: `Most lines to return, newest kept. Default ${DEFAULT_TAIL_LINES}.` },
935
+ },
936
+ required: ['id'],
937
+ },
938
+ },
939
+ },
940
+ {
941
+ type: 'function',
942
+ function: {
943
+ name: 'wait_for_output',
944
+ description: [
945
+ 'Block until a line appears in a background process\'s output, or until a timeout.',
946
+ 'Use it right after start_process instead of guessing how long a server takes to boot:',
947
+ 'wait_for_output {"id":"bg1","contains":"Ready in"} returns the moment the line is printed.',
948
+ 'It stops early — and tells you — if the process EXITS, so you never wait out a full timeout on',
949
+ 'something that already crashed. On a timeout it reports how long it waited, how many lines appeared,',
950
+ 'whether the log was still growing, and the last lines it did see, so you can tell "still booting"',
951
+ 'from "wrong pattern" from "dead".',
952
+ ].join(' '),
953
+ parameters: {
954
+ type: 'object',
955
+ properties: {
956
+ id: { type: 'string', description: 'The background process id from start_process, e.g. "bg1".' },
957
+ contains: { type: 'string', description: 'The literal text to wait for, e.g. "Ready in" or "Listening on". Preferred.' },
958
+ matches: { type: 'string', description: 'A restricted regex to wait for, e.g. "ready|listening". Use "contains" unless you need alternation.' },
959
+ ignoreCase: { type: 'boolean', description: 'Match case-insensitively. Default false.' },
960
+ cursor: { type: 'string', description: 'Only consider output after this cursor, so an older identical line does not match. Use the cursor from a previous read_log.' },
961
+ timeoutMs: { type: 'number', description: `How long to wait. Default ${DEFAULT_WAIT_MS}, ceiling ${MAX_WAIT_MS}.` },
962
+ },
963
+ required: ['id'],
964
+ },
965
+ },
966
+ },
967
+ {
968
+ type: 'function',
969
+ function: {
970
+ name: 'summarize_log',
971
+ description: [
972
+ 'Fold a long log down to what matters: the error and warning lines first, then the last lines,',
973
+ 'then an exact count of what was left out. Use this when a build or a test run printed far more',
974
+ 'than you want to read, instead of paging through it.',
975
+ 'It never truncates silently — every reply says how many lines exist, how many are shown, and how',
976
+ 'many are not, so you always know whether the thing you are looking for could still be hiding.',
977
+ ].join(' '),
978
+ parameters: {
979
+ type: 'object',
980
+ properties: {
981
+ id: { type: 'string', description: 'The background process id from start_process, e.g. "bg1".' },
982
+ tailLines: { type: 'number', description: `How many trailing lines to show. Default ${DEFAULT_SUMMARY_TAIL}.` },
983
+ errorLines: { type: 'number', description: `How many earlier error/warning lines to show. Default ${DEFAULT_SUMMARY_ERRORS}.` },
984
+ },
985
+ required: ['id'],
986
+ },
987
+ },
988
+ },
989
+ ];
990
+ }
991
+
992
+ /**
993
+ * Dispatch. `readLog(id)` is INJECTED — this module never reaches into
994
+ * `background.mjs`'s registry, so the lead can point these three tools at a
995
+ * background process, a file, or a test fixture without changing a line here.
996
+ *
997
+ * @param {string} name
998
+ * @param {object} args
999
+ * @param {{readLog?: (id:string)=>any, now?: ()=>number, sleep?: (ms:number)=>Promise<void>, signal?: AbortSignal}} [ctx]
1000
+ */
1001
+ export async function runLogTailTool(name, args = {}, ctx = {}) {
1002
+ const { readLog, now = Date.now, sleep = realSleep, signal = null } = ctx;
1003
+ if (typeof readLog !== 'function') {
1004
+ return { ok: false, error: 'the log tools are not wired to a log source in this run. Read the output with check_process instead.' };
1005
+ }
1006
+
1007
+ const id = String(args.id ?? '');
1008
+ if (id === '') {
1009
+ return { ok: false, error: 'which process? Pass the "id" that start_process returned, e.g. "bg1". check_process with no id lists what is running.' };
1010
+ }
1011
+
1012
+ if (name === 'wait_for_output') {
1013
+ return waitFor({
1014
+ read: () => readLog(id),
1015
+ contains: args.contains,
1016
+ matches: args.matches,
1017
+ exclude: args.exclude,
1018
+ ignoreCase: args.ignoreCase === true,
1019
+ cursor: typeof args.cursor === 'string' ? args.cursor : null,
1020
+ timeoutMs: args.timeoutMs,
1021
+ now,
1022
+ sleep,
1023
+ signal,
1024
+ });
1025
+ }
1026
+
1027
+ const got = normaliseRead(await readLog(id));
1028
+ if (!got.ok) return { ok: false, error: got.error };
1029
+
1030
+ switch (name) {
1031
+ case 'read_log': {
1032
+ const out = tailSince(got.text, {
1033
+ cursor: typeof args.cursor === 'string' ? args.cursor : null,
1034
+ // ⭐ A process that has stopped will never finish its last line, so the
1035
+ // partial is flushed rather than held forever.
1036
+ flush: got.running === false,
1037
+ contains: args.contains,
1038
+ exclude: args.exclude,
1039
+ matches: args.matches,
1040
+ ignoreCase: args.ignoreCase === true,
1041
+ maxLines: args.maxLines,
1042
+ now,
1043
+ });
1044
+ if (out.ok === false) return out;
1045
+ return { ...out, id, running: got.running ?? null };
1046
+ }
1047
+ case 'summarize_log':
1048
+ return { ...summariseLog(got.text, { tailLines: args.tailLines, errorLines: args.errorLines }), id, running: got.running ?? null };
1049
+ default:
1050
+ return { ok: false, error: `unknown log tool "${name}". This module offers: ${LOG_TAIL_TOOL_NAMES.join(', ')}.` };
1051
+ }
1052
+ }