acuvo-code 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ WHICH FILES DID THIS TOOL CALL ACTUALLY CHANGE — ONE ANSWER ─────────
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ THIS CONCEPT LIVED IN THREE PLACES AND THEY DISAGREED, which is how the
|
|
5
|
+
* package has already lost files:
|
|
6
|
+
*
|
|
7
|
+
* · `parallel.mjs:83` read `rec.mutatedPath ?? rec.result.path` — correct,
|
|
8
|
+
* and the comment above it records the bug that forced `mutatedPath` to
|
|
9
|
+
* exist at all: `see_page`'s `result.path` is the page it READ, so two
|
|
10
|
+
* tasks that merely looked at one page were reported as colliding over it
|
|
11
|
+
* and `--parallel` exited 1 on work that never conflicted.
|
|
12
|
+
* · `best-of.mjs:167` read `record.args?.path` — a DIFFERENT field. Measured
|
|
13
|
+
* by reading `write-many.mjs:128`: a `write_files` call carries `files[]`
|
|
14
|
+
* in its arguments and has no top-level `path`, so a winning attempt that
|
|
15
|
+
* wrote 45 files in one bulk call had NONE of them copied back out of the
|
|
16
|
+
* attempt directory. Silently, and reported as a success.
|
|
17
|
+
* · `subagent.mjs:91` read `rec.args?.path ?? rec.result?.path`, a third
|
|
18
|
+
* spelling again.
|
|
19
|
+
*
|
|
20
|
+
* ⭐ ONE FUNCTION, AND EVERY CALLER ASKS IT. A future tool that touches disk in
|
|
21
|
+
* a new shape gets taught here, once, instead of in three files that will not
|
|
22
|
+
* all be found — and the third one to be missed is always the one that matters.
|
|
23
|
+
*
|
|
24
|
+
* ⚠️ IT IS A LEAF ON PURPOSE: zero imports, node builtins included. `handoff`,
|
|
25
|
+
* `parallel` and `best-of` all need it and two of them import each other, so
|
|
26
|
+
* anything less than a leaf reintroduces a cycle.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {any} record a dispatcher record: `{ name, args, result, mutated, mutatedPath? }`
|
|
31
|
+
* @returns {string[]} workspace-relative paths, in the order the tool reports them
|
|
32
|
+
*/
|
|
33
|
+
export function changedPaths(record) {
|
|
34
|
+
/**
|
|
35
|
+
* ⚠️ `mutated` IS THE GATE, NOT THE TOOL NAME. The dispatcher sets it
|
|
36
|
+
* (`tools.mjs:816` says so outright) precisely so a future tool that also
|
|
37
|
+
* touches disk cannot be missed by a name-based check.
|
|
38
|
+
*/
|
|
39
|
+
if (!record?.mutated) return [];
|
|
40
|
+
|
|
41
|
+
const out = [];
|
|
42
|
+
const push = (p) => { if (typeof p === 'string' && p.trim() && !out.includes(p)) out.push(p); };
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* ── ⭐⭐ `written[]` — THE ONE MULTI-FILE SHAPE, AND NOW TWO TOOLS USE IT ──
|
|
46
|
+
*
|
|
47
|
+
* Read it FIRST. Such a record also carries `mutatedPath`, but only when
|
|
48
|
+
* exactly ONE file landed (`tools.mjs:954`), so trusting that field alone
|
|
49
|
+
* silently drops 44 of 45.
|
|
50
|
+
*
|
|
51
|
+
* ⭐ `delegate` WITH `write: true` REPORTS THE SAME SHAPE. That was a
|
|
52
|
+
* deliberate choice over inventing an `applied[]` field: a second spelling
|
|
53
|
+
* of "these files changed" would need an arm here, an arm in `report.mjs`,
|
|
54
|
+
* and an arm in every future reader — which is precisely the three-way
|
|
55
|
+
* disagreement described above. Reusing the shape means a delegated build
|
|
56
|
+
* is understood by every reader that already understood a bulk write, with
|
|
57
|
+
* no new line of code anywhere.
|
|
58
|
+
*/
|
|
59
|
+
for (const f of record.result?.written ?? []) push(f?.path);
|
|
60
|
+
|
|
61
|
+
// ⚠️ `mutatedPath` BEFORE `result.path` — see the `see_page` note above.
|
|
62
|
+
if (out.length === 0) push(record.mutatedPath ?? record.result?.path);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* ── ⚠️⚠️ `args.path` IS THE LAST RESORT, AND ITS ORDER IS THE WHOLE POINT ──
|
|
66
|
+
*
|
|
67
|
+
* This function has to be a strict SUPERSET of what each caller did before
|
|
68
|
+
* it, or consolidating three readers into one is how the fourth thing breaks.
|
|
69
|
+
* `best-of.mjs` read `args.path` and nothing else, so a record shaped only
|
|
70
|
+
* that way must still resolve — otherwise this "cleanup" would stop best-of
|
|
71
|
+
* applying files, which is the exact category of regression it was written to
|
|
72
|
+
* prevent.
|
|
73
|
+
*
|
|
74
|
+
* ⚠️ BUT IT MUST NEVER BE CONSULTED FIRST. `args.path` is the RAW STRING A
|
|
75
|
+
* MODEL WROTE; the fields above are what the tool RESOLVED and actually
|
|
76
|
+
* touched — `./src/a.ts` and `src/a.ts` are one file and only the resolved
|
|
77
|
+
* form says so. And `see_page`'s `args.path` is the page it READ while its
|
|
78
|
+
* `mutatedPath` is the screenshot it wrote, so reading arguments first would
|
|
79
|
+
* re-open the bug that forced `mutatedPath` into existence: two tasks that
|
|
80
|
+
* merely looked at one page, reported as colliding over it.
|
|
81
|
+
*/
|
|
82
|
+
if (out.length === 0) push(record.args?.path);
|
|
83
|
+
return out;
|
|
84
|
+
}
|
package/lib/chat.mjs
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
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 { EXIT_INTERRUPTED } from './interrupt.mjs';
|
|
26
|
+
import { parseSlash, runSlashCommand } from './slash.mjs';
|
|
27
|
+
import { estimateMessagesTokens } from './compact.mjs';
|
|
28
|
+
|
|
29
|
+
/** What ends a session. `exit`/`quit` because both are muscle memory. */
|
|
30
|
+
const QUIT = new Set(['exit', 'quit', ':q', 'bye']);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* ⚠️ CONTEXT GROWS UNBOUNDED AND A CODING SESSION IS THE WORST CASE — tool
|
|
34
|
+
* results carry whole files. Left alone, turn 30 sends everything from turns
|
|
35
|
+
* 1-29 and eventually 400s on a context-length error mid-thought.
|
|
36
|
+
*
|
|
37
|
+
* The trim keeps the SYSTEM message and the FIRST user message (the workspace
|
|
38
|
+
* context — the cacheable prefix, dropping it would cost more than it saves) and
|
|
39
|
+
* discards the oldest middle turns.
|
|
40
|
+
*/
|
|
41
|
+
export const MAX_HISTORY_MESSAGES = 40;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* ── 💰⭐⭐⭐ THE TRIM WAS A CONTINUOUS SLIDE, AND IT COST ~90% OF THE CACHE ──
|
|
45
|
+
*
|
|
46
|
+
* `messages.slice(-keep)` re-slices on EVERY turn once a session passes the
|
|
47
|
+
* cap, so the third message of the prompt is different every time. Prefix
|
|
48
|
+
* caching matches from the first token and stops at the first difference — so
|
|
49
|
+
* everything after the 2-message head was re-bought at full price, every turn,
|
|
50
|
+
* for the rest of the session.
|
|
51
|
+
*
|
|
52
|
+
* ⭐ MEASURED (no credits, no network — a simulated 60-turn session counting how
|
|
53
|
+
* much of each prompt is byte-identical to the previous one):
|
|
54
|
+
*
|
|
55
|
+
* CURRENT slice(-38) cache 9.2% history 38-40
|
|
56
|
+
* stepped high=48 low=40 cache 74.5% history 38-48
|
|
57
|
+
* stepped high=56 low=40 cache 85.3% history 38-56
|
|
58
|
+
* stepped high=64 low=40 cache 89.7% history 38-64 <- shipped
|
|
59
|
+
* stepped high=72 low=40 cache 92.0% history 38-72
|
|
60
|
+
*
|
|
61
|
+
* ⚠️ THE HEAD BEING STABLE IS NOT THE SAME AS THE PROMPT BEING CACHED, and
|
|
62
|
+
* measuring position 0 alone would have said this code was fine. The 2-message
|
|
63
|
+
* head never moved; the cacheable prefix was still 2 messages out of 40.
|
|
64
|
+
*
|
|
65
|
+
* ⭐ `LOW` IS TODAY'S CAP ON PURPOSE, so this can only ever keep MORE history
|
|
66
|
+
* than the rule it replaces — never less. That makes it a pure win rather than
|
|
67
|
+
* a trade of memory for money, and it is why HIGH was raised instead of LOW
|
|
68
|
+
* being lowered (console shipped 16/8, halving its window; the CLI holds whole
|
|
69
|
+
* files in tool results and cannot afford to forget more).
|
|
70
|
+
*
|
|
71
|
+
* ⚠️ RAISING `HIGH` COSTS TOKENS PER REQUEST, and the ceiling that matters is
|
|
72
|
+
* not here: `turn.mjs` compacts above CONTEXT_BUDGET_TOKENS (96k), which
|
|
73
|
+
* `compact.mjs` warns is the moment the cache discount dies for good. This is a
|
|
74
|
+
* MESSAGE-COUNT backstop, not the token bound — 64 messages of ordinary turns
|
|
75
|
+
* sit far under 96k, but a session whose tool results carry whole files can
|
|
76
|
+
* approach it, and then compaction (with its own hysteresis) takes over. 72 and
|
|
77
|
+
* 80 buy 2 more points of cache for materially more of that risk, which is why
|
|
78
|
+
* 64 is the shipped number and not the best one in the table.
|
|
79
|
+
*/
|
|
80
|
+
export const HISTORY_HIGH_WATER = 64;
|
|
81
|
+
export const HISTORY_LOW_WATER = MAX_HISTORY_MESSAGES;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* ⚠️ WELL UNDER `turn.mjs`'s CONTEXT_BUDGET_TOKENS (96,000), not near it. The
|
|
85
|
+
* request also carries the TOOL OFFER — measured at ~15,200 tokens for the
|
|
86
|
+
* CLI's 63 verbs — plus the system prompt, so history must leave room for both
|
|
87
|
+
* and still clear the line where compaction fires. 55k + ~15k offer + prompt
|
|
88
|
+
* sits comfortably inside 96k.
|
|
89
|
+
*
|
|
90
|
+
* ⭐ This is a CEILING, not a target: an ordinary session (~551 tokens/message,
|
|
91
|
+
* measured) reaches the 64-message cap at ~35k and never comes near it. It
|
|
92
|
+
* exists for the file-heavy session, which is exactly the one that would
|
|
93
|
+
* otherwise be pushed into permanent compaction by the higher message cap.
|
|
94
|
+
*/
|
|
95
|
+
export const HISTORY_TOKEN_CEILING = 55_000;
|
|
96
|
+
|
|
97
|
+
export function trimHistory(messages, max = HISTORY_HIGH_WATER) {
|
|
98
|
+
if (!Array.isArray(messages)) return messages;
|
|
99
|
+
/**
|
|
100
|
+
* ⚠️⚠️ THE GATE MUST ASK BOTH QUESTIONS, AND MY FIRST VERSION ASKED ONLY ONE.
|
|
101
|
+
* It read `messages.length <= max` and returned early — so the token ceiling
|
|
102
|
+
* below was unreachable for exactly the session it was written for: 64 heavy
|
|
103
|
+
* messages are under the COUNT cap and ~137,000 tokens, and the function
|
|
104
|
+
* handed them straight back. The test reported 133,491 tokens and I had to
|
|
105
|
+
* measure to find that the ceiling was never consulted at all rather than
|
|
106
|
+
* being wrong.
|
|
107
|
+
*/
|
|
108
|
+
const overBudget = estimateMessagesTokens(messages) > HISTORY_TOKEN_CEILING;
|
|
109
|
+
if (messages.length <= max && !overBudget) return messages;
|
|
110
|
+
const head = messages.slice(0, 2); // system + the context-bearing user turn
|
|
111
|
+
/**
|
|
112
|
+
* ⭐ THE HEAD MOVES IN STEPS, NOT EVERY TURN. `dropped` is a multiple of STEP,
|
|
113
|
+
* so it changes only when the conversation crosses the next boundary — and
|
|
114
|
+
* between boundaries the prompt is byte-identical to the previous turn's.
|
|
115
|
+
*
|
|
116
|
+
* ⚠️ NOT `length > HIGH ? slice(-LOW)`. That is still a continuous slide past
|
|
117
|
+
* the mark and is the exact mistake console's own guard records catching
|
|
118
|
+
* before it shipped.
|
|
119
|
+
*/
|
|
120
|
+
/**
|
|
121
|
+
* ⚠️⚠️ THE LOW WATER MUST BE DERIVED FROM `max`, NOT PINNED TO A CONSTANT,
|
|
122
|
+
* AND I ALMOST SHIPPED IT PINNED. My first version was
|
|
123
|
+
* `Math.min(HISTORY_LOW_WATER, max)`. `runChat`'s default `maxHistory` was
|
|
124
|
+
* MAX_HISTORY_MESSAGES (40), which equals HISTORY_LOW_WATER — so `step`
|
|
125
|
+
* became `max(1, 0)` = 1, and one-message steps ARE the continuous slide this
|
|
126
|
+
* whole change exists to remove. The fix would have measured 89.6% in a
|
|
127
|
+
* simulation and done NOTHING on the only path that calls it.
|
|
128
|
+
*
|
|
129
|
+
* ⭐ 0.625 is 40/64 — the shipped ratio, so the default keeps exactly the
|
|
130
|
+
* numbers that were measured, and any other `max` still steps properly
|
|
131
|
+
* instead of silently degrading to a slide.
|
|
132
|
+
*/
|
|
133
|
+
const low = Math.min(HISTORY_LOW_WATER, Math.floor(max * 0.625));
|
|
134
|
+
const step = Math.max(1, max - low);
|
|
135
|
+
const body = messages.slice(head.length);
|
|
136
|
+
const overflow = Math.max(0, messages.length - max);
|
|
137
|
+
let dropped = Math.ceil(overflow / step) * step;
|
|
138
|
+
/**
|
|
139
|
+
* ── ⚠️⚠️ AND A TOKEN CEILING, BECAUSE MESSAGE COUNT IS THE WRONG UNIT ──────
|
|
140
|
+
*
|
|
141
|
+
* MEASURED against the one real recorded session on disk (93,036 prompt
|
|
142
|
+
* tokens, 18 messages, `.acuvo/sessions/20260814-095636-99d4.json`):
|
|
143
|
+
*
|
|
144
|
+
* avg message ~551 tokens largest observed ~2,145 tokens
|
|
145
|
+
*
|
|
146
|
+
* at 40 msgs: typical ~22,000 worst case (all like the largest) ~85,800
|
|
147
|
+
* at 64 msgs: typical ~35,300 worst case ~137,300
|
|
148
|
+
*
|
|
149
|
+
* ⚠️ SO RAISING THE COUNT ALONE MOVES THE WORST CASE FROM JUST UNDER THE
|
|
150
|
+
* 96k CONTEXT BUDGET TO WELL OVER IT. Past that line `turn.mjs` compacts, and
|
|
151
|
+
* `compact.mjs` is explicit that compaction voids the cache discount and
|
|
152
|
+
* "once it starts, it never stops" — so a change made ENTIRELY to protect the
|
|
153
|
+
* cache would, in a file-heavy session, destroy it. A tool result carrying a
|
|
154
|
+
* whole file is not the exception in a coding session; it is the normal case.
|
|
155
|
+
*
|
|
156
|
+
* ⭐ THE CEILING DROPS IN THE SAME `step` BLOCKS. Any multiple of `step`
|
|
157
|
+
* keeps block alignment, so the prefix still changes only at boundaries and
|
|
158
|
+
* the caching win is untouched — this bounds the worst case without
|
|
159
|
+
* reintroducing a slide.
|
|
160
|
+
*/
|
|
161
|
+
const budget = Math.max(1, HISTORY_TOKEN_CEILING - estimateMessagesTokens(head));
|
|
162
|
+
while (dropped < body.length && estimateMessagesTokens(body.slice(dropped)) > budget) {
|
|
163
|
+
dropped += step;
|
|
164
|
+
}
|
|
165
|
+
let tail = body.slice(dropped);
|
|
166
|
+
/**
|
|
167
|
+
* ⚠️ A `tool` MESSAGE WITHOUT ITS `assistant` TOOL CALL IS A HARD 400 from
|
|
168
|
+
* every OpenAI-shaped provider — "tool_call_id did not have a preceding
|
|
169
|
+
* message with tool_calls". Slicing mid-exchange produces exactly that, and it
|
|
170
|
+
* would surface as a mysterious API error thirty turns into a session.
|
|
171
|
+
*/
|
|
172
|
+
while (tail.length > 0 && tail[0].role === 'tool') tail = tail.slice(1);
|
|
173
|
+
return [...head, ...tail];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* One prompt line. Returns null on EOF (Ctrl-D, or a pipe that ran out).
|
|
178
|
+
*
|
|
179
|
+
* ⚠️ THE CLOSED CHECK IS NOT DEFENSIVE, IT IS THE PIPED CASE. Found by piping a
|
|
180
|
+
* list of prompts in: readline emits 'close' when the stream ends, and the NEXT
|
|
181
|
+
* `rl.question()` throws ERR_USE_AFTER_CLOSE. The first turn had already
|
|
182
|
+
* succeeded and written a real file, so the session crashed AFTER doing its job
|
|
183
|
+
* — the worst shape of failure, because the work looks lost.
|
|
184
|
+
*
|
|
185
|
+
* Scripted input matters beyond tests: piping a prompt list is how anyone would
|
|
186
|
+
* automate this.
|
|
187
|
+
*/
|
|
188
|
+
function ask(rl, prompt, state) {
|
|
189
|
+
if (state.closed) return Promise.resolve(null);
|
|
190
|
+
return new Promise((resolve) => {
|
|
191
|
+
let answered = false;
|
|
192
|
+
const onClose = () => { if (!answered) resolve(null); };
|
|
193
|
+
rl.once('close', onClose);
|
|
194
|
+
rl.question(prompt, (line) => {
|
|
195
|
+
answered = true;
|
|
196
|
+
rl.removeListener('close', onClose);
|
|
197
|
+
resolve(line);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Run an interactive session.
|
|
204
|
+
*
|
|
205
|
+
* `runOne(task, priorMessages)` performs one turn and returns the session
|
|
206
|
+
* outcome — injected rather than imported so this loop is testable with a stub
|
|
207
|
+
* and never needs a model or a terminal in a test.
|
|
208
|
+
*/
|
|
209
|
+
/**
|
|
210
|
+
* ── ⚠️ PIPED INPUT IS A DIFFERENT PROBLEM AND NEEDED A DIFFERENT ANSWER ──────
|
|
211
|
+
* `readline` on a non-TTY DRAINS the stream as fast as it can and emits 'close'
|
|
212
|
+
* the moment it ends. The model call for turn 1 takes seconds, by which point
|
|
213
|
+
* the interface is already closed and every later prompt is lost — measured:
|
|
214
|
+
* a three-line pipe ran exactly ONE turn and exited quietly, which is worse than
|
|
215
|
+
* crashing because it looks like it worked.
|
|
216
|
+
*
|
|
217
|
+
* So a pipe is read WHOLE and replayed from a queue. A TTY keeps the real
|
|
218
|
+
* readline loop, where a human types the next line after seeing the last answer.
|
|
219
|
+
* Two input shapes, two mechanisms — pretending they are the same is what broke.
|
|
220
|
+
*/
|
|
221
|
+
async function readAllLines(input) {
|
|
222
|
+
const chunks = [];
|
|
223
|
+
for await (const chunk of input) chunks.push(chunk);
|
|
224
|
+
return Buffer.concat(chunks.map((c) => (typeof c === 'string' ? Buffer.from(c) : c)))
|
|
225
|
+
.toString('utf8')
|
|
226
|
+
.split(String.fromCharCode(10))
|
|
227
|
+
.map((l) => l.replace(String.fromCharCode(13), ''));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* ── ⚠️⚠️⭐ READLINE EATS CTRL-C. MEASURED IN NODE'S OWN SOURCE ──────────────
|
|
232
|
+
*
|
|
233
|
+
* This is the finding that made an interrupt handler in `bin/acuvo.mjs`
|
|
234
|
+
* necessary-but-not-sufficient. Read out of `process.binding('natives')` on
|
|
235
|
+
* node v22.17.0, `internal/readline/interface.js`, the ttyWrite ctrl-key
|
|
236
|
+
* switch, verbatim:
|
|
237
|
+
*
|
|
238
|
+
* case 'c':
|
|
239
|
+
* if (this.listenerCount('SIGINT') > 0) {
|
|
240
|
+
* this.emit('SIGINT');
|
|
241
|
+
* } else {
|
|
242
|
+
* // This readline instance is finished
|
|
243
|
+
* this.close();
|
|
244
|
+
* this[kQuestionReject]?.(new AbortError('Aborted with Ctrl+C'));
|
|
245
|
+
* }
|
|
246
|
+
*
|
|
247
|
+
* ⚠️ So with a TTY readline open and NO `'SIGINT'` listener on the interface,
|
|
248
|
+
* Ctrl-C never reaches `process.on('SIGINT')` at all — readline just closes
|
|
249
|
+
* itself. The run in flight would have carried on to completion, for minutes,
|
|
250
|
+
* with the user's Ctrl-C having produced nothing on screen. That is strictly
|
|
251
|
+
* worse than the bug we set out to fix, and no amount of correct handling in
|
|
252
|
+
* `bin/acuvo.mjs` would have been reached.
|
|
253
|
+
*
|
|
254
|
+
* ⭐ So the interface takes a listener whose whole job is to hand the signal
|
|
255
|
+
* back to the process, where the one policy in `interrupt.mjs` decides between
|
|
256
|
+
* "stop after this round" and "quit now".
|
|
257
|
+
*
|
|
258
|
+
* ── ⚠️⚠️ AND A SYNTHETIC EMIT INTO AN EMPTY EMITTER DOES NOTHING ────────────
|
|
259
|
+
*
|
|
260
|
+
* `process.emit('SIGINT')` is plain `EventEmitter.emit` — it does NOT invoke
|
|
261
|
+
* the OS default action. `turn.mjs` installs the process signal handlers inside
|
|
262
|
+
* `runSession`, so before the first turn of a session there are ZERO listeners
|
|
263
|
+
* and the emit would return `false` having done absolutely nothing. Ctrl-C at
|
|
264
|
+
* the very first prompt would be inert.
|
|
265
|
+
*
|
|
266
|
+
* ⭐ Hence the count check and the explicit exit: **every path out of this
|
|
267
|
+
* function either aborts a run or ends the process.** That is the rule this
|
|
268
|
+
* whole feature is built on, and it is the one that is easy to break here.
|
|
269
|
+
*/
|
|
270
|
+
export function deliverInterrupt({
|
|
271
|
+
emit = (sig) => process.emit(sig),
|
|
272
|
+
listenerCount = (sig) => process.listenerCount(sig),
|
|
273
|
+
exit = (code) => process.exit(code),
|
|
274
|
+
} = {}) {
|
|
275
|
+
if (listenerCount('SIGINT') > 0) {
|
|
276
|
+
emit('SIGINT');
|
|
277
|
+
return 'delegated';
|
|
278
|
+
}
|
|
279
|
+
exit(EXIT_INTERRUPTED);
|
|
280
|
+
return 'exited';
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function runChat({
|
|
284
|
+
runOne,
|
|
285
|
+
render,
|
|
286
|
+
input = process.stdin,
|
|
287
|
+
output = process.stdout,
|
|
288
|
+
banner = '',
|
|
289
|
+
/**
|
|
290
|
+
* ⚠️ THE HIGH WATER MARK, NOT THE OLD FLAT CAP. This default is what makes
|
|
291
|
+
* the stepped trim REACH the live session — see the note in `trimHistory`
|
|
292
|
+
* about the version of this fix that measured 89.6% and changed nothing.
|
|
293
|
+
*/
|
|
294
|
+
maxHistory = HISTORY_HIGH_WATER,
|
|
295
|
+
/**
|
|
296
|
+
* ⚠️ INJECTED SO A TEST CAN SEE IT. The real one exits the process, and a
|
|
297
|
+
* test that could not substitute it could only assert this feature by killing
|
|
298
|
+
* its own runner. Default is production behaviour, so no caller changes.
|
|
299
|
+
*/
|
|
300
|
+
onInterrupt = deliverInterrupt,
|
|
301
|
+
/**
|
|
302
|
+
* ── ⭐ THE `/` SURFACE'S ONE SEAM ─────────────────────────────────────────
|
|
303
|
+
*
|
|
304
|
+
* Providers for the things a command reports on — skills, MCP servers, spend,
|
|
305
|
+
* the model. `bin/` owns where those facts come from; this loop only asks, for
|
|
306
|
+
* the same reason `workspace.mjs` takes `claimPath` and `journal` injected
|
|
307
|
+
* rather than importing them.
|
|
308
|
+
*
|
|
309
|
+
* ⚠️ `{}` BY DEFAULT, NOT `null`. Every command degrades to "not available in
|
|
310
|
+
* this session" on a missing provider (see `slash.mjs`), so an embedder that
|
|
311
|
+
* wires nothing still gets a working `/help` instead of a crash.
|
|
312
|
+
*/
|
|
313
|
+
slashContext = {},
|
|
314
|
+
}) {
|
|
315
|
+
const interactive = input.isTTY === true;
|
|
316
|
+
|
|
317
|
+
// ⚠️ A pipe is drained up front — see readAllLines. Doing this lazily is what
|
|
318
|
+
// silently lost every prompt after the first.
|
|
319
|
+
const queued = interactive ? null : await readAllLines(input);
|
|
320
|
+
let queueIndex = 0;
|
|
321
|
+
|
|
322
|
+
const rl = interactive ? createInterface({ input, output, terminal: true }) : null;
|
|
323
|
+
const state = { closed: false };
|
|
324
|
+
/**
|
|
325
|
+
* ⚠️ ATTACHED ONCE, NOT PER QUESTION — and the per-question version is why the
|
|
326
|
+
* first fix did not work. The stream can end WHILE the model call is in
|
|
327
|
+
* flight, when no question is pending and therefore no listener is attached;
|
|
328
|
+
* `close` fires into nothing, the flag stays false, and the next question
|
|
329
|
+
* throws anyway. A session-lifetime listener sees it whenever it happens.
|
|
330
|
+
*/
|
|
331
|
+
if (rl) rl.once('close', () => { state.closed = true; });
|
|
332
|
+
/**
|
|
333
|
+
* ⭐ THE ONE LINE THAT MAKES CTRL-C REACH THE RUN — see `deliverInterrupt`
|
|
334
|
+
* above for the Node source that proves it is needed. `on`, not `once`: an
|
|
335
|
+
* interactive session runs many turns and the SECOND Ctrl-C (the one that
|
|
336
|
+
* quits) has to arrive here too, or the escape hatch is a single-use one.
|
|
337
|
+
*/
|
|
338
|
+
if (rl) rl.on('SIGINT', () => { onInterrupt(); });
|
|
339
|
+
if (banner) output.write(`${banner}\n`);
|
|
340
|
+
// ⚠️ `/help` IS ADVERTISED IN THE ONE LINE EVERY SESSION PRINTS. A command
|
|
341
|
+
// surface nobody is told about is the same defect item 14 closed for `--help`:
|
|
342
|
+
// the feature worked and nothing a stranger would read mentioned it.
|
|
343
|
+
output.write('Type what you want done. "/help" for commands, "exit" to leave.\n\n');
|
|
344
|
+
|
|
345
|
+
let history = null;
|
|
346
|
+
let turns = 0;
|
|
347
|
+
/**
|
|
348
|
+
* ⭐ SET BY `/skills <name>`, CONSUMED BY THE NEXT REAL TURN AND THEN CLEARED.
|
|
349
|
+
* A skill that was printed to the terminal would look loaded and be invisible
|
|
350
|
+
* to the model; this is the variable that makes the verb real.
|
|
351
|
+
*/
|
|
352
|
+
let pendingInject = null;
|
|
353
|
+
|
|
354
|
+
try {
|
|
355
|
+
for (;;) {
|
|
356
|
+
const line = interactive
|
|
357
|
+
? await ask(rl, '› ', state)
|
|
358
|
+
: (queueIndex < queued.length ? queued[queueIndex++] : null);
|
|
359
|
+
// Echo a piped prompt so a scripted transcript reads like a session.
|
|
360
|
+
if (!interactive && line !== null && line.trim()) output.write(`› ${line.trim()}
|
|
361
|
+
`);
|
|
362
|
+
// ⚠️ EOF is not an error. A closed pipe or Ctrl-D ends the session the
|
|
363
|
+
// same way "exit" does — treating it as a fault would print a stack trace
|
|
364
|
+
// at the end of every scripted run.
|
|
365
|
+
if (line === null) break;
|
|
366
|
+
const task = line.trim();
|
|
367
|
+
if (!task) continue;
|
|
368
|
+
if (QUIT.has(task.toLowerCase())) break;
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* ── ⭐ THE `/` SURFACE, BEFORE ANYTHING IS SENT TO A MODEL ────────────
|
|
372
|
+
*
|
|
373
|
+
* ⚠️ IT COSTS NOTHING AND MUST NOT COUNT AS A TURN. `/cost` is asked
|
|
374
|
+
* precisely by somebody watching their spend, and answering it by
|
|
375
|
+
* incrementing the turn counter and appending to the history would make
|
|
376
|
+
* the question change the answer.
|
|
377
|
+
*
|
|
378
|
+
* ⚠️ AN UNRECOGNISED COMMAND IS ANSWERED HERE AND NOT FORWARDED. Passing
|
|
379
|
+
* `/skil` to the model gets a confident essay about a typo; the one thing
|
|
380
|
+
* the person needed was the word `/skills`, which `slash.mjs` supplies.
|
|
381
|
+
* See its header for why `/etc/hosts` is NOT treated as a command.
|
|
382
|
+
*/
|
|
383
|
+
const command = parseSlash(line.trim());
|
|
384
|
+
if (command) {
|
|
385
|
+
const result = runSlashCommand(command, slashContext);
|
|
386
|
+
for (const l of result.output ?? []) output.write(`${l}\n`);
|
|
387
|
+
output.write('\n');
|
|
388
|
+
if (result.effect === 'clear') history = null;
|
|
389
|
+
if (typeof result.inject === 'string' && result.inject) pendingInject = result.inject;
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* ⚠️ THE SKILL IS PREPENDED TO THE TASK, NOT SUBSTITUTED FOR IT. The user
|
|
395
|
+
* typed an instruction; the skill is context for it. And it is cleared
|
|
396
|
+
* BEFORE the call rather than after, so a turn that throws cannot leave it
|
|
397
|
+
* armed and silently attach it to an unrelated question later.
|
|
398
|
+
*/
|
|
399
|
+
let sendTask = task;
|
|
400
|
+
if (pendingInject) {
|
|
401
|
+
sendTask = `${pendingInject}\n\n---\n\n${task}`;
|
|
402
|
+
pendingInject = null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
let outcome;
|
|
406
|
+
try {
|
|
407
|
+
outcome = await runOne(sendTask, history);
|
|
408
|
+
} catch (err) {
|
|
409
|
+
/**
|
|
410
|
+
* ⚠️ ONE BAD TURN MUST NOT END THE SESSION. A timeout or a provider blip
|
|
411
|
+
* after twenty minutes of context is infuriating if it drops everything;
|
|
412
|
+
* the history is still valid, so report and keep the prompt.
|
|
413
|
+
*/
|
|
414
|
+
output.write(`\n ✖ that turn failed: ${String(err?.message || err)}\n\n`);
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
turns += 1;
|
|
419
|
+
render(outcome, output);
|
|
420
|
+
|
|
421
|
+
if (outcome?.ok && Array.isArray(outcome.messages)) {
|
|
422
|
+
history = trimHistory(outcome.messages, maxHistory);
|
|
423
|
+
} else if (!outcome?.ok) {
|
|
424
|
+
// A failed turn leaves history UNTOUCHED. Appending a turn that produced
|
|
425
|
+
// nothing would poison the next one with a dead exchange.
|
|
426
|
+
output.write(`\n (history unchanged — that turn did not complete)\n`);
|
|
427
|
+
}
|
|
428
|
+
output.write('\n');
|
|
429
|
+
}
|
|
430
|
+
} finally {
|
|
431
|
+
if (rl) rl.close();
|
|
432
|
+
}
|
|
433
|
+
return { turns };
|
|
434
|
+
}
|