acuvo-code 0.6.1 → 0.6.2
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/input-box.mjs +20 -1
- package/lib/session.mjs +207 -4
- package/package.json +1 -1
package/lib/input-box.mjs
CHANGED
|
@@ -528,7 +528,26 @@ export function pinRegion(output, { rows = 2, env = process.env } = {}) {
|
|
|
528
528
|
* screen. Both are needed and the order matters: erase the history, erase the
|
|
529
529
|
* screen, then home.
|
|
530
530
|
*/
|
|
531
|
-
|
|
531
|
+
/**
|
|
532
|
+
* ── ⚠️⚠️ THE CANONICAL CLEAR, IN THE CANONICAL ORDER — TWICE WRONG BEFORE ──
|
|
533
|
+
*
|
|
534
|
+
* Roman, twice: *"you have to scroll down to see the prompt area."*
|
|
535
|
+
*
|
|
536
|
+
* What `clear` itself emits is `ESC[H ESC[2J ESC[3J` — HOME FIRST, then erase
|
|
537
|
+
* the screen, then erase the scrollback. I had `3J 2J` with the home last, and
|
|
538
|
+
* the order is not cosmetic: erasing scrollback while the cursor is still
|
|
539
|
+
* parked in it leaves the viewport anchored to a region that no longer exists,
|
|
540
|
+
* so the terminal keeps showing the old shell output and our banner and prompt
|
|
541
|
+
* sit below the fold.
|
|
542
|
+
*
|
|
543
|
+
* ⭐ HOME FIRST puts the viewport at the top of the buffer BEFORE anything is
|
|
544
|
+
* erased, so there is nowhere stale for it to stay.
|
|
545
|
+
*
|
|
546
|
+
* ⚠️ AND THE SCROLL REGION IS SET LAST. Setting a region while the cursor is
|
|
547
|
+
* outside it is undefined across terminals — some clamp, some ignore it. Clear
|
|
548
|
+
* completely, then declare the region, then place the cursor inside it.
|
|
549
|
+
*/
|
|
550
|
+
output.write(`${CSI}H${CSI}2J${CSI}3J${CSI}1;${bottom}r${CSI}1;1H`);
|
|
532
551
|
|
|
533
552
|
const release = () => {
|
|
534
553
|
if (released) return;
|
package/lib/session.mjs
CHANGED
|
@@ -83,7 +83,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync,
|
|
|
83
83
|
import { dirname } from 'node:path';
|
|
84
84
|
|
|
85
85
|
import { resolveInWorkspace } from './workspace.mjs';
|
|
86
|
-
import { refusedCommitPath } from './secret-paths.mjs';
|
|
86
|
+
import { refusedCommitPath } from './secret-paths.mjs';
|
|
87
87
|
import { ensureAcuvoDirIgnored } from './acuvo-dir.mjs';
|
|
88
88
|
|
|
89
89
|
/** Scratch, alongside `plan.json`, `mcp.json` and the screenshots `see_page`
|
|
@@ -101,6 +101,43 @@ export const MAX_SESSIONS = 20;
|
|
|
101
101
|
/** Per-message ceiling. A single `read_file` of a 200KB source file would
|
|
102
102
|
* otherwise be most of the budget on its own. */
|
|
103
103
|
export const MAX_MESSAGE_CHARS = 8_000;
|
|
104
|
+
/**
|
|
105
|
+
* ── ⚠️⚠️ THE HEAD GETS ITS OWN CEILING, AND THE OLD ONE WAS A CACHE BUG ─────
|
|
106
|
+
*
|
|
107
|
+
* MEASURED 2026-08-22 on a real run against a stub provider, not reasoned about:
|
|
108
|
+
*
|
|
109
|
+
* live system message 12,118 chars
|
|
110
|
+
* saved system message 8,117 chars ← MAX_MESSAGE_CHARS + the truncation note
|
|
111
|
+
*
|
|
112
|
+
* `MAX_MESSAGE_CHARS` was applied to EVERY message including the system one, so
|
|
113
|
+
* a `--resume` handed the provider a prompt that diverged from the original at
|
|
114
|
+
* **character 8,121 of message zero**. Measured end to end (original round-2
|
|
115
|
+
* payload vs resumed round-1 payload): the byte-identical common prefix was
|
|
116
|
+
* **60.8%**, and prefix caching matches from the first token and stops at the
|
|
117
|
+
* first difference — so the divergence being inside message 0 means the WHOLE
|
|
118
|
+
* restored conversation was re-bought at full price. `lib/chat.mjs` puts the
|
|
119
|
+
* value of that prefix at 97.2% cached / 4.3x cheaper; this threw all of it
|
|
120
|
+
* away, silently, on the one code path whose entire justification is not paying
|
|
121
|
+
* twice for work already done.
|
|
122
|
+
*
|
|
123
|
+
* ⚠️ AND THE MONEY IS THE SMALLER HALF. The 4,118 characters that fell off the
|
|
124
|
+
* end of that system message were the SKILLS BLOCK — so a resumed model was
|
|
125
|
+
* reasoning under rules it had never been shown, which is exactly the failure
|
|
126
|
+
* `compact.mjs`'s header names ("the resumed model starts reasoning about
|
|
127
|
+
* instructions it was never given").
|
|
128
|
+
*
|
|
129
|
+
* ⭐ SO THE HEAD IS CAPPED SEPARATELY AND GENEROUSLY. It is bounded by
|
|
130
|
+
* construction anyway — the system message is a prompt this package assembles,
|
|
131
|
+
* and the first user message is `repo-map.mjs`'s output, which has its own
|
|
132
|
+
* budget. 60,000 is ~5x the largest head observed and still a quarter of
|
|
133
|
+
* MAX_SESSION_BYTES, so the file cap below stays the real bound.
|
|
134
|
+
*
|
|
135
|
+
* ⚠️ IT IS A CAP, NOT AN EXEMPTION. A head that somehow exceeds it is still
|
|
136
|
+
* truncated rather than allowed to blow the file limit — but the record then
|
|
137
|
+
* carries `headTruncated: true` so `resumeMessages` can SAY the cache will miss
|
|
138
|
+
* instead of letting someone believe they got the discount.
|
|
139
|
+
*/
|
|
140
|
+
export const MAX_HEAD_CHARS = 60_000;
|
|
104
141
|
export const MAX_TASK_CHARS = 400;
|
|
105
142
|
/** The metadata lists are for a HUMAN reading `--sessions`, so they are short
|
|
106
143
|
* by intent — the full detail is in the messages. */
|
|
@@ -475,15 +512,25 @@ function isCredentialCall(call) {
|
|
|
475
512
|
*/
|
|
476
513
|
export function sanitizeMessages(messages, { maxBytes = MAX_SESSION_BYTES, maxChars = MAX_MESSAGE_CHARS } = {}) {
|
|
477
514
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
478
|
-
return { messages: [], redactions: 0, droppedGroups: 0, droppedIncomplete: 0, truncated: false };
|
|
515
|
+
return { messages: [], redactions: 0, droppedGroups: 0, droppedIncomplete: 0, truncated: false, headTruncated: false };
|
|
479
516
|
}
|
|
480
517
|
const { head, groups } = groupMessages(messages);
|
|
481
518
|
const pruned = dropDanglingCalls(groups);
|
|
482
519
|
|
|
483
520
|
let redactions = 0;
|
|
521
|
+
/**
|
|
522
|
+
* ⭐ THE HEAD IS THE CACHEABLE PREFIX, so it is capped at MAX_HEAD_CHARS and
|
|
523
|
+
* not at the per-message ceiling — see MAX_HEAD_CHARS for the measurement that
|
|
524
|
+
* forced this apart. `headTruncated` is reported rather than hidden: a resume
|
|
525
|
+
* whose first message was clipped cannot hit the prompt cache, and the person
|
|
526
|
+
* paying for it is entitled to know that before the bill.
|
|
527
|
+
*/
|
|
528
|
+
let headTruncated = false;
|
|
529
|
+
const headCap = Math.max(maxChars, MAX_HEAD_CHARS);
|
|
484
530
|
const cleanHead = head.map((m) => {
|
|
485
|
-
const s = sanitizeMessage(m, { maxChars });
|
|
531
|
+
const s = sanitizeMessage(m, { maxChars: headCap });
|
|
486
532
|
redactions += s.redactions;
|
|
533
|
+
if (typeof m?.content === 'string' && m.content.length > headCap) headTruncated = true;
|
|
487
534
|
return s.message;
|
|
488
535
|
});
|
|
489
536
|
const cleanGroups = pruned.groups.map((group) => {
|
|
@@ -530,6 +577,7 @@ export function sanitizeMessages(messages, { maxBytes = MAX_SESSION_BYTES, maxCh
|
|
|
530
577
|
droppedGroups,
|
|
531
578
|
droppedIncomplete: pruned.dropped,
|
|
532
579
|
truncated: droppedGroups > 0,
|
|
580
|
+
headTruncated,
|
|
533
581
|
};
|
|
534
582
|
}
|
|
535
583
|
|
|
@@ -600,9 +648,30 @@ function extractActivity(executed) {
|
|
|
600
648
|
* A failed session is saved and listable; it is simply not RESUMABLE, and the
|
|
601
649
|
* record says so rather than leaving the caller to infer it.
|
|
602
650
|
*
|
|
651
|
+
* ── ⭐⭐⭐ `meta.live` — THE HALF THAT SURVIVES A LAPTOP DYING ────────────────
|
|
652
|
+
*
|
|
653
|
+
* MEASURED 2026-08-22 before this existed: a run was SIGKILLed mid-round after
|
|
654
|
+
* two completed rounds and two files written, and `.acuvo/sessions/` **did not
|
|
655
|
+
* exist at all**. Not empty — absent. The checkpoint journal survived (it is
|
|
656
|
+
* appended per write), so the FILES could be put back and the CONVERSATION that
|
|
657
|
+
* produced them was gone. Every save happened at the end of a turn, so the one
|
|
658
|
+
* run you would most want to carry on — the one that died — was the only kind
|
|
659
|
+
* that left nothing to carry on from.
|
|
660
|
+
*
|
|
661
|
+
* ⭐ A live save is the same record, written early and rewritten as the run
|
|
662
|
+
* goes. `closedCleanly: false` is the crash marker; the final save at the end of
|
|
663
|
+
* the turn passes the SAME id and flips it true. So there is exactly one file
|
|
664
|
+
* per turn, exactly as before, and the only new state on disk is one boolean.
|
|
665
|
+
*
|
|
666
|
+
* ⚠️ `pid` IS RECORDED BECAUSE "NOT CLOSED" AND "STILL RUNNING" LOOK IDENTICAL
|
|
667
|
+
* ON DISK. Seven terminals in one workspace is the documented normal case here;
|
|
668
|
+
* offering to resume a conversation another live process is in the middle of
|
|
669
|
+
* would be worse than never offering at all. `findCrashedSession` refuses any
|
|
670
|
+
* record whose pid still answers.
|
|
671
|
+
*
|
|
603
672
|
* @param {string} root
|
|
604
673
|
* @param {any} outcome the SessionOutcome from turn.mjs
|
|
605
|
-
* @param {{ task?: string, id?: string, now?: Date, keep?: number }} [meta]
|
|
674
|
+
* @param {{ task?: string, id?: string, now?: Date, keep?: number, live?: boolean, pid?: number }} [meta]
|
|
606
675
|
* @returns {SessionSaved | SessionRefused}
|
|
607
676
|
*/
|
|
608
677
|
export function saveSession(root, outcome, meta = {}) {
|
|
@@ -641,9 +710,23 @@ export function saveSession(root, outcome, meta = {}) {
|
|
|
641
710
|
// and it is the difference between a listable record and a resumable one.
|
|
642
711
|
resumable: clean.messages.length > 0,
|
|
643
712
|
truncated: clean.truncated,
|
|
713
|
+
/**
|
|
714
|
+
* ⚠️ A CLIPPED HEAD MEANS THE PROMPT CACHE CANNOT HIT ON RESUME — see
|
|
715
|
+
* MAX_HEAD_CHARS. Recorded so `resumeMessages` states it instead of leaving
|
|
716
|
+
* someone to discover it on the invoice.
|
|
717
|
+
*/
|
|
718
|
+
headTruncated: clean.headTruncated === true,
|
|
644
719
|
droppedGroups: clean.droppedGroups,
|
|
645
720
|
droppedIncomplete: clean.droppedIncomplete,
|
|
646
721
|
redactions: clean.redactions,
|
|
722
|
+
/**
|
|
723
|
+
* ⚠️ FALSE MEANS "THIS PROCESS NEVER GOT TO THE END", NOT "IT FAILED". A run
|
|
724
|
+
* that stopped at the round cap closed cleanly; a run whose laptop shut is
|
|
725
|
+
* the one this flag is for. Absent (an older record) is read as TRUE, so
|
|
726
|
+
* upgrading never manufactures a crash offer for a run that finished fine.
|
|
727
|
+
*/
|
|
728
|
+
closedCleanly: meta.live !== true,
|
|
729
|
+
pid: typeof meta.pid === 'number' ? meta.pid : process.pid,
|
|
647
730
|
messages: clean.messages,
|
|
648
731
|
};
|
|
649
732
|
|
|
@@ -792,6 +875,17 @@ export function resumeMessages(root, id) {
|
|
|
792
875
|
if (s.files.length > 0) {
|
|
793
876
|
bits.push(`Files it touched: ${s.files.slice(0, 12).map((f) => f.path).join(', ')}${s.files.length > 12 ? `, +${s.files.length - 12} more` : ''}.`);
|
|
794
877
|
}
|
|
878
|
+
if (s.closedCleanly === false) {
|
|
879
|
+
/**
|
|
880
|
+
* ⚠️ SAY THAT IT DIED, because the model's own transcript gives it no way to
|
|
881
|
+
* tell. A record closed at the round cap and a record whose process was
|
|
882
|
+
* killed look identical from the inside — and the difference matters: the
|
|
883
|
+
* killed one may have started a tool call whose EFFECT landed while its
|
|
884
|
+
* result never did, so "check the disk before redoing anything" is advice
|
|
885
|
+
* only this branch can honestly give.
|
|
886
|
+
*/
|
|
887
|
+
bits.push('⚠️ That run was killed mid-round (the process never reached the end), so its final round may be missing from this history even though its work may already be on disk.');
|
|
888
|
+
}
|
|
795
889
|
if (s.droppedIncomplete > 0) {
|
|
796
890
|
bits.push('Its last round was incomplete and has been discarded, so the final tool call it started never finished.');
|
|
797
891
|
}
|
|
@@ -817,6 +911,16 @@ export function resumeMessages(root, id) {
|
|
|
817
911
|
messages: [...s.messages, { role: 'user', content: note }],
|
|
818
912
|
note,
|
|
819
913
|
rootChanged,
|
|
914
|
+
/**
|
|
915
|
+
* ⭐ WHETHER THE DISCOUNT SURVIVED. The first two messages ARE the cacheable
|
|
916
|
+
* prefix, so a record whose head was clipped resumes into a prompt that
|
|
917
|
+
* differs from the original at message zero and cannot hit the cache at all
|
|
918
|
+
* — see MAX_HEAD_CHARS for the 60.8% this was measured at before the head
|
|
919
|
+
* got its own ceiling. Reported so the CLI can say so out loud; silence here
|
|
920
|
+
* is how a 4.3x price difference goes unnoticed.
|
|
921
|
+
*/
|
|
922
|
+
headTruncated: s.headTruncated === true,
|
|
923
|
+
crashed: s.closedCleanly === false,
|
|
820
924
|
replayed: false,
|
|
821
925
|
};
|
|
822
926
|
}
|
|
@@ -863,12 +967,111 @@ export function listSessions(root, { limit = 10 } = {}) {
|
|
|
863
967
|
commands: s.commands.length,
|
|
864
968
|
stoppedBecause: s.stoppedBecause,
|
|
865
969
|
resumable: s.resumable === true,
|
|
970
|
+
// ⚠️ ABSENT READS AS CLEAN. Records written before this field existed are
|
|
971
|
+
// finished runs, and inventing a crash for them would greet every upgrade
|
|
972
|
+
// with an offer to recover something that never broke.
|
|
973
|
+
closedCleanly: s.closedCleanly !== false,
|
|
974
|
+
pid: typeof s.pid === 'number' ? s.pid : null,
|
|
866
975
|
summary: summarizeSession(s),
|
|
867
976
|
});
|
|
868
977
|
}
|
|
869
978
|
return { ok: true, sessions, unreadable };
|
|
870
979
|
}
|
|
871
980
|
|
|
981
|
+
/**
|
|
982
|
+
* ── ⭐⭐⭐ "MY LAPTOP CRASHED. WHERE DID MY CONVERSATION GO?" ────────────────
|
|
983
|
+
*
|
|
984
|
+
* The one question this module existed to answer and could not. `listSessions`
|
|
985
|
+
* shows you everything and makes you pick; `--continue` takes the newest and
|
|
986
|
+
* asks nothing. Neither of them tells you, unprompted, that the run you were in
|
|
987
|
+
* the middle of never finished — and a recovery you have to already know about
|
|
988
|
+
* is a recovery for the person who does not need it.
|
|
989
|
+
*
|
|
990
|
+
* ⚠️ THREE THINGS MUST ALL BE TRUE, and each one has a case behind it:
|
|
991
|
+
*
|
|
992
|
+
* · `closedCleanly === false` — it really did die mid-run. A run that stopped
|
|
993
|
+
* at the round cap is FINISHED; offering to rescue it would train people to
|
|
994
|
+
* dismiss this prompt, which is how the real one gets dismissed too.
|
|
995
|
+
* · `resumable` — there is a conversation to restore. A record with no
|
|
996
|
+
* messages can be listed and cannot be continued, and offering it would be
|
|
997
|
+
* a promise the next step breaks.
|
|
998
|
+
* · the pid does not answer — nobody is running it RIGHT NOW. Seven terminals
|
|
999
|
+
* in one workspace is the documented normal case for this tool, and every
|
|
1000
|
+
* one of them holds an open live record. Without this check, opening a
|
|
1001
|
+
* second terminal would offer to "recover" the session the first one is
|
|
1002
|
+
* still working in.
|
|
1003
|
+
*
|
|
1004
|
+
* ⚠️ AND IT NEVER THROWS. It runs on the startup path of every ordinary run, so
|
|
1005
|
+
* a corrupt file or an unreadable directory must cost the offer and nothing
|
|
1006
|
+
* else.
|
|
1007
|
+
*
|
|
1008
|
+
* @param {string} root
|
|
1009
|
+
* @param {{ limit?: number, selfPid?: number, isAlive?: (pid: number) => boolean }} [opts]
|
|
1010
|
+
* @returns {{ ok: true, crashed: null | { id: string, savedAt: string, task: string, roundsUsed: number, files: number, pid: number | null, summary: string } } | SessionRefused}
|
|
1011
|
+
*/
|
|
1012
|
+
export function findCrashedSession(root, { limit = 5, selfPid = process.pid, isAlive = pidIsAlive } = {}) {
|
|
1013
|
+
let listed;
|
|
1014
|
+
try { listed = listSessions(root, { limit }); } catch (e) { return { ok: false, error: err(e) }; }
|
|
1015
|
+
if (!listed.ok) return listed;
|
|
1016
|
+
|
|
1017
|
+
for (const s of listed.sessions) {
|
|
1018
|
+
if (s.closedCleanly) continue;
|
|
1019
|
+
if (!s.resumable) continue;
|
|
1020
|
+
/**
|
|
1021
|
+
* ⚠️ OUR OWN PID IS "ALIVE" TRIVIALLY, so it is excluded explicitly rather
|
|
1022
|
+
* than relied on. A process that re-enters this function after writing its
|
|
1023
|
+
* own live record would otherwise skip it for the right reason by accident,
|
|
1024
|
+
* and an accident is not a guard.
|
|
1025
|
+
*/
|
|
1026
|
+
if (s.pid !== null && (s.pid === selfPid || isAlive(s.pid))) continue;
|
|
1027
|
+
return {
|
|
1028
|
+
ok: true,
|
|
1029
|
+
crashed: {
|
|
1030
|
+
id: s.id, savedAt: s.savedAt, task: s.task, roundsUsed: s.roundsUsed,
|
|
1031
|
+
files: s.files, pid: s.pid, summary: s.summary,
|
|
1032
|
+
},
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
return { ok: true, crashed: null };
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* Does this pid still answer?
|
|
1040
|
+
*
|
|
1041
|
+
* ⚠️ `EPERM` MEANS ALIVE, NOT DEAD, and reading it the other way is the
|
|
1042
|
+
* dangerous direction: a pid owned by another user exists, and treating it as
|
|
1043
|
+
* gone is how we offer to resume a conversation somebody else is having. Only
|
|
1044
|
+
* `ESRCH` — no such process — is an answer of "no".
|
|
1045
|
+
*/
|
|
1046
|
+
export function pidIsAlive(pid) {
|
|
1047
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
1048
|
+
try {
|
|
1049
|
+
process.kill(pid, 0);
|
|
1050
|
+
return true;
|
|
1051
|
+
} catch (e) {
|
|
1052
|
+
return e?.code !== 'ESRCH';
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* ⭐ THE EXACT SENTENCE A PERSON SEES AFTER THEIR LAPTOP DIED, exported so the
|
|
1058
|
+
* test asserts what they read rather than a paraphrase of it — the rule
|
|
1059
|
+
* `interrupt.mjs` set for FIRST_PRESS_NOTICE, for the same reason: the promise
|
|
1060
|
+
* this line makes has to stay wired to the code that keeps it.
|
|
1061
|
+
*
|
|
1062
|
+
* ⚠️ IT NAMES THE COMMAND. "A previous run did not finish" with no next step is
|
|
1063
|
+
* a notification, not a recovery, and the person reading it has just lost work.
|
|
1064
|
+
*/
|
|
1065
|
+
export function crashOfferLines(crashed, { command = 'acuvo --continue' } = {}) {
|
|
1066
|
+
const when = String(crashed?.savedAt ?? '').slice(0, 16).replace('T', ' ');
|
|
1067
|
+
const task = String(crashed?.task ?? '').replace(/\s+/g, ' ').trim();
|
|
1068
|
+
return [
|
|
1069
|
+
` ⚠ a run in this workspace never finished — it stopped mid-round at ${when} (UTC).`,
|
|
1070
|
+
` ${crashed?.roundsUsed ?? 0} round${crashed?.roundsUsed === 1 ? '' : 's'} and ${crashed?.files ?? 0} file change${crashed?.files === 1 ? '' : 's'} were recorded: ${task.length > 64 ? `${task.slice(0, 63)}…` : task}`,
|
|
1071
|
+
` Its conversation was saved. Carry on from it with: ${command}`,
|
|
1072
|
+
];
|
|
1073
|
+
}
|
|
1074
|
+
|
|
872
1075
|
/**
|
|
873
1076
|
* One line, and it has to earn its width: the id (which is what you type to
|
|
874
1077
|
* resume), when, how far it got, what it produced, and why it stopped. The task
|