acuvo-code 0.5.3 → 0.6.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/lib/chat.mjs +545 -536
- package/lib/input-box.mjs +70 -13
- package/lib/tool-prefix.mjs +38 -2
- package/lib/turn.mjs +21 -1
- package/package.json +1 -1
package/lib/input-box.mjs
CHANGED
|
@@ -66,7 +66,14 @@ export function visibleWidth(s) {
|
|
|
66
66
|
*
|
|
67
67
|
* @returns {{lines: string[], cursorColumn: number}} 1-based cursor column
|
|
68
68
|
*/
|
|
69
|
-
|
|
69
|
+
/**
|
|
70
|
+
* The names offered by the slash menu. Passed in rather than imported so this
|
|
71
|
+
* module keeps no opinion about what commands exist — `slash.mjs` owns that
|
|
72
|
+
* list, and a second copy here would drift the first time one is added.
|
|
73
|
+
*/
|
|
74
|
+
export const DEFAULT_COMMANDS = Object.freeze(['help', 'skills', 'mcp', 'cost', 'model', 'clear']);
|
|
75
|
+
|
|
76
|
+
export function renderBox({ value = '', cursor = 0, columns = 80, prompt = '› ', commands = DEFAULT_COMMANDS } = {}) {
|
|
70
77
|
/**
|
|
71
78
|
* ── ⚠️ FULL WIDTH. THE 100-COLUMN CAP WAS WRONG AND IT LOOKED WRONG ────────
|
|
72
79
|
*
|
|
@@ -113,13 +120,48 @@ export function renderBox({ value = '', cursor = 0, columns = 80, prompt = '›
|
|
|
113
120
|
* ⚠️ Two rows, not three — so the reserved region shrinks with it and the
|
|
114
121
|
* transcript gets a row back.
|
|
115
122
|
*/
|
|
123
|
+
/**
|
|
124
|
+
* ── ⭐⭐⭐ THE SLASH MENU — WHAT YOU CAN DO, AT THE MOMENT YOU ASK ───────────
|
|
125
|
+
*
|
|
126
|
+
* Roman: *"when users do a special Acuvo command it changes the colour and
|
|
127
|
+
* also shows you your options in a dropdown."*
|
|
128
|
+
*
|
|
129
|
+
* ⭐ THIS IS THE DISCOVERABILITY FIX, NOT DECORATION. 28 skills, 7 commands,
|
|
130
|
+
* MCP in both directions — all built, and nothing on screen ever mentioned
|
|
131
|
+
* any of it. A capability nobody is shown is worth zero, which is the same
|
|
132
|
+
* defect as the toolbox and the whiteboard. Typing one character is the
|
|
133
|
+
* cheapest possible moment to answer "what can this thing do".
|
|
134
|
+
*
|
|
135
|
+
* ⚠️ IT REPLACES THE RULE RATHER THAN ADDING A ROW. The input lives in a
|
|
136
|
+
* reserved region of fixed height; growing it would mean resizing the scroll
|
|
137
|
+
* region mid-keystroke, and a region that changes size while a transcript is
|
|
138
|
+
* scrolling through it is how the display gets corrupted. The rule is already
|
|
139
|
+
* a full-width line doing nothing — so it becomes the menu while a command is
|
|
140
|
+
* being typed, and goes back to being a rule the moment it is not.
|
|
141
|
+
*/
|
|
142
|
+
const slash = /^\/(\S*)$/.exec(value);
|
|
143
|
+
let rule = '─'.repeat(width);
|
|
144
|
+
if (slash) {
|
|
145
|
+
const typed = slash[1].toLowerCase();
|
|
146
|
+
const matches = commands.filter((c) => c.startsWith(typed));
|
|
147
|
+
/**
|
|
148
|
+
* ⚠️ NO MATCHES IS ITS OWN ANSWER. Falling back to the full list would tell
|
|
149
|
+
* somebody who mistyped that everything is fine; saying so is what lets them
|
|
150
|
+
* fix it.
|
|
151
|
+
*/
|
|
152
|
+
const shown = matches.length ? matches.map((c) => `/${c}`).join(' ') : 'no command starts with that';
|
|
153
|
+
const label = ` ${shown} `;
|
|
154
|
+
rule = visibleWidth(label) >= width
|
|
155
|
+
? label.slice(0, width)
|
|
156
|
+
: `${label}${'─'.repeat(width - visibleWidth(label))}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
116
159
|
return {
|
|
117
|
-
lines: [
|
|
118
|
-
'─'.repeat(width),
|
|
119
|
-
`${body}${pad}`,
|
|
120
|
-
],
|
|
160
|
+
lines: [rule, `${body}${pad}`],
|
|
121
161
|
// 1-based, and there is no left border to skip any more.
|
|
122
162
|
cursorColumn: 1 + promptWidth + (cursor - start),
|
|
163
|
+
/** True while a slash command is being typed — the caller paints it. */
|
|
164
|
+
isCommand: Boolean(slash),
|
|
123
165
|
};
|
|
124
166
|
}
|
|
125
167
|
|
|
@@ -238,8 +280,23 @@ export function splitKeys(chunk) {
|
|
|
238
280
|
* to the end of each border as it is written — which reads as a flicker and is
|
|
239
281
|
* the difference between a rendered box and a drawn one.
|
|
240
282
|
*/
|
|
241
|
-
export function paint(output, state, { first = false, atRow = 0 } = {}) {
|
|
242
|
-
const
|
|
283
|
+
export function paint(output, state, { first = false, atRow = 0, paintFn = null } = {}) {
|
|
284
|
+
const r = renderBox(state);
|
|
285
|
+
const { cursorColumn } = r;
|
|
286
|
+
/**
|
|
287
|
+
* ── ⭐ THE INPUT TURNS BRAND GREEN WHILE A COMMAND IS BEING TYPED ──────────
|
|
288
|
+
*
|
|
289
|
+
* Roman: *"it changes the colour and also shows you your options."* The colour
|
|
290
|
+
* is the faster half of that — it says "you are in a different mode" before
|
|
291
|
+
* anyone has read a single menu entry.
|
|
292
|
+
*
|
|
293
|
+
* ⚠️ THE PAINTER IS INJECTED and colour is applied AFTER layout, never before:
|
|
294
|
+
* escape codes have no width, so colouring a string and then padding it aligns
|
|
295
|
+
* the text against invisible bytes. `renderBox` stays pure and returns plain
|
|
296
|
+
* text; this is the only place that knows about colour.
|
|
297
|
+
*/
|
|
298
|
+
const brand = (paintFn && r.isCommand) ? paintFn : null;
|
|
299
|
+
const lines = brand ? r.lines.map((l) => brand(l)) : r.lines;
|
|
243
300
|
|
|
244
301
|
/**
|
|
245
302
|
* ── ⭐⭐⭐ PINNED: DRAW AT AN ABSOLUTE ROW, NOT WHEREVER THE CURSOR IS ───────
|
|
@@ -311,7 +368,7 @@ export function paint(output, state, { first = false, atRow = 0 } = {}) {
|
|
|
311
368
|
*
|
|
312
369
|
* @returns {Promise<{value: string|null, reason: 'submit'|'cancel'|'eof'}>}
|
|
313
370
|
*/
|
|
314
|
-
export function readBoxedLine({ input, output, history = [], onInterrupt = null, prompt = '› ', atRow = 0 }) {
|
|
371
|
+
export function readBoxedLine({ input, output, history = [], onInterrupt = null, prompt = '› ', atRow = 0, commands = DEFAULT_COMMANDS, paintFn = null }) {
|
|
315
372
|
return new Promise((resolve) => {
|
|
316
373
|
let state = { value: '', cursor: 0, history, historyIndex: history.length, draft: '' };
|
|
317
374
|
const columns = () => output.columns ?? process.stdout.columns ?? 80;
|
|
@@ -346,7 +403,7 @@ export function readBoxedLine({ input, output, history = [], onInterrupt = null,
|
|
|
346
403
|
* instead of on top of it.
|
|
347
404
|
*/
|
|
348
405
|
output.write(`${CSI}${atRow - 1};1H\n${prompt}${value ?? ''}\n`);
|
|
349
|
-
paint(output, { value: '', cursor: 0, columns: output.columns ?? 80 }, { atRow });
|
|
406
|
+
paint(output, { value: '', cursor: 0, columns: output.columns ?? 80, commands }, { atRow, paintFn });
|
|
350
407
|
output.write(`${CSI}${atRow - 1};1H`);
|
|
351
408
|
} else {
|
|
352
409
|
/**
|
|
@@ -370,7 +427,7 @@ export function readBoxedLine({ input, output, history = [], onInterrupt = null,
|
|
|
370
427
|
if (next.done === 'submit') {
|
|
371
428
|
state = next;
|
|
372
429
|
// Repaint once so the committed line is what stays on screen.
|
|
373
|
-
paint(output, { ...state, columns: columns() }, { atRow });
|
|
430
|
+
paint(output, { ...state, columns: columns(), commands }, { atRow, paintFn });
|
|
374
431
|
return finish(state.value, 'submit');
|
|
375
432
|
}
|
|
376
433
|
if (next.done === 'cancel') {
|
|
@@ -392,18 +449,18 @@ export function readBoxedLine({ input, output, history = [], onInterrupt = null,
|
|
|
392
449
|
*/
|
|
393
450
|
state = { ...state, value: '', cursor: 0, historyIndex: history.length, draft: '' };
|
|
394
451
|
onInterrupt?.();
|
|
395
|
-
paint(output, { ...state, columns: columns() }, { atRow });
|
|
452
|
+
paint(output, { ...state, columns: columns(), commands }, { atRow, paintFn });
|
|
396
453
|
continue;
|
|
397
454
|
}
|
|
398
455
|
if (next.done === 'eof') return finish(null, 'eof');
|
|
399
456
|
state = next;
|
|
400
|
-
paint(output, { ...state, columns: columns() }, { atRow });
|
|
457
|
+
paint(output, { ...state, columns: columns(), commands }, { atRow, paintFn });
|
|
401
458
|
}
|
|
402
459
|
};
|
|
403
460
|
|
|
404
461
|
try { input.setRawMode?.(true); } catch { /* not a TTY */ }
|
|
405
462
|
input.resume?.();
|
|
406
|
-
paint(output, { ...state, columns: columns() }, { first: true, atRow });
|
|
463
|
+
paint(output, { ...state, columns: columns(), commands }, { first: true, atRow, paintFn });
|
|
407
464
|
input.on('data', onData);
|
|
408
465
|
input.once('end', onEnd);
|
|
409
466
|
});
|
package/lib/tool-prefix.mjs
CHANGED
|
@@ -218,9 +218,45 @@ export function alwaysOfferedNames(maxRounds) {
|
|
|
218
218
|
* @param {{ maxRounds?: number }} [opts]
|
|
219
219
|
* @returns {Array<any>} the same schemas, constant ones first
|
|
220
220
|
*/
|
|
221
|
-
export function orderForCachePrefix(schemas, { maxRounds = 8 } = {}) {
|
|
221
|
+
export function orderForCachePrefix(schemas, { maxRounds = 8, shortlist = null } = {}) {
|
|
222
222
|
if (!Array.isArray(schemas) || schemas.length === 0) return Array.isArray(schemas) ? [...schemas] : [];
|
|
223
223
|
const core = alwaysOfferedNames(maxRounds);
|
|
224
224
|
const isCore = (t) => core.has(t?.function?.name);
|
|
225
|
-
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* ── 💰⭐⭐⭐ THE SHORTLIST GOES FIRST, OR A WIDEN BURNS THE WHOLE CACHE ──────
|
|
228
|
+
*
|
|
229
|
+
* MEASURED on a real brief, 2026-08-22: the narrow offer is 27 tools / 24,602
|
|
230
|
+
* bytes and the widened offer is 64 / 62,470 — and only **6,866 bytes were a
|
|
231
|
+
* shared prefix. 27.9%.** So widening rewrote 72% of the tools block, and
|
|
232
|
+
* `tool-prefix`'s own header measures that block at 94% of the shared head.
|
|
233
|
+
* One widen therefore threw away roughly **68% of everything cacheable.**
|
|
234
|
+
*
|
|
235
|
+
* ⚠️ AND THE ORDERING ABOVE DID NOT PREVENT IT. Sorting core-first is correct
|
|
236
|
+
* and insufficient: the SHORTLIST cuts across core-vs-conditional, so a
|
|
237
|
+
* shortlisted conditional tool sits after a non-shortlisted core one, and
|
|
238
|
+
* re-admitting the missing tools INTERLEAVES them into the middle of the
|
|
239
|
+
* block rather than appending.
|
|
240
|
+
*
|
|
241
|
+
* ⭐ THE FIX IS ORDER, NOT CONTENT. If the shortlisted tools are emitted FIRST
|
|
242
|
+
* in a stable order, the narrow block becomes a byte-prefix of the wide one —
|
|
243
|
+
* so widening APPENDS and everything already cached stays cached. The model
|
|
244
|
+
* gains capability and pays only for the tools it just gained.
|
|
245
|
+
*
|
|
246
|
+
* ⚠️ `shortlist` is optional and omitting it keeps the previous behaviour
|
|
247
|
+
* exactly, so nothing that does not know about this changes.
|
|
248
|
+
*/
|
|
249
|
+
const inShortlist = shortlist ? new Set(shortlist) : null;
|
|
250
|
+
const rank = (t) => {
|
|
251
|
+
const name = t?.function?.name;
|
|
252
|
+
if (inShortlist) return inShortlist.has(name) ? (isCore(t) ? 0 : 1) : (isCore(t) ? 2 : 3);
|
|
253
|
+
return isCore(t) ? 0 : 1;
|
|
254
|
+
};
|
|
255
|
+
/**
|
|
256
|
+
* ⚠️ A STABLE SORT, AND NODE'S IS GUARANTEED STABLE since V8 7.0 — so tools of
|
|
257
|
+
* equal rank keep their registry order and the block is byte-identical run to
|
|
258
|
+
* run. An unstable sort here would change the prefix on every process for no
|
|
259
|
+
* reason at all, which is the same defect as the random sticky key.
|
|
260
|
+
*/
|
|
261
|
+
return [...schemas].sort((a, b) => rank(a) - rank(b));
|
|
226
262
|
}
|
package/lib/turn.mjs
CHANGED
|
@@ -2713,7 +2713,27 @@ export async function runSession({
|
|
|
2713
2713
|
* definition — they are the one part of the block that CANNOT be shared — so
|
|
2714
2714
|
* they belong behind everything that can be.
|
|
2715
2715
|
*/
|
|
2716
|
-
|
|
2716
|
+
/**
|
|
2717
|
+
* ── 💰⭐⭐⭐ THE ORDERING KEY IS THE *NARROW* LIST, ALWAYS ────────────────────
|
|
2718
|
+
*
|
|
2719
|
+
* Computed with `widened: false` even after a widen, deliberately. It is not
|
|
2720
|
+
* the set of tools to offer — it is the ORDER to emit them in, and it has to
|
|
2721
|
+
* be the same before and after so the narrow block stays a byte-prefix of the
|
|
2722
|
+
* wide one.
|
|
2723
|
+
*
|
|
2724
|
+
* MEASURED before this: a widen shared only **6,866 of 24,602 bytes — 27.9%**
|
|
2725
|
+
* — with the rest of the tools block rewritten. `tool-prefix`'s own header
|
|
2726
|
+
* puts that block at 94% of the shared head, so one widen threw away ~68% of
|
|
2727
|
+
* everything cacheable. With the narrow list pinning the order: **100%.**
|
|
2728
|
+
*
|
|
2729
|
+
* ⚠️ Passing the WIDENED list here would silently undo it — the order would
|
|
2730
|
+
* change the moment it widened, which is the exact failure being fixed.
|
|
2731
|
+
*/
|
|
2732
|
+
const orderKey = shortlistEnabled ? shortlistTools(task ?? '', environmentOffer, { widened: false }) : null;
|
|
2733
|
+
const tools = [
|
|
2734
|
+
...orderForCachePrefix(toolSchemasFor(offered, { shell }), { maxRounds, shortlist: orderKey }),
|
|
2735
|
+
...mcpSchemas,
|
|
2736
|
+
];
|
|
2717
2737
|
/**
|
|
2718
2738
|
* ⚠️ APPEND, NEVER REBUILD. The system prompt and the workspace context are the
|
|
2719
2739
|
* cacheable prefix; a continuing turn adds one user message to the end and
|