acuvo-code 0.4.4 → 0.5.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/ENTERPRISE.md +1 -1
- package/lib/chat.mjs +521 -487
- package/lib/input-box.mjs +58 -0
- package/package.json +1 -1
package/ENTERPRISE.md
CHANGED
|
@@ -189,7 +189,7 @@ are the numbers to quote. Counting is the first thing a reviewer does.
|
|
|
189
189
|
|
|
190
190
|
⚠️ **This said "18 shipped files", then "41", then "90", then "101", then "108", and every
|
|
191
191
|
one went stale in turn.** The package ships **120 files — 118 in `lib/`, 2 in
|
|
192
|
-
`bin/` — about
|
|
192
|
+
`bin/` — about 78245 lines**, with **237 test files** beside them (counted 2026-08-22).
|
|
193
193
|
|
|
194
194
|
⭐ **AND THE 108 WENT STALE IN THE MOST INSTRUCTIVE WAY POSSIBLE: THREE OF THE FILES IT
|
|
195
195
|
MISSED WERE REACHABLE FROM NOTHING.** `wiring-reach.test.mjs` was naming
|
package/lib/chat.mjs
CHANGED
|
@@ -1,487 +1,521 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ── ⭐ THE INTERACTIVE SESSION ───────────────────────────────────────────────
|
|
3
|
-
*
|
|
4
|
-
* Every invocation of `acuvo "task"` started COLD: it re-gathered the workspace,
|
|
5
|
-
* rebuilt the prompt, and knew nothing about the last thing you asked. So the
|
|
6
|
-
* second instruction cost as much as the first, and "now do the same for the
|
|
7
|
-
* other file" was not a sentence you could say.
|
|
8
|
-
*
|
|
9
|
-
* ⭐ AND THE ECONOMICS ARE THE ARGUMENT, NOT JUST THE ERGONOMICS. Measured
|
|
10
|
-
* 2026-08-09: an identical prompt prefix cached at **97.2%**, dropping the call
|
|
11
|
-
* cost **4.3x** ($0.000836 → $0.000195). A session that APPENDS keeps that
|
|
12
|
-
* prefix intact, so every turn after the first is nearly free. A tool that
|
|
13
|
-
* rebuilds its prompt each time throws that away and looks identical from the
|
|
14
|
-
* outside — which is exactly why this is worth building rather than assuming.
|
|
15
|
-
*
|
|
16
|
-
* ── ⚠️ WHY NOT A FULL TUI ────────────────────────────────────────────────────
|
|
17
|
-
* `readline` and plain writes, no alternate screen buffer, no cursor addressing.
|
|
18
|
-
* A TUI that redraws breaks `>` redirection, breaks piping into a file, breaks
|
|
19
|
-
* `tee`, and breaks every terminal that is not the one it was tested in. The
|
|
20
|
-
* output here is append-only text, so a session transcript is a file you can
|
|
21
|
-
* keep. That is a deliberate trade of polish for portability.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import { createInterface } from 'node:readline';
|
|
25
|
-
import { readBoxedLine } from './input-box.mjs';
|
|
26
|
-
import { EXIT_INTERRUPTED } from './interrupt.mjs';
|
|
27
|
-
import { parseSlash, runSlashCommand } from './slash.mjs';
|
|
28
|
-
import { estimateMessagesTokens } from './compact.mjs';
|
|
29
|
-
|
|
30
|
-
/** What ends a session. `exit`/`quit` because both are muscle memory. */
|
|
31
|
-
const QUIT = new Set(['exit', 'quit', ':q', 'bye']);
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* ⚠️ CONTEXT GROWS UNBOUNDED AND A CODING SESSION IS THE WORST CASE — tool
|
|
35
|
-
* results carry whole files. Left alone, turn 30 sends everything from turns
|
|
36
|
-
* 1-29 and eventually 400s on a context-length error mid-thought.
|
|
37
|
-
*
|
|
38
|
-
* The trim keeps the SYSTEM message and the FIRST user message (the workspace
|
|
39
|
-
* context — the cacheable prefix, dropping it would cost more than it saves) and
|
|
40
|
-
* discards the oldest middle turns.
|
|
41
|
-
*/
|
|
42
|
-
export const MAX_HISTORY_MESSAGES = 40;
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* ── 💰⭐⭐⭐ THE TRIM WAS A CONTINUOUS SLIDE, AND IT COST ~90% OF THE CACHE ──
|
|
46
|
-
*
|
|
47
|
-
* `messages.slice(-keep)` re-slices on EVERY turn once a session passes the
|
|
48
|
-
* cap, so the third message of the prompt is different every time. Prefix
|
|
49
|
-
* caching matches from the first token and stops at the first difference — so
|
|
50
|
-
* everything after the 2-message head was re-bought at full price, every turn,
|
|
51
|
-
* for the rest of the session.
|
|
52
|
-
*
|
|
53
|
-
* ⭐ MEASURED (no credits, no network — a simulated 60-turn session counting how
|
|
54
|
-
* much of each prompt is byte-identical to the previous one):
|
|
55
|
-
*
|
|
56
|
-
* CURRENT slice(-38) cache 9.2% history 38-40
|
|
57
|
-
* stepped high=48 low=40 cache 74.5% history 38-48
|
|
58
|
-
* stepped high=56 low=40 cache 85.3% history 38-56
|
|
59
|
-
* stepped high=64 low=40 cache 89.7% history 38-64 <- shipped
|
|
60
|
-
* stepped high=72 low=40 cache 92.0% history 38-72
|
|
61
|
-
*
|
|
62
|
-
* ⚠️ THE HEAD BEING STABLE IS NOT THE SAME AS THE PROMPT BEING CACHED, and
|
|
63
|
-
* measuring position 0 alone would have said this code was fine. The 2-message
|
|
64
|
-
* head never moved; the cacheable prefix was still 2 messages out of 40.
|
|
65
|
-
*
|
|
66
|
-
* ⭐ `LOW` IS TODAY'S CAP ON PURPOSE, so this can only ever keep MORE history
|
|
67
|
-
* than the rule it replaces — never less. That makes it a pure win rather than
|
|
68
|
-
* a trade of memory for money, and it is why HIGH was raised instead of LOW
|
|
69
|
-
* being lowered (console shipped 16/8, halving its window; the CLI holds whole
|
|
70
|
-
* files in tool results and cannot afford to forget more).
|
|
71
|
-
*
|
|
72
|
-
* ⚠️ RAISING `HIGH` COSTS TOKENS PER REQUEST, and the ceiling that matters is
|
|
73
|
-
* not here: `turn.mjs` compacts above CONTEXT_BUDGET_TOKENS (96k), which
|
|
74
|
-
* `compact.mjs` warns is the moment the cache discount dies for good. This is a
|
|
75
|
-
* MESSAGE-COUNT backstop, not the token bound — 64 messages of ordinary turns
|
|
76
|
-
* sit far under 96k, but a session whose tool results carry whole files can
|
|
77
|
-
* approach it, and then compaction (with its own hysteresis) takes over. 72 and
|
|
78
|
-
* 80 buy 2 more points of cache for materially more of that risk, which is why
|
|
79
|
-
* 64 is the shipped number and not the best one in the table.
|
|
80
|
-
*/
|
|
81
|
-
export const HISTORY_HIGH_WATER = 64;
|
|
82
|
-
export const HISTORY_LOW_WATER = MAX_HISTORY_MESSAGES;
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* ⚠️ WELL UNDER `turn.mjs`'s CONTEXT_BUDGET_TOKENS (96,000), not near it. The
|
|
86
|
-
* request also carries the TOOL OFFER — measured at ~15,200 tokens for the
|
|
87
|
-
* CLI's 63 verbs — plus the system prompt, so history must leave room for both
|
|
88
|
-
* and still clear the line where compaction fires. 55k + ~15k offer + prompt
|
|
89
|
-
* sits comfortably inside 96k.
|
|
90
|
-
*
|
|
91
|
-
* ⭐ This is a CEILING, not a target: an ordinary session (~551 tokens/message,
|
|
92
|
-
* measured) reaches the 64-message cap at ~35k and never comes near it. It
|
|
93
|
-
* exists for the file-heavy session, which is exactly the one that would
|
|
94
|
-
* otherwise be pushed into permanent compaction by the higher message cap.
|
|
95
|
-
*/
|
|
96
|
-
export const HISTORY_TOKEN_CEILING = 55_000;
|
|
97
|
-
|
|
98
|
-
export function trimHistory(messages, max = HISTORY_HIGH_WATER) {
|
|
99
|
-
if (!Array.isArray(messages)) return messages;
|
|
100
|
-
/**
|
|
101
|
-
* ⚠️⚠️ THE GATE MUST ASK BOTH QUESTIONS, AND MY FIRST VERSION ASKED ONLY ONE.
|
|
102
|
-
* It read `messages.length <= max` and returned early — so the token ceiling
|
|
103
|
-
* below was unreachable for exactly the session it was written for: 64 heavy
|
|
104
|
-
* messages are under the COUNT cap and ~137,000 tokens, and the function
|
|
105
|
-
* handed them straight back. The test reported 133,491 tokens and I had to
|
|
106
|
-
* measure to find that the ceiling was never consulted at all rather than
|
|
107
|
-
* being wrong.
|
|
108
|
-
*/
|
|
109
|
-
const overBudget = estimateMessagesTokens(messages) > HISTORY_TOKEN_CEILING;
|
|
110
|
-
if (messages.length <= max && !overBudget) return messages;
|
|
111
|
-
const head = messages.slice(0, 2); // system + the context-bearing user turn
|
|
112
|
-
/**
|
|
113
|
-
* ⭐ THE HEAD MOVES IN STEPS, NOT EVERY TURN. `dropped` is a multiple of STEP,
|
|
114
|
-
* so it changes only when the conversation crosses the next boundary — and
|
|
115
|
-
* between boundaries the prompt is byte-identical to the previous turn's.
|
|
116
|
-
*
|
|
117
|
-
* ⚠️ NOT `length > HIGH ? slice(-LOW)`. That is still a continuous slide past
|
|
118
|
-
* the mark and is the exact mistake console's own guard records catching
|
|
119
|
-
* before it shipped.
|
|
120
|
-
*/
|
|
121
|
-
/**
|
|
122
|
-
* ⚠️⚠️ THE LOW WATER MUST BE DERIVED FROM `max`, NOT PINNED TO A CONSTANT,
|
|
123
|
-
* AND I ALMOST SHIPPED IT PINNED. My first version was
|
|
124
|
-
* `Math.min(HISTORY_LOW_WATER, max)`. `runChat`'s default `maxHistory` was
|
|
125
|
-
* MAX_HISTORY_MESSAGES (40), which equals HISTORY_LOW_WATER — so `step`
|
|
126
|
-
* became `max(1, 0)` = 1, and one-message steps ARE the continuous slide this
|
|
127
|
-
* whole change exists to remove. The fix would have measured 89.6% in a
|
|
128
|
-
* simulation and done NOTHING on the only path that calls it.
|
|
129
|
-
*
|
|
130
|
-
* ⭐ 0.625 is 40/64 — the shipped ratio, so the default keeps exactly the
|
|
131
|
-
* numbers that were measured, and any other `max` still steps properly
|
|
132
|
-
* instead of silently degrading to a slide.
|
|
133
|
-
*/
|
|
134
|
-
const low = Math.min(HISTORY_LOW_WATER, Math.floor(max * 0.625));
|
|
135
|
-
const step = Math.max(1, max - low);
|
|
136
|
-
const body = messages.slice(head.length);
|
|
137
|
-
const overflow = Math.max(0, messages.length - max);
|
|
138
|
-
let dropped = Math.ceil(overflow / step) * step;
|
|
139
|
-
/**
|
|
140
|
-
* ── ⚠️⚠️ AND A TOKEN CEILING, BECAUSE MESSAGE COUNT IS THE WRONG UNIT ──────
|
|
141
|
-
*
|
|
142
|
-
* MEASURED against the one real recorded session on disk (93,036 prompt
|
|
143
|
-
* tokens, 18 messages, `.acuvo/sessions/20260814-095636-99d4.json`):
|
|
144
|
-
*
|
|
145
|
-
* avg message ~551 tokens largest observed ~2,145 tokens
|
|
146
|
-
*
|
|
147
|
-
* at 40 msgs: typical ~22,000 worst case (all like the largest) ~85,800
|
|
148
|
-
* at 64 msgs: typical ~35,300 worst case ~137,300
|
|
149
|
-
*
|
|
150
|
-
* ⚠️ SO RAISING THE COUNT ALONE MOVES THE WORST CASE FROM JUST UNDER THE
|
|
151
|
-
* 96k CONTEXT BUDGET TO WELL OVER IT. Past that line `turn.mjs` compacts, and
|
|
152
|
-
* `compact.mjs` is explicit that compaction voids the cache discount and
|
|
153
|
-
* "once it starts, it never stops" — so a change made ENTIRELY to protect the
|
|
154
|
-
* cache would, in a file-heavy session, destroy it. A tool result carrying a
|
|
155
|
-
* whole file is not the exception in a coding session; it is the normal case.
|
|
156
|
-
*
|
|
157
|
-
* ⭐ THE CEILING DROPS IN THE SAME `step` BLOCKS. Any multiple of `step`
|
|
158
|
-
* keeps block alignment, so the prefix still changes only at boundaries and
|
|
159
|
-
* the caching win is untouched — this bounds the worst case without
|
|
160
|
-
* reintroducing a slide.
|
|
161
|
-
*/
|
|
162
|
-
const budget = Math.max(1, HISTORY_TOKEN_CEILING - estimateMessagesTokens(head));
|
|
163
|
-
while (dropped < body.length && estimateMessagesTokens(body.slice(dropped)) > budget) {
|
|
164
|
-
dropped += step;
|
|
165
|
-
}
|
|
166
|
-
let tail = body.slice(dropped);
|
|
167
|
-
/**
|
|
168
|
-
* ⚠️ A `tool` MESSAGE WITHOUT ITS `assistant` TOOL CALL IS A HARD 400 from
|
|
169
|
-
* every OpenAI-shaped provider — "tool_call_id did not have a preceding
|
|
170
|
-
* message with tool_calls". Slicing mid-exchange produces exactly that, and it
|
|
171
|
-
* would surface as a mysterious API error thirty turns into a session.
|
|
172
|
-
*/
|
|
173
|
-
while (tail.length > 0 && tail[0].role === 'tool') tail = tail.slice(1);
|
|
174
|
-
return [...head, ...tail];
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* One prompt line. Returns null on EOF (Ctrl-D, or a pipe that ran out).
|
|
179
|
-
*
|
|
180
|
-
* ⚠️ THE CLOSED CHECK IS NOT DEFENSIVE, IT IS THE PIPED CASE. Found by piping a
|
|
181
|
-
* list of prompts in: readline emits 'close' when the stream ends, and the NEXT
|
|
182
|
-
* `rl.question()` throws ERR_USE_AFTER_CLOSE. The first turn had already
|
|
183
|
-
* succeeded and written a real file, so the session crashed AFTER doing its job
|
|
184
|
-
* — the worst shape of failure, because the work looks lost.
|
|
185
|
-
*
|
|
186
|
-
* Scripted input matters beyond tests: piping a prompt list is how anyone would
|
|
187
|
-
* automate this.
|
|
188
|
-
*/
|
|
189
|
-
function ask(rl, prompt, state) {
|
|
190
|
-
if (state.closed) return Promise.resolve(null);
|
|
191
|
-
return new Promise((resolve) => {
|
|
192
|
-
let answered = false;
|
|
193
|
-
const onClose = () => { if (!answered) resolve(null); };
|
|
194
|
-
rl.once('close', onClose);
|
|
195
|
-
rl.question(prompt, (line) => {
|
|
196
|
-
answered = true;
|
|
197
|
-
rl.removeListener('close', onClose);
|
|
198
|
-
resolve(line);
|
|
199
|
-
});
|
|
200
|
-
});
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Run an interactive session.
|
|
205
|
-
*
|
|
206
|
-
* `runOne(task, priorMessages)` performs one turn and returns the session
|
|
207
|
-
* outcome — injected rather than imported so this loop is testable with a stub
|
|
208
|
-
* and never needs a model or a terminal in a test.
|
|
209
|
-
*/
|
|
210
|
-
/**
|
|
211
|
-
* ── ⚠️ PIPED INPUT IS A DIFFERENT PROBLEM AND NEEDED A DIFFERENT ANSWER ──────
|
|
212
|
-
* `readline` on a non-TTY DRAINS the stream as fast as it can and emits 'close'
|
|
213
|
-
* the moment it ends. The model call for turn 1 takes seconds, by which point
|
|
214
|
-
* the interface is already closed and every later prompt is lost — measured:
|
|
215
|
-
* a three-line pipe ran exactly ONE turn and exited quietly, which is worse than
|
|
216
|
-
* crashing because it looks like it worked.
|
|
217
|
-
*
|
|
218
|
-
* So a pipe is read WHOLE and replayed from a queue. A TTY keeps the real
|
|
219
|
-
* readline loop, where a human types the next line after seeing the last answer.
|
|
220
|
-
* Two input shapes, two mechanisms — pretending they are the same is what broke.
|
|
221
|
-
*/
|
|
222
|
-
async function readAllLines(input) {
|
|
223
|
-
const chunks = [];
|
|
224
|
-
for await (const chunk of input) chunks.push(chunk);
|
|
225
|
-
return Buffer.concat(chunks.map((c) => (typeof c === 'string' ? Buffer.from(c) : c)))
|
|
226
|
-
.toString('utf8')
|
|
227
|
-
.split(String.fromCharCode(10))
|
|
228
|
-
.map((l) => l.replace(String.fromCharCode(13), ''));
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* ── ⚠️⚠️⭐ READLINE EATS CTRL-C. MEASURED IN NODE'S OWN SOURCE ──────────────
|
|
233
|
-
*
|
|
234
|
-
* This is the finding that made an interrupt handler in `bin/acuvo.mjs`
|
|
235
|
-
* necessary-but-not-sufficient. Read out of `process.binding('natives')` on
|
|
236
|
-
* node v22.17.0, `internal/readline/interface.js`, the ttyWrite ctrl-key
|
|
237
|
-
* switch, verbatim:
|
|
238
|
-
*
|
|
239
|
-
* case 'c':
|
|
240
|
-
* if (this.listenerCount('SIGINT') > 0) {
|
|
241
|
-
* this.emit('SIGINT');
|
|
242
|
-
* } else {
|
|
243
|
-
* // This readline instance is finished
|
|
244
|
-
* this.close();
|
|
245
|
-
* this[kQuestionReject]?.(new AbortError('Aborted with Ctrl+C'));
|
|
246
|
-
* }
|
|
247
|
-
*
|
|
248
|
-
* ⚠️ So with a TTY readline open and NO `'SIGINT'` listener on the interface,
|
|
249
|
-
* Ctrl-C never reaches `process.on('SIGINT')` at all — readline just closes
|
|
250
|
-
* itself. The run in flight would have carried on to completion, for minutes,
|
|
251
|
-
* with the user's Ctrl-C having produced nothing on screen. That is strictly
|
|
252
|
-
* worse than the bug we set out to fix, and no amount of correct handling in
|
|
253
|
-
* `bin/acuvo.mjs` would have been reached.
|
|
254
|
-
*
|
|
255
|
-
* ⭐ So the interface takes a listener whose whole job is to hand the signal
|
|
256
|
-
* back to the process, where the one policy in `interrupt.mjs` decides between
|
|
257
|
-
* "stop after this round" and "quit now".
|
|
258
|
-
*
|
|
259
|
-
* ── ⚠️⚠️ AND A SYNTHETIC EMIT INTO AN EMPTY EMITTER DOES NOTHING ────────────
|
|
260
|
-
*
|
|
261
|
-
* `process.emit('SIGINT')` is plain `EventEmitter.emit` — it does NOT invoke
|
|
262
|
-
* the OS default action. `turn.mjs` installs the process signal handlers inside
|
|
263
|
-
* `runSession`, so before the first turn of a session there are ZERO listeners
|
|
264
|
-
* and the emit would return `false` having done absolutely nothing. Ctrl-C at
|
|
265
|
-
* the very first prompt would be inert.
|
|
266
|
-
*
|
|
267
|
-
* ⭐ Hence the count check and the explicit exit: **every path out of this
|
|
268
|
-
* function either aborts a run or ends the process.** That is the rule this
|
|
269
|
-
* whole feature is built on, and it is the one that is easy to break here.
|
|
270
|
-
*/
|
|
271
|
-
export function deliverInterrupt({
|
|
272
|
-
emit = (sig) => process.emit(sig),
|
|
273
|
-
listenerCount = (sig) => process.listenerCount(sig),
|
|
274
|
-
exit = (code) => process.exit(code),
|
|
275
|
-
} = {}) {
|
|
276
|
-
if (listenerCount('SIGINT') > 0) {
|
|
277
|
-
emit('SIGINT');
|
|
278
|
-
return 'delegated';
|
|
279
|
-
}
|
|
280
|
-
exit(EXIT_INTERRUPTED);
|
|
281
|
-
return 'exited';
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
export async function runChat({
|
|
285
|
-
runOne,
|
|
286
|
-
render,
|
|
287
|
-
input = process.stdin,
|
|
288
|
-
output = process.stdout,
|
|
289
|
-
banner = '',
|
|
290
|
-
/**
|
|
291
|
-
* ⚠️ THE HIGH WATER MARK, NOT THE OLD FLAT CAP. This default is what makes
|
|
292
|
-
* the stepped trim REACH the live session — see the note in `trimHistory`
|
|
293
|
-
* about the version of this fix that measured 89.6% and changed nothing.
|
|
294
|
-
*/
|
|
295
|
-
maxHistory = HISTORY_HIGH_WATER,
|
|
296
|
-
/**
|
|
297
|
-
* ⚠️ INJECTED SO A TEST CAN SEE IT. The real one exits the process, and a
|
|
298
|
-
* test that could not substitute it could only assert this feature by killing
|
|
299
|
-
* its own runner. Default is production behaviour, so no caller changes.
|
|
300
|
-
*/
|
|
301
|
-
onInterrupt = deliverInterrupt,
|
|
302
|
-
/**
|
|
303
|
-
* ── ⭐ THE `/` SURFACE'S ONE SEAM ─────────────────────────────────────────
|
|
304
|
-
*
|
|
305
|
-
* Providers for the things a command reports on — skills, MCP servers, spend,
|
|
306
|
-
* the model. `bin/` owns where those facts come from; this loop only asks, for
|
|
307
|
-
* the same reason `workspace.mjs` takes `claimPath` and `journal` injected
|
|
308
|
-
* rather than importing them.
|
|
309
|
-
*
|
|
310
|
-
* ⚠️ `{}` BY DEFAULT, NOT `null`. Every command degrades to "not available in
|
|
311
|
-
* this session" on a missing provider (see `slash.mjs`), so an embedder that
|
|
312
|
-
* wires nothing still gets a working `/help` instead of a crash.
|
|
313
|
-
*/
|
|
314
|
-
slashContext = {},
|
|
315
|
-
}) {
|
|
316
|
-
const interactive = input.isTTY === true;
|
|
317
|
-
|
|
318
|
-
// ⚠️ A pipe is drained up front — see readAllLines. Doing this lazily is what
|
|
319
|
-
// silently lost every prompt after the first.
|
|
320
|
-
const queued = interactive ? null : await readAllLines(input);
|
|
321
|
-
let queueIndex = 0;
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* ── ⚠️⚠️ READLINE IS GONE FROM THE INTERACTIVE PATH, AND IT HAD TO GO ──────
|
|
325
|
-
*
|
|
326
|
-
* `input-box.mjs` now owns the keyboard. Leaving the readline interface
|
|
327
|
-
* attached to the SAME stream was not merely redundant — both it and the box
|
|
328
|
-
* saw every Ctrl-C, so `onInterrupt` fired TWICE for one keypress. Measured,
|
|
329
|
-
* not theorised: a single `\x03` produced `['interrupt', 'interrupt']`.
|
|
330
|
-
*
|
|
331
|
-
* ⭐ A DOUBLE INTERRUPT IS NOT A COSMETIC BUG. The second Ctrl-C is the one
|
|
332
|
-
* that QUITS — so one press would have armed and fired the escape hatch in the
|
|
333
|
-
* same instant, ending a session the user meant only to nudge.
|
|
334
|
-
*
|
|
335
|
-
* The Node-internals note below about `question()` swallowing Ctrl-C is kept
|
|
336
|
-
* because it is why the box reads raw keys itself rather than asking readline
|
|
337
|
-
* for a line. It is history now, not a live constraint.
|
|
338
|
-
*/
|
|
339
|
-
const rl = null;
|
|
340
|
-
const state = { closed: false };
|
|
341
|
-
/**
|
|
342
|
-
* ⚠️ ATTACHED ONCE, NOT PER QUESTION — and the per-question version is why the
|
|
343
|
-
* first fix did not work. The stream can end WHILE the model call is in
|
|
344
|
-
* flight, when no question is pending and therefore no listener is attached;
|
|
345
|
-
* `close` fires into nothing, the flag stays false, and the next question
|
|
346
|
-
* throws anyway. A session-lifetime listener sees it whenever it happens.
|
|
347
|
-
*/
|
|
348
|
-
if (rl) rl.once('close', () => { state.closed = true; });
|
|
349
|
-
/**
|
|
350
|
-
* ⭐ THE ONE LINE THAT MAKES CTRL-C REACH THE RUN — see `deliverInterrupt`
|
|
351
|
-
* above for the Node source that proves it is needed. `on`, not `once`: an
|
|
352
|
-
* interactive session runs many turns and the SECOND Ctrl-C (the one that
|
|
353
|
-
* quits) has to arrive here too, or the escape hatch is a single-use one.
|
|
354
|
-
*/
|
|
355
|
-
if (rl) rl.on('SIGINT', () => { onInterrupt(); });
|
|
356
|
-
if (banner) output.write(`${banner}\n`);
|
|
357
|
-
// ⚠️ `/help` IS ADVERTISED IN THE ONE LINE EVERY SESSION PRINTS. A command
|
|
358
|
-
// surface nobody is told about is the same defect item 14 closed for `--help`:
|
|
359
|
-
// the feature worked and nothing a stranger would read mentioned it.
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐ THE INTERACTIVE SESSION ───────────────────────────────────────────────
|
|
3
|
+
*
|
|
4
|
+
* Every invocation of `acuvo "task"` started COLD: it re-gathered the workspace,
|
|
5
|
+
* rebuilt the prompt, and knew nothing about the last thing you asked. So the
|
|
6
|
+
* second instruction cost as much as the first, and "now do the same for the
|
|
7
|
+
* other file" was not a sentence you could say.
|
|
8
|
+
*
|
|
9
|
+
* ⭐ AND THE ECONOMICS ARE THE ARGUMENT, NOT JUST THE ERGONOMICS. Measured
|
|
10
|
+
* 2026-08-09: an identical prompt prefix cached at **97.2%**, dropping the call
|
|
11
|
+
* cost **4.3x** ($0.000836 → $0.000195). A session that APPENDS keeps that
|
|
12
|
+
* prefix intact, so every turn after the first is nearly free. A tool that
|
|
13
|
+
* rebuilds its prompt each time throws that away and looks identical from the
|
|
14
|
+
* outside — which is exactly why this is worth building rather than assuming.
|
|
15
|
+
*
|
|
16
|
+
* ── ⚠️ WHY NOT A FULL TUI ────────────────────────────────────────────────────
|
|
17
|
+
* `readline` and plain writes, no alternate screen buffer, no cursor addressing.
|
|
18
|
+
* A TUI that redraws breaks `>` redirection, breaks piping into a file, breaks
|
|
19
|
+
* `tee`, and breaks every terminal that is not the one it was tested in. The
|
|
20
|
+
* output here is append-only text, so a session transcript is a file you can
|
|
21
|
+
* keep. That is a deliberate trade of polish for portability.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createInterface } from 'node:readline';
|
|
25
|
+
import { readBoxedLine, pinRegion } from './input-box.mjs';
|
|
26
|
+
import { EXIT_INTERRUPTED } from './interrupt.mjs';
|
|
27
|
+
import { parseSlash, runSlashCommand } from './slash.mjs';
|
|
28
|
+
import { estimateMessagesTokens } from './compact.mjs';
|
|
29
|
+
|
|
30
|
+
/** What ends a session. `exit`/`quit` because both are muscle memory. */
|
|
31
|
+
const QUIT = new Set(['exit', 'quit', ':q', 'bye']);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* ⚠️ CONTEXT GROWS UNBOUNDED AND A CODING SESSION IS THE WORST CASE — tool
|
|
35
|
+
* results carry whole files. Left alone, turn 30 sends everything from turns
|
|
36
|
+
* 1-29 and eventually 400s on a context-length error mid-thought.
|
|
37
|
+
*
|
|
38
|
+
* The trim keeps the SYSTEM message and the FIRST user message (the workspace
|
|
39
|
+
* context — the cacheable prefix, dropping it would cost more than it saves) and
|
|
40
|
+
* discards the oldest middle turns.
|
|
41
|
+
*/
|
|
42
|
+
export const MAX_HISTORY_MESSAGES = 40;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* ── 💰⭐⭐⭐ THE TRIM WAS A CONTINUOUS SLIDE, AND IT COST ~90% OF THE CACHE ──
|
|
46
|
+
*
|
|
47
|
+
* `messages.slice(-keep)` re-slices on EVERY turn once a session passes the
|
|
48
|
+
* cap, so the third message of the prompt is different every time. Prefix
|
|
49
|
+
* caching matches from the first token and stops at the first difference — so
|
|
50
|
+
* everything after the 2-message head was re-bought at full price, every turn,
|
|
51
|
+
* for the rest of the session.
|
|
52
|
+
*
|
|
53
|
+
* ⭐ MEASURED (no credits, no network — a simulated 60-turn session counting how
|
|
54
|
+
* much of each prompt is byte-identical to the previous one):
|
|
55
|
+
*
|
|
56
|
+
* CURRENT slice(-38) cache 9.2% history 38-40
|
|
57
|
+
* stepped high=48 low=40 cache 74.5% history 38-48
|
|
58
|
+
* stepped high=56 low=40 cache 85.3% history 38-56
|
|
59
|
+
* stepped high=64 low=40 cache 89.7% history 38-64 <- shipped
|
|
60
|
+
* stepped high=72 low=40 cache 92.0% history 38-72
|
|
61
|
+
*
|
|
62
|
+
* ⚠️ THE HEAD BEING STABLE IS NOT THE SAME AS THE PROMPT BEING CACHED, and
|
|
63
|
+
* measuring position 0 alone would have said this code was fine. The 2-message
|
|
64
|
+
* head never moved; the cacheable prefix was still 2 messages out of 40.
|
|
65
|
+
*
|
|
66
|
+
* ⭐ `LOW` IS TODAY'S CAP ON PURPOSE, so this can only ever keep MORE history
|
|
67
|
+
* than the rule it replaces — never less. That makes it a pure win rather than
|
|
68
|
+
* a trade of memory for money, and it is why HIGH was raised instead of LOW
|
|
69
|
+
* being lowered (console shipped 16/8, halving its window; the CLI holds whole
|
|
70
|
+
* files in tool results and cannot afford to forget more).
|
|
71
|
+
*
|
|
72
|
+
* ⚠️ RAISING `HIGH` COSTS TOKENS PER REQUEST, and the ceiling that matters is
|
|
73
|
+
* not here: `turn.mjs` compacts above CONTEXT_BUDGET_TOKENS (96k), which
|
|
74
|
+
* `compact.mjs` warns is the moment the cache discount dies for good. This is a
|
|
75
|
+
* MESSAGE-COUNT backstop, not the token bound — 64 messages of ordinary turns
|
|
76
|
+
* sit far under 96k, but a session whose tool results carry whole files can
|
|
77
|
+
* approach it, and then compaction (with its own hysteresis) takes over. 72 and
|
|
78
|
+
* 80 buy 2 more points of cache for materially more of that risk, which is why
|
|
79
|
+
* 64 is the shipped number and not the best one in the table.
|
|
80
|
+
*/
|
|
81
|
+
export const HISTORY_HIGH_WATER = 64;
|
|
82
|
+
export const HISTORY_LOW_WATER = MAX_HISTORY_MESSAGES;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* ⚠️ WELL UNDER `turn.mjs`'s CONTEXT_BUDGET_TOKENS (96,000), not near it. The
|
|
86
|
+
* request also carries the TOOL OFFER — measured at ~15,200 tokens for the
|
|
87
|
+
* CLI's 63 verbs — plus the system prompt, so history must leave room for both
|
|
88
|
+
* and still clear the line where compaction fires. 55k + ~15k offer + prompt
|
|
89
|
+
* sits comfortably inside 96k.
|
|
90
|
+
*
|
|
91
|
+
* ⭐ This is a CEILING, not a target: an ordinary session (~551 tokens/message,
|
|
92
|
+
* measured) reaches the 64-message cap at ~35k and never comes near it. It
|
|
93
|
+
* exists for the file-heavy session, which is exactly the one that would
|
|
94
|
+
* otherwise be pushed into permanent compaction by the higher message cap.
|
|
95
|
+
*/
|
|
96
|
+
export const HISTORY_TOKEN_CEILING = 55_000;
|
|
97
|
+
|
|
98
|
+
export function trimHistory(messages, max = HISTORY_HIGH_WATER) {
|
|
99
|
+
if (!Array.isArray(messages)) return messages;
|
|
100
|
+
/**
|
|
101
|
+
* ⚠️⚠️ THE GATE MUST ASK BOTH QUESTIONS, AND MY FIRST VERSION ASKED ONLY ONE.
|
|
102
|
+
* It read `messages.length <= max` and returned early — so the token ceiling
|
|
103
|
+
* below was unreachable for exactly the session it was written for: 64 heavy
|
|
104
|
+
* messages are under the COUNT cap and ~137,000 tokens, and the function
|
|
105
|
+
* handed them straight back. The test reported 133,491 tokens and I had to
|
|
106
|
+
* measure to find that the ceiling was never consulted at all rather than
|
|
107
|
+
* being wrong.
|
|
108
|
+
*/
|
|
109
|
+
const overBudget = estimateMessagesTokens(messages) > HISTORY_TOKEN_CEILING;
|
|
110
|
+
if (messages.length <= max && !overBudget) return messages;
|
|
111
|
+
const head = messages.slice(0, 2); // system + the context-bearing user turn
|
|
112
|
+
/**
|
|
113
|
+
* ⭐ THE HEAD MOVES IN STEPS, NOT EVERY TURN. `dropped` is a multiple of STEP,
|
|
114
|
+
* so it changes only when the conversation crosses the next boundary — and
|
|
115
|
+
* between boundaries the prompt is byte-identical to the previous turn's.
|
|
116
|
+
*
|
|
117
|
+
* ⚠️ NOT `length > HIGH ? slice(-LOW)`. That is still a continuous slide past
|
|
118
|
+
* the mark and is the exact mistake console's own guard records catching
|
|
119
|
+
* before it shipped.
|
|
120
|
+
*/
|
|
121
|
+
/**
|
|
122
|
+
* ⚠️⚠️ THE LOW WATER MUST BE DERIVED FROM `max`, NOT PINNED TO A CONSTANT,
|
|
123
|
+
* AND I ALMOST SHIPPED IT PINNED. My first version was
|
|
124
|
+
* `Math.min(HISTORY_LOW_WATER, max)`. `runChat`'s default `maxHistory` was
|
|
125
|
+
* MAX_HISTORY_MESSAGES (40), which equals HISTORY_LOW_WATER — so `step`
|
|
126
|
+
* became `max(1, 0)` = 1, and one-message steps ARE the continuous slide this
|
|
127
|
+
* whole change exists to remove. The fix would have measured 89.6% in a
|
|
128
|
+
* simulation and done NOTHING on the only path that calls it.
|
|
129
|
+
*
|
|
130
|
+
* ⭐ 0.625 is 40/64 — the shipped ratio, so the default keeps exactly the
|
|
131
|
+
* numbers that were measured, and any other `max` still steps properly
|
|
132
|
+
* instead of silently degrading to a slide.
|
|
133
|
+
*/
|
|
134
|
+
const low = Math.min(HISTORY_LOW_WATER, Math.floor(max * 0.625));
|
|
135
|
+
const step = Math.max(1, max - low);
|
|
136
|
+
const body = messages.slice(head.length);
|
|
137
|
+
const overflow = Math.max(0, messages.length - max);
|
|
138
|
+
let dropped = Math.ceil(overflow / step) * step;
|
|
139
|
+
/**
|
|
140
|
+
* ── ⚠️⚠️ AND A TOKEN CEILING, BECAUSE MESSAGE COUNT IS THE WRONG UNIT ──────
|
|
141
|
+
*
|
|
142
|
+
* MEASURED against the one real recorded session on disk (93,036 prompt
|
|
143
|
+
* tokens, 18 messages, `.acuvo/sessions/20260814-095636-99d4.json`):
|
|
144
|
+
*
|
|
145
|
+
* avg message ~551 tokens largest observed ~2,145 tokens
|
|
146
|
+
*
|
|
147
|
+
* at 40 msgs: typical ~22,000 worst case (all like the largest) ~85,800
|
|
148
|
+
* at 64 msgs: typical ~35,300 worst case ~137,300
|
|
149
|
+
*
|
|
150
|
+
* ⚠️ SO RAISING THE COUNT ALONE MOVES THE WORST CASE FROM JUST UNDER THE
|
|
151
|
+
* 96k CONTEXT BUDGET TO WELL OVER IT. Past that line `turn.mjs` compacts, and
|
|
152
|
+
* `compact.mjs` is explicit that compaction voids the cache discount and
|
|
153
|
+
* "once it starts, it never stops" — so a change made ENTIRELY to protect the
|
|
154
|
+
* cache would, in a file-heavy session, destroy it. A tool result carrying a
|
|
155
|
+
* whole file is not the exception in a coding session; it is the normal case.
|
|
156
|
+
*
|
|
157
|
+
* ⭐ THE CEILING DROPS IN THE SAME `step` BLOCKS. Any multiple of `step`
|
|
158
|
+
* keeps block alignment, so the prefix still changes only at boundaries and
|
|
159
|
+
* the caching win is untouched — this bounds the worst case without
|
|
160
|
+
* reintroducing a slide.
|
|
161
|
+
*/
|
|
162
|
+
const budget = Math.max(1, HISTORY_TOKEN_CEILING - estimateMessagesTokens(head));
|
|
163
|
+
while (dropped < body.length && estimateMessagesTokens(body.slice(dropped)) > budget) {
|
|
164
|
+
dropped += step;
|
|
165
|
+
}
|
|
166
|
+
let tail = body.slice(dropped);
|
|
167
|
+
/**
|
|
168
|
+
* ⚠️ A `tool` MESSAGE WITHOUT ITS `assistant` TOOL CALL IS A HARD 400 from
|
|
169
|
+
* every OpenAI-shaped provider — "tool_call_id did not have a preceding
|
|
170
|
+
* message with tool_calls". Slicing mid-exchange produces exactly that, and it
|
|
171
|
+
* would surface as a mysterious API error thirty turns into a session.
|
|
172
|
+
*/
|
|
173
|
+
while (tail.length > 0 && tail[0].role === 'tool') tail = tail.slice(1);
|
|
174
|
+
return [...head, ...tail];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* One prompt line. Returns null on EOF (Ctrl-D, or a pipe that ran out).
|
|
179
|
+
*
|
|
180
|
+
* ⚠️ THE CLOSED CHECK IS NOT DEFENSIVE, IT IS THE PIPED CASE. Found by piping a
|
|
181
|
+
* list of prompts in: readline emits 'close' when the stream ends, and the NEXT
|
|
182
|
+
* `rl.question()` throws ERR_USE_AFTER_CLOSE. The first turn had already
|
|
183
|
+
* succeeded and written a real file, so the session crashed AFTER doing its job
|
|
184
|
+
* — the worst shape of failure, because the work looks lost.
|
|
185
|
+
*
|
|
186
|
+
* Scripted input matters beyond tests: piping a prompt list is how anyone would
|
|
187
|
+
* automate this.
|
|
188
|
+
*/
|
|
189
|
+
function ask(rl, prompt, state) {
|
|
190
|
+
if (state.closed) return Promise.resolve(null);
|
|
191
|
+
return new Promise((resolve) => {
|
|
192
|
+
let answered = false;
|
|
193
|
+
const onClose = () => { if (!answered) resolve(null); };
|
|
194
|
+
rl.once('close', onClose);
|
|
195
|
+
rl.question(prompt, (line) => {
|
|
196
|
+
answered = true;
|
|
197
|
+
rl.removeListener('close', onClose);
|
|
198
|
+
resolve(line);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Run an interactive session.
|
|
205
|
+
*
|
|
206
|
+
* `runOne(task, priorMessages)` performs one turn and returns the session
|
|
207
|
+
* outcome — injected rather than imported so this loop is testable with a stub
|
|
208
|
+
* and never needs a model or a terminal in a test.
|
|
209
|
+
*/
|
|
210
|
+
/**
|
|
211
|
+
* ── ⚠️ PIPED INPUT IS A DIFFERENT PROBLEM AND NEEDED A DIFFERENT ANSWER ──────
|
|
212
|
+
* `readline` on a non-TTY DRAINS the stream as fast as it can and emits 'close'
|
|
213
|
+
* the moment it ends. The model call for turn 1 takes seconds, by which point
|
|
214
|
+
* the interface is already closed and every later prompt is lost — measured:
|
|
215
|
+
* a three-line pipe ran exactly ONE turn and exited quietly, which is worse than
|
|
216
|
+
* crashing because it looks like it worked.
|
|
217
|
+
*
|
|
218
|
+
* So a pipe is read WHOLE and replayed from a queue. A TTY keeps the real
|
|
219
|
+
* readline loop, where a human types the next line after seeing the last answer.
|
|
220
|
+
* Two input shapes, two mechanisms — pretending they are the same is what broke.
|
|
221
|
+
*/
|
|
222
|
+
async function readAllLines(input) {
|
|
223
|
+
const chunks = [];
|
|
224
|
+
for await (const chunk of input) chunks.push(chunk);
|
|
225
|
+
return Buffer.concat(chunks.map((c) => (typeof c === 'string' ? Buffer.from(c) : c)))
|
|
226
|
+
.toString('utf8')
|
|
227
|
+
.split(String.fromCharCode(10))
|
|
228
|
+
.map((l) => l.replace(String.fromCharCode(13), ''));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* ── ⚠️⚠️⭐ READLINE EATS CTRL-C. MEASURED IN NODE'S OWN SOURCE ──────────────
|
|
233
|
+
*
|
|
234
|
+
* This is the finding that made an interrupt handler in `bin/acuvo.mjs`
|
|
235
|
+
* necessary-but-not-sufficient. Read out of `process.binding('natives')` on
|
|
236
|
+
* node v22.17.0, `internal/readline/interface.js`, the ttyWrite ctrl-key
|
|
237
|
+
* switch, verbatim:
|
|
238
|
+
*
|
|
239
|
+
* case 'c':
|
|
240
|
+
* if (this.listenerCount('SIGINT') > 0) {
|
|
241
|
+
* this.emit('SIGINT');
|
|
242
|
+
* } else {
|
|
243
|
+
* // This readline instance is finished
|
|
244
|
+
* this.close();
|
|
245
|
+
* this[kQuestionReject]?.(new AbortError('Aborted with Ctrl+C'));
|
|
246
|
+
* }
|
|
247
|
+
*
|
|
248
|
+
* ⚠️ So with a TTY readline open and NO `'SIGINT'` listener on the interface,
|
|
249
|
+
* Ctrl-C never reaches `process.on('SIGINT')` at all — readline just closes
|
|
250
|
+
* itself. The run in flight would have carried on to completion, for minutes,
|
|
251
|
+
* with the user's Ctrl-C having produced nothing on screen. That is strictly
|
|
252
|
+
* worse than the bug we set out to fix, and no amount of correct handling in
|
|
253
|
+
* `bin/acuvo.mjs` would have been reached.
|
|
254
|
+
*
|
|
255
|
+
* ⭐ So the interface takes a listener whose whole job is to hand the signal
|
|
256
|
+
* back to the process, where the one policy in `interrupt.mjs` decides between
|
|
257
|
+
* "stop after this round" and "quit now".
|
|
258
|
+
*
|
|
259
|
+
* ── ⚠️⚠️ AND A SYNTHETIC EMIT INTO AN EMPTY EMITTER DOES NOTHING ────────────
|
|
260
|
+
*
|
|
261
|
+
* `process.emit('SIGINT')` is plain `EventEmitter.emit` — it does NOT invoke
|
|
262
|
+
* the OS default action. `turn.mjs` installs the process signal handlers inside
|
|
263
|
+
* `runSession`, so before the first turn of a session there are ZERO listeners
|
|
264
|
+
* and the emit would return `false` having done absolutely nothing. Ctrl-C at
|
|
265
|
+
* the very first prompt would be inert.
|
|
266
|
+
*
|
|
267
|
+
* ⭐ Hence the count check and the explicit exit: **every path out of this
|
|
268
|
+
* function either aborts a run or ends the process.** That is the rule this
|
|
269
|
+
* whole feature is built on, and it is the one that is easy to break here.
|
|
270
|
+
*/
|
|
271
|
+
export function deliverInterrupt({
|
|
272
|
+
emit = (sig) => process.emit(sig),
|
|
273
|
+
listenerCount = (sig) => process.listenerCount(sig),
|
|
274
|
+
exit = (code) => process.exit(code),
|
|
275
|
+
} = {}) {
|
|
276
|
+
if (listenerCount('SIGINT') > 0) {
|
|
277
|
+
emit('SIGINT');
|
|
278
|
+
return 'delegated';
|
|
279
|
+
}
|
|
280
|
+
exit(EXIT_INTERRUPTED);
|
|
281
|
+
return 'exited';
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export async function runChat({
|
|
285
|
+
runOne,
|
|
286
|
+
render,
|
|
287
|
+
input = process.stdin,
|
|
288
|
+
output = process.stdout,
|
|
289
|
+
banner = '',
|
|
290
|
+
/**
|
|
291
|
+
* ⚠️ THE HIGH WATER MARK, NOT THE OLD FLAT CAP. This default is what makes
|
|
292
|
+
* the stepped trim REACH the live session — see the note in `trimHistory`
|
|
293
|
+
* about the version of this fix that measured 89.6% and changed nothing.
|
|
294
|
+
*/
|
|
295
|
+
maxHistory = HISTORY_HIGH_WATER,
|
|
296
|
+
/**
|
|
297
|
+
* ⚠️ INJECTED SO A TEST CAN SEE IT. The real one exits the process, and a
|
|
298
|
+
* test that could not substitute it could only assert this feature by killing
|
|
299
|
+
* its own runner. Default is production behaviour, so no caller changes.
|
|
300
|
+
*/
|
|
301
|
+
onInterrupt = deliverInterrupt,
|
|
302
|
+
/**
|
|
303
|
+
* ── ⭐ THE `/` SURFACE'S ONE SEAM ─────────────────────────────────────────
|
|
304
|
+
*
|
|
305
|
+
* Providers for the things a command reports on — skills, MCP servers, spend,
|
|
306
|
+
* the model. `bin/` owns where those facts come from; this loop only asks, for
|
|
307
|
+
* the same reason `workspace.mjs` takes `claimPath` and `journal` injected
|
|
308
|
+
* rather than importing them.
|
|
309
|
+
*
|
|
310
|
+
* ⚠️ `{}` BY DEFAULT, NOT `null`. Every command degrades to "not available in
|
|
311
|
+
* this session" on a missing provider (see `slash.mjs`), so an embedder that
|
|
312
|
+
* wires nothing still gets a working `/help` instead of a crash.
|
|
313
|
+
*/
|
|
314
|
+
slashContext = {},
|
|
315
|
+
}) {
|
|
316
|
+
const interactive = input.isTTY === true;
|
|
317
|
+
|
|
318
|
+
// ⚠️ A pipe is drained up front — see readAllLines. Doing this lazily is what
|
|
319
|
+
// silently lost every prompt after the first.
|
|
320
|
+
const queued = interactive ? null : await readAllLines(input);
|
|
321
|
+
let queueIndex = 0;
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* ── ⚠️⚠️ READLINE IS GONE FROM THE INTERACTIVE PATH, AND IT HAD TO GO ──────
|
|
325
|
+
*
|
|
326
|
+
* `input-box.mjs` now owns the keyboard. Leaving the readline interface
|
|
327
|
+
* attached to the SAME stream was not merely redundant — both it and the box
|
|
328
|
+
* saw every Ctrl-C, so `onInterrupt` fired TWICE for one keypress. Measured,
|
|
329
|
+
* not theorised: a single `\x03` produced `['interrupt', 'interrupt']`.
|
|
330
|
+
*
|
|
331
|
+
* ⭐ A DOUBLE INTERRUPT IS NOT A COSMETIC BUG. The second Ctrl-C is the one
|
|
332
|
+
* that QUITS — so one press would have armed and fired the escape hatch in the
|
|
333
|
+
* same instant, ending a session the user meant only to nudge.
|
|
334
|
+
*
|
|
335
|
+
* The Node-internals note below about `question()` swallowing Ctrl-C is kept
|
|
336
|
+
* because it is why the box reads raw keys itself rather than asking readline
|
|
337
|
+
* for a line. It is history now, not a live constraint.
|
|
338
|
+
*/
|
|
339
|
+
const rl = null;
|
|
340
|
+
const state = { closed: false };
|
|
341
|
+
/**
|
|
342
|
+
* ⚠️ ATTACHED ONCE, NOT PER QUESTION — and the per-question version is why the
|
|
343
|
+
* first fix did not work. The stream can end WHILE the model call is in
|
|
344
|
+
* flight, when no question is pending and therefore no listener is attached;
|
|
345
|
+
* `close` fires into nothing, the flag stays false, and the next question
|
|
346
|
+
* throws anyway. A session-lifetime listener sees it whenever it happens.
|
|
347
|
+
*/
|
|
348
|
+
if (rl) rl.once('close', () => { state.closed = true; });
|
|
349
|
+
/**
|
|
350
|
+
* ⭐ THE ONE LINE THAT MAKES CTRL-C REACH THE RUN — see `deliverInterrupt`
|
|
351
|
+
* above for the Node source that proves it is needed. `on`, not `once`: an
|
|
352
|
+
* interactive session runs many turns and the SECOND Ctrl-C (the one that
|
|
353
|
+
* quits) has to arrive here too, or the escape hatch is a single-use one.
|
|
354
|
+
*/
|
|
355
|
+
if (rl) rl.on('SIGINT', () => { onInterrupt(); });
|
|
356
|
+
if (banner) output.write(`${banner}\n`);
|
|
357
|
+
// ⚠️ `/help` IS ADVERTISED IN THE ONE LINE EVERY SESSION PRINTS. A command
|
|
358
|
+
// surface nobody is told about is the same defect item 14 closed for `--help`:
|
|
359
|
+
// the feature worked and nothing a stranger would read mentioned it.
|
|
360
|
+
/**
|
|
361
|
+
* ⚠️ A BLANK LINE BEFORE IT, not just after. Roman: *"move it one line down."*
|
|
362
|
+
* Pressed against the banner it read as a fifth detail row rather than as an
|
|
363
|
+
* instruction addressed to the person.
|
|
364
|
+
*/
|
|
365
|
+
output.write('\nType what you want done. "/help" for commands, "exit" to leave.\n\n');
|
|
366
|
+
|
|
367
|
+
let history = null;
|
|
368
|
+
let turns = 0;
|
|
369
|
+
/**
|
|
370
|
+
* ⭐ SET BY `/skills <name>`, CONSUMED BY THE NEXT REAL TURN AND THEN CLEARED.
|
|
371
|
+
* A skill that was printed to the terminal would look loaded and be invisible
|
|
372
|
+
* to the model; this is the variable that makes the verb real.
|
|
373
|
+
*/
|
|
374
|
+
let pendingInject = null;
|
|
375
|
+
/** Lines the user has submitted this session — the box's Up/Down history. */
|
|
376
|
+
const typed = [];
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* ── ⭐⭐⭐ THE BOX IS PINNED TO THE BOTTOM OF THE SCREEN ────────────────────
|
|
380
|
+
*
|
|
381
|
+
* Roman: *"we need that prompt box stuck down the bottom, it is professional."*
|
|
382
|
+
*
|
|
383
|
+
* `pinRegion` reserves the last three rows, so output scrolls ABOVE the box
|
|
384
|
+
* and the box itself never moves. Off a TTY, in CI, on a short terminal, or
|
|
385
|
+
* with ACUVO_NO_PIN=1 it does nothing at all.
|
|
386
|
+
*
|
|
387
|
+
* ⚠️⚠️ RELEASED ON EVERY PATH OUT, INCLUDING THE ONES NOBODY PLANS FOR. A
|
|
388
|
+
* process that exits with a scroll region still set leaves the user with a
|
|
389
|
+
* terminal that scrolls inside a box until they type `reset` blind — the same
|
|
390
|
+
* class of harm as leaving raw mode on. The `finally` covers a normal end and
|
|
391
|
+
* a throw; the signal handlers cover Ctrl-C and `kill`.
|
|
392
|
+
*/
|
|
393
|
+
const pin = interactive ? pinRegion(output) : { enabled: false, release() {} };
|
|
394
|
+
const releasePin = () => pin.release();
|
|
395
|
+
if (pin.enabled) {
|
|
396
|
+
process.once('exit', releasePin);
|
|
397
|
+
process.once('SIGINT', releasePin);
|
|
398
|
+
process.once('SIGTERM', releasePin);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
try {
|
|
402
|
+
for (;;) {
|
|
403
|
+
/**
|
|
404
|
+
* ── ⭐⭐ THE INPUT IS A BOX, BECAUSE A BARE `› ` IS NOT A PLACE TO TYPE ──
|
|
405
|
+
*
|
|
406
|
+
* Roman, comparing against a real Claude Code screenshot: *"you can see
|
|
407
|
+
* the box where you type, acuvo doesn't have that."* He is right — the
|
|
408
|
+
* prompt was two characters floating in the scrollback, which reads as
|
|
409
|
+
* output rather than as somewhere input goes.
|
|
410
|
+
*
|
|
411
|
+
* ⚠️ THE BOX OPENS BEFORE THE LINE AND CLOSES AFTER IT, deliberately.
|
|
412
|
+
* Drawing all four sides up front needs absolute cursor control, and
|
|
413
|
+
* readline owns the cursor once `question()` starts — every version of
|
|
414
|
+
* that fights the line editor the moment input wraps, a history entry is
|
|
415
|
+
* recalled, or the window is resized. Opening on entry and closing on
|
|
416
|
+
* submit needs no cursor math at all, survives all three, and leaves a
|
|
417
|
+
* transcript where each turn is visibly a closed unit.
|
|
418
|
+
*
|
|
419
|
+
* ⚠️ WIDTH IS READ FRESH EACH TURN — a terminal resized mid-session would
|
|
420
|
+
* otherwise draw rules at the old width for the rest of the run. Clamped,
|
|
421
|
+
* because `columns` is `undefined` when stdout is not a TTY and enormous
|
|
422
|
+
* when someone maximises on an ultrawide.
|
|
423
|
+
*/
|
|
424
|
+
let line;
|
|
425
|
+
if (interactive) {
|
|
426
|
+
output.write('\n');
|
|
427
|
+
const got = await readBoxedLine({
|
|
428
|
+
input,
|
|
429
|
+
output,
|
|
430
|
+
history: typed,
|
|
431
|
+
onInterrupt,
|
|
432
|
+
});
|
|
433
|
+
line = got.value;
|
|
434
|
+
} else {
|
|
435
|
+
line = queueIndex < queued.length ? queued[queueIndex++] : null;
|
|
436
|
+
}
|
|
437
|
+
// Echo a piped prompt so a scripted transcript reads like a session.
|
|
438
|
+
if (!interactive && line !== null && line.trim()) output.write(`› ${line.trim()}
|
|
439
|
+
`);
|
|
440
|
+
// ⚠️ EOF is not an error. A closed pipe or Ctrl-D ends the session the
|
|
441
|
+
// same way "exit" does — treating it as a fault would print a stack trace
|
|
442
|
+
// at the end of every scripted run.
|
|
443
|
+
if (line === null) break;
|
|
444
|
+
const task = line.trim();
|
|
445
|
+
if (!task) continue;
|
|
446
|
+
// ⚠️ Deduped against the PREVIOUS entry only: pressing Up should walk
|
|
447
|
+
// distinct instructions, not scroll through five copies of `npm test`.
|
|
448
|
+
if (typed[typed.length - 1] !== task) typed.push(task);
|
|
449
|
+
if (QUIT.has(task.toLowerCase())) break;
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* ── ⭐ THE `/` SURFACE, BEFORE ANYTHING IS SENT TO A MODEL ────────────
|
|
453
|
+
*
|
|
454
|
+
* ⚠️ IT COSTS NOTHING AND MUST NOT COUNT AS A TURN. `/cost` is asked
|
|
455
|
+
* precisely by somebody watching their spend, and answering it by
|
|
456
|
+
* incrementing the turn counter and appending to the history would make
|
|
457
|
+
* the question change the answer.
|
|
458
|
+
*
|
|
459
|
+
* ⚠️ AN UNRECOGNISED COMMAND IS ANSWERED HERE AND NOT FORWARDED. Passing
|
|
460
|
+
* `/skil` to the model gets a confident essay about a typo; the one thing
|
|
461
|
+
* the person needed was the word `/skills`, which `slash.mjs` supplies.
|
|
462
|
+
* See its header for why `/etc/hosts` is NOT treated as a command.
|
|
463
|
+
*/
|
|
464
|
+
const command = parseSlash(line.trim());
|
|
465
|
+
if (command) {
|
|
466
|
+
const result = runSlashCommand(command, slashContext);
|
|
467
|
+
for (const l of result.output ?? []) output.write(`${l}\n`);
|
|
468
|
+
output.write('\n');
|
|
469
|
+
if (result.effect === 'clear') history = null;
|
|
470
|
+
if (typeof result.inject === 'string' && result.inject) pendingInject = result.inject;
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* ⚠️ THE SKILL IS PREPENDED TO THE TASK, NOT SUBSTITUTED FOR IT. The user
|
|
476
|
+
* typed an instruction; the skill is context for it. And it is cleared
|
|
477
|
+
* BEFORE the call rather than after, so a turn that throws cannot leave it
|
|
478
|
+
* armed and silently attach it to an unrelated question later.
|
|
479
|
+
*/
|
|
480
|
+
let sendTask = task;
|
|
481
|
+
if (pendingInject) {
|
|
482
|
+
sendTask = `${pendingInject}\n\n---\n\n${task}`;
|
|
483
|
+
pendingInject = null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
let outcome;
|
|
487
|
+
try {
|
|
488
|
+
outcome = await runOne(sendTask, history);
|
|
489
|
+
} catch (err) {
|
|
490
|
+
/**
|
|
491
|
+
* ⚠️ ONE BAD TURN MUST NOT END THE SESSION. A timeout or a provider blip
|
|
492
|
+
* after twenty minutes of context is infuriating if it drops everything;
|
|
493
|
+
* the history is still valid, so report and keep the prompt.
|
|
494
|
+
*/
|
|
495
|
+
output.write(`\n ✖ that turn failed: ${String(err?.message || err)}\n\n`);
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
turns += 1;
|
|
500
|
+
render(outcome, output);
|
|
501
|
+
|
|
502
|
+
if (outcome?.ok && Array.isArray(outcome.messages)) {
|
|
503
|
+
history = trimHistory(outcome.messages, maxHistory);
|
|
504
|
+
} else if (!outcome?.ok) {
|
|
505
|
+
// A failed turn leaves history UNTOUCHED. Appending a turn that produced
|
|
506
|
+
// nothing would poison the next one with a dead exchange.
|
|
507
|
+
output.write(`\n (history unchanged — that turn did not complete)\n`);
|
|
508
|
+
}
|
|
509
|
+
output.write('\n');
|
|
510
|
+
}
|
|
511
|
+
} finally {
|
|
512
|
+
if (rl) rl.close();
|
|
513
|
+
pin.release();
|
|
514
|
+
// ⚠️ The listeners come off too — a long-lived process that started several
|
|
515
|
+
// sessions would otherwise accumulate them and warn about a leak.
|
|
516
|
+
process.off('exit', releasePin);
|
|
517
|
+
process.off('SIGINT', releasePin);
|
|
518
|
+
process.off('SIGTERM', releasePin);
|
|
519
|
+
}
|
|
520
|
+
return { turns };
|
|
521
|
+
}
|
package/lib/input-box.mjs
CHANGED
|
@@ -349,3 +349,61 @@ export function readBoxedLine({ input, output, history = [], onInterrupt = null,
|
|
|
349
349
|
input.once('end', onEnd);
|
|
350
350
|
});
|
|
351
351
|
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* ── ⭐⭐⭐ PINNING THE BOX TO THE BOTTOM OF THE SCREEN ───────────────────────
|
|
355
|
+
*
|
|
356
|
+
* Roman: *"we need that prompt box stuck down the bottom, it is professional."*
|
|
357
|
+
*
|
|
358
|
+
* A terminal can be told to scroll only PART of itself. `ESC[{top};{bottom}r`
|
|
359
|
+
* sets the scrolling region; everything printed scrolls inside it, and the rows
|
|
360
|
+
* below are left alone. Reserve the last three and the box never moves while
|
|
361
|
+
* output flows past above it.
|
|
362
|
+
*
|
|
363
|
+
* ── ⚠️⚠️ THE PART THAT MUST NEVER BE GOT WRONG ──────────────────────────────
|
|
364
|
+
*
|
|
365
|
+
* A process that exits WITHOUT releasing the region leaves the user with a
|
|
366
|
+
* terminal that scrolls inside a box forever, fixable only by typing `reset`
|
|
367
|
+
* blind. That is the same class of harm as leaving raw mode on, and it must be
|
|
368
|
+
* released on every path out — normal exit, Ctrl-C, SIGTERM, and an uncaught
|
|
369
|
+
* throw. `release()` is idempotent and safe to call from all of them.
|
|
370
|
+
*
|
|
371
|
+
* ⚠️ AND IT IS OPT-IN. A reserved region is a claim on somebody's whole screen;
|
|
372
|
+
* off a TTY, in CI, under a pipe or with ACUVO_NO_PIN=1 it is never set.
|
|
373
|
+
*/
|
|
374
|
+
export function pinRegion(output, { rows = 3, env = process.env } = {}) {
|
|
375
|
+
const height = output?.rows ?? process.stdout?.rows ?? 0;
|
|
376
|
+
const enabled = Boolean(output?.isTTY)
|
|
377
|
+
&& height > rows + 4
|
|
378
|
+
&& String(env.ACUVO_NO_PIN ?? '') !== '1'
|
|
379
|
+
&& String(env.CI ?? '').toLowerCase() !== 'true';
|
|
380
|
+
|
|
381
|
+
if (!enabled) return { enabled: false, release() {}, rows: 0, bottom: 0 };
|
|
382
|
+
|
|
383
|
+
const bottom = height - rows;
|
|
384
|
+
let released = false;
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* ⚠️ THE CURSOR IS PARKED INSIDE THE SCROLL REGION BEFORE ANYTHING PRINTS.
|
|
388
|
+
* Setting a region moves the cursor to home (1,1) on most terminals, so
|
|
389
|
+
* without this the next line of output lands at the TOP of the screen and the
|
|
390
|
+
* transcript reads backwards.
|
|
391
|
+
*/
|
|
392
|
+
output.write(`${CSI}1;${bottom}r${CSI}${bottom};1H`);
|
|
393
|
+
|
|
394
|
+
const release = () => {
|
|
395
|
+
if (released) return;
|
|
396
|
+
released = true;
|
|
397
|
+
/**
|
|
398
|
+
* ⚠️ `ESC[r` WITH NO ARGUMENTS RESETS TO THE FULL SCREEN. Then the cursor is
|
|
399
|
+
* moved below the reserved rows so the shell prompt does not land on top of
|
|
400
|
+
* our box — an exit that leaves the terminal technically correct and
|
|
401
|
+
* visually broken is still a bad exit.
|
|
402
|
+
*/
|
|
403
|
+
try {
|
|
404
|
+
output.write(`${CSI}r${CSI}${height};1H\n`);
|
|
405
|
+
} catch { /* the stream may already be gone on a hard exit */ }
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
return { enabled: true, release, rows, bottom };
|
|
409
|
+
}
|