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,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ THE FIRST CTRL-C ASKS; THE SECOND ONE INSISTS ───────────────────────
|
|
3
|
+
*
|
|
4
|
+
* Today Ctrl-C kills the process outright, and a killed run loses its SESSION
|
|
5
|
+
* and its AUDIT LINE — so the run a user most wants to resume is the one that
|
|
6
|
+
* leaves nothing behind. That is the worst possible moment to lose everything:
|
|
7
|
+
* they interrupted because something was going wrong, and now there is no
|
|
8
|
+
* transcript to look at and no record it ever ran.
|
|
9
|
+
*
|
|
10
|
+
* ⭐ So the first press asks the loop to stop at the end of its round — the
|
|
11
|
+
* transcript saves, the cost is recorded, the changes are reported, `--resume`
|
|
12
|
+
* works. The second press is the escape hatch, because a user who presses twice
|
|
13
|
+
* has stopped negotiating and a tool that ignores that is broken.
|
|
14
|
+
*
|
|
15
|
+
* ── ⚠️⚠️ WHY THIS IS A SHARED POLICY AND NOT A SIXTH LISTENER ───────────────
|
|
16
|
+
*
|
|
17
|
+
* FIVE modules already register `SIGINT` — background, lsp, repl, tsserver and
|
|
18
|
+
* turn — and each does `cleanup(); process.exit(code)`. Node runs listeners in
|
|
19
|
+
* registration order, so whichever fires first exits, and a sixth listener that
|
|
20
|
+
* merely wanted to be polite would be overruled by whichever of the five
|
|
21
|
+
* happened to be loaded. Their cleanup is not optional either: it reaps real
|
|
22
|
+
* child processes, and this package has already found two true orphans on this
|
|
23
|
+
* machine.
|
|
24
|
+
*
|
|
25
|
+
* ⭐ So they keep their cleanup and consult `exitIsDeferred()` before exiting.
|
|
26
|
+
* One line each, and the ownership stays where the children are.
|
|
27
|
+
*
|
|
28
|
+
* ── ⚠️ THE RULES THIS FILE MUST NOT BREAK, ALL LEARNED ALREADY ──────────────
|
|
29
|
+
*
|
|
30
|
+
* · A SIGINT listener SUPPRESSES Node's default terminate-on-Ctrl-C. So a
|
|
31
|
+
* path that neither aborts nor exits leaves Ctrl-C doing nothing at all,
|
|
32
|
+
* which is a worse bug than the one being fixed.
|
|
33
|
+
* · Exit is 128+n, never 1. `bin/acuvo.mjs` spends 1 on "the code it wrote
|
|
34
|
+
* still does not pass"; an interrupt is a different answer to a different
|
|
35
|
+
* question and a script that cannot tell them apart retries the wrong one.
|
|
36
|
+
* · With NO graceful handler registered, behaviour is exactly as before —
|
|
37
|
+
* immediate exit. Every existing caller is unchanged.
|
|
38
|
+
*
|
|
39
|
+
* Zero dependencies, no timers, no I/O.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** The handler to call on a first interrupt, or null when nobody is listening. */
|
|
43
|
+
let graceful = null;
|
|
44
|
+
/** True once a first interrupt has been honoured, so the next one is fatal. */
|
|
45
|
+
let asked = false;
|
|
46
|
+
/**
|
|
47
|
+
* ── ⚠️⚠️ ONE KEYPRESS, MANY LISTENERS, AND THE ANSWER MUST NOT CHANGE ───────
|
|
48
|
+
*
|
|
49
|
+
* THE DEFECT THIS EXISTS TO CLOSE, found by refutation and reproduced on the
|
|
50
|
+
* real CLI: `asked` was consumed once per PROCESS, and Node invokes EVERY
|
|
51
|
+
* SIGINT listener for a SINGLE delivery, synchronously, in registration order.
|
|
52
|
+
* So with two listeners the first press did the graceful abort AND THEN DIED —
|
|
53
|
+
* listener one deferred and printed "stopping after this round", listener two
|
|
54
|
+
* saw `asked === true`, read that as "second press", and called
|
|
55
|
+
* `process.exit()` on the spot. No summary, no session, no audit line, no
|
|
56
|
+
* `--resume`. Strictly worse than the bug this file was written to fix, because
|
|
57
|
+
* the old behaviour at least did not print a promise it then broke.
|
|
58
|
+
*
|
|
59
|
+
* ⚠️ AND TWO LISTENERS IS THE ORDINARY CASE, not a corner. `turn.mjs` installs
|
|
60
|
+
* one unconditionally; `repl`, `start_process`, tsserver and LSP each install
|
|
61
|
+
* another the moment the model uses that tool. ONE `repl` call was enough.
|
|
62
|
+
*
|
|
63
|
+
* ⭐ THE FIX IS TO MAKE THE DEFERRAL PER-DELIVERY. Every listener Node invokes
|
|
64
|
+
* for one signal gets the same answer, and only a genuinely LATER press — a new
|
|
65
|
+
* delivery, therefore a new tick — is fatal. `process.nextTick` runs after the
|
|
66
|
+
* current stack unwinds, which is after the last listener for this delivery and
|
|
67
|
+
* before any subsequent signal, so it is exactly the boundary we mean.
|
|
68
|
+
*
|
|
69
|
+
* ⚠️ THE OLD TEST COULD NOT HAVE CAUGHT THIS. It asserted `SIGINT_LISTENERS=1`
|
|
70
|
+
* as if that were a constant of the system, in the one session shape where it
|
|
71
|
+
* happens to be true. A number pinned in the only configuration that satisfies
|
|
72
|
+
* it is not a guard.
|
|
73
|
+
*/
|
|
74
|
+
let deferringThisDelivery = false;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Ask to be told about the first interrupt instead of dying on it.
|
|
78
|
+
*
|
|
79
|
+
* @param {(reason: string) => void} handler called once, on the first signal
|
|
80
|
+
* @returns {() => void} dispose — MUST be called when the run ends, or a later
|
|
81
|
+
* Ctrl-C in the same process (an interactive session runs many turns) would
|
|
82
|
+
* be swallowed by a handler belonging to a run that finished long ago.
|
|
83
|
+
*/
|
|
84
|
+
export function onFirstInterrupt(handler) {
|
|
85
|
+
const fn = typeof handler === 'function' ? handler : null;
|
|
86
|
+
graceful = fn;
|
|
87
|
+
asked = false;
|
|
88
|
+
/**
|
|
89
|
+
* ⚠️ THE DISPOSE ONLY CLEARS ITS OWN REGISTRATION, and that guard is not
|
|
90
|
+
* theoretical tidiness. `dispose()` lands in a `finally`, and a `finally`
|
|
91
|
+
* runs LATE — if a second run has already armed itself by then (the
|
|
92
|
+
* escalation ladder runs rungs back to back, and the steering loop runs
|
|
93
|
+
* segments back to back), an unguarded `graceful = null` would disarm the
|
|
94
|
+
* LIVE run on behalf of a dead one. The symptom would be the worst one this
|
|
95
|
+
* file knows: Ctrl-C doing nothing, intermittently.
|
|
96
|
+
*/
|
|
97
|
+
return () => { if (graceful === fn) { graceful = null; asked = false; deferringThisDelivery = false; } };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Called by the five signal handlers before they exit.
|
|
102
|
+
*
|
|
103
|
+
* ⚠️ IT HAS A SIDE EFFECT, AND THE NAME SAYS ONLY HALF OF THAT. The first call
|
|
104
|
+
* consumes the graceful chance and fires the handler; the second returns false
|
|
105
|
+
* so the caller exits. It is written this way because the alternative — a
|
|
106
|
+
* separate `notice()` and `shouldExit()` — is two calls that five call sites
|
|
107
|
+
* would eventually get out of order.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} [reason] what to tell the run
|
|
110
|
+
* @returns {boolean} true when the caller must NOT exit
|
|
111
|
+
*/
|
|
112
|
+
export function exitIsDeferred(reason = 'you pressed Ctrl-C', { schedule = process.nextTick } = {}) {
|
|
113
|
+
if (!graceful) return false;
|
|
114
|
+
/**
|
|
115
|
+
* ⭐ THE SAME KEYPRESS, ARRIVING AT A SECOND LISTENER. It must get the same
|
|
116
|
+
* answer as the first, or the press that was honoured also kills the process.
|
|
117
|
+
* Checked BEFORE `asked`, because `asked` is already true by now.
|
|
118
|
+
*/
|
|
119
|
+
if (deferringThisDelivery) return true;
|
|
120
|
+
if (asked) return false;
|
|
121
|
+
asked = true;
|
|
122
|
+
const fn = graceful;
|
|
123
|
+
try {
|
|
124
|
+
fn(reason);
|
|
125
|
+
} catch {
|
|
126
|
+
/**
|
|
127
|
+
* ⚠️ A THROWING HANDLER MUST NOT WEDGE THE PROCESS. If the graceful path is
|
|
128
|
+
* broken, the honest outcome is the old one — exit now — not a Ctrl-C that
|
|
129
|
+
* does nothing because our own callback failed.
|
|
130
|
+
*
|
|
131
|
+
* ⚠️ AND THE WINDOW IS DELIBERATELY NOT OPENED. A handler that threw did not
|
|
132
|
+
* defer anything, so the remaining listeners for this delivery must exit
|
|
133
|
+
* too — telling them to hold would be the wedge this comment forbids.
|
|
134
|
+
*/
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
deferringThisDelivery = true;
|
|
138
|
+
schedule(() => { deferringThisDelivery = false; });
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Test seam: forget any registered handler. Never called in production. */
|
|
143
|
+
export function resetInterruptState() {
|
|
144
|
+
graceful = null;
|
|
145
|
+
asked = false;
|
|
146
|
+
deferringThisDelivery = false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Whether a first interrupt has already been honoured — for messaging only. */
|
|
150
|
+
export function interruptAlreadyRequested() {
|
|
151
|
+
return asked;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* ── ⚠️⭐ THE EXIT CODE. 128+n, AND THE REASON IS NOT STYLE ──────────────────
|
|
156
|
+
*
|
|
157
|
+
* `bin/acuvo.mjs` spends exit **1** on "the code it wrote still does not pass"
|
|
158
|
+
* — a VERDICT. An interrupt is a different answer to a different question, and
|
|
159
|
+
* a script that cannot tell them apart retries the wrong one: it would re-run
|
|
160
|
+
* the agent because a human walked away, or give up on a fixable test failure.
|
|
161
|
+
* 128+SIGINT(2) = 130 is the shell convention and collides with none of this
|
|
162
|
+
* package's documented 0 / 1 / 2 / 3 / 64.
|
|
163
|
+
*/
|
|
164
|
+
export const EXIT_INTERRUPTED = 130;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* ⭐ WHAT THE FIRST PRESS SAYS, IMMEDIATELY. Exported so the test asserts the
|
|
168
|
+
* exact sentence the user sees rather than a paraphrase, and so the promise it
|
|
169
|
+
* makes ("press again to quit now") stays wired to the code that keeps it.
|
|
170
|
+
*
|
|
171
|
+
* ⚠️ IT NAMES BOTH HALVES ON PURPOSE. A press that silently changed a hidden
|
|
172
|
+
* flag would read as Ctrl-C being broken — which is the failure this whole file
|
|
173
|
+
* exists to avoid — and a user who is not told the second press is available
|
|
174
|
+
* has no way to escape a round that hangs.
|
|
175
|
+
*/
|
|
176
|
+
export const FIRST_PRESS_NOTICE =
|
|
177
|
+
'stopping after this round — press Ctrl-C again to quit now';
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* ── ⭐⭐ ARM ONE RUN. The ONLY thing a caller has to get right is `dispose` ──
|
|
181
|
+
*
|
|
182
|
+
* Turns the module's policy into the three things a run actually needs: a
|
|
183
|
+
* signal to hand `runSession`, a dispose to call when the run ends, and an
|
|
184
|
+
* answer to "was this run interrupted?" for the exit code.
|
|
185
|
+
*
|
|
186
|
+
* ⚠️ THE ABORT HAPPENS BEFORE THE NOTICE, AND THE NOTICE IS IN A `finally`.
|
|
187
|
+
* Order matters twice over. Aborting first means a `notify` that throws still
|
|
188
|
+
* leaves the run cancelled; the `finally` means an abort that throws still
|
|
189
|
+
* tells the user what happened. `exitIsDeferred` catches a throwing handler and
|
|
190
|
+
* exits instead — so the worst case here is the OLD behaviour (immediate exit),
|
|
191
|
+
* never a Ctrl-C that did nothing.
|
|
192
|
+
*
|
|
193
|
+
* ⚠️ ALWAYS ARMED, TTY OR NOT. A `kill -INT` from a CI runner or a supervisor
|
|
194
|
+
* deserves the same graceful stop as a keypress; gating on `isTTY` would make
|
|
195
|
+
* the unattended fleet the one place a stop still loses the transcript.
|
|
196
|
+
*
|
|
197
|
+
* @param {{ notify?: (notice: string, reason: string) => void, controller?: AbortController }} [opts]
|
|
198
|
+
* @returns {{ signal: AbortSignal, dispose: () => void, wasInterrupted: () => boolean }}
|
|
199
|
+
*/
|
|
200
|
+
export function armInterrupt({ notify = () => {}, controller = new AbortController() } = {}) {
|
|
201
|
+
let fired = false;
|
|
202
|
+
const dispose = onFirstInterrupt((reason) => {
|
|
203
|
+
fired = true;
|
|
204
|
+
const why = typeof reason === 'string' && reason.trim() ? reason : 'you pressed Ctrl-C';
|
|
205
|
+
try {
|
|
206
|
+
controller.abort(why);
|
|
207
|
+
} finally {
|
|
208
|
+
notify(FIRST_PRESS_NOTICE, why);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
return { signal: controller.signal, dispose, wasInterrupted: () => fired };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* ── ⭐ DID THIS RUN END BECAUSE OF THE KEYPRESS, OR MERELY DURING IT? ───────
|
|
216
|
+
*
|
|
217
|
+
* ⚠️ BOTH HALVES ARE REQUIRED, and dropping either one produces a wrong exit
|
|
218
|
+
* code in a real case:
|
|
219
|
+
*
|
|
220
|
+
* · `interrupted` alone — a press that lands while the loop is already in its
|
|
221
|
+
* LAST round leaves `stoppedBecause: 'verified'`. The run finished, the
|
|
222
|
+
* tests passed, the work is on disk. Reporting 130 there would tell a
|
|
223
|
+
* script to retry a job that succeeded.
|
|
224
|
+
* · `stoppedBecause === 'aborted'` alone — the signal is also how a lost
|
|
225
|
+
* lease or a fleet ceiling stops a run (see `abort-signal.test.mjs`, which
|
|
226
|
+
* names three distinct causes on purpose). Those are not interrupts and
|
|
227
|
+
* must keep the ordinary verdict exit.
|
|
228
|
+
*
|
|
229
|
+
* @param {{ interrupted?: boolean, outcome?: { stoppedBecause?: string } | null }} state
|
|
230
|
+
* @returns {boolean}
|
|
231
|
+
*/
|
|
232
|
+
export function wasAbortedByInterrupt({ interrupted, outcome } = {}) {
|
|
233
|
+
return interrupted === true && outcome?.stoppedBecause === 'aborted';
|
|
234
|
+
}
|
package/lib/learned.mjs
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ WHAT THE AGENT LEARNS, KEPT ─────────────────────────────────────────
|
|
3
|
+
*
|
|
4
|
+
* `project-memory.mjs` opens by naming the prize exactly right: *"what a wrapper
|
|
5
|
+
* CAN own is the accumulated context — the thing that makes session forty better
|
|
6
|
+
* than session one."* Then it delivers a file a HUMAN writes. ACUVO.md is
|
|
7
|
+
* conventions someone typed; nothing the agent DISCOVERS survives the process
|
|
8
|
+
* exiting.
|
|
9
|
+
*
|
|
10
|
+
* So session forty re-derives what session one already learned, and gets it
|
|
11
|
+
* wrong in the same way — `npm test` that runs zero tests, a `require()` in an
|
|
12
|
+
* ESM project, the dev server on the wrong port. Every one of those is a fact
|
|
13
|
+
* the agent held for a few minutes and then threw away.
|
|
14
|
+
*
|
|
15
|
+
* ⭐ `session.mjs` FIXED RESUMING ONE RUN. This is the other half: facts that
|
|
16
|
+
* outlive every run. Together they are the difference between a tool you
|
|
17
|
+
* re-explain your project to daily and one that knows it.
|
|
18
|
+
*
|
|
19
|
+
* ── ⚠️ THE FIVE RULES, AND THE FAILURE EACH ONE PREVENTS ────────────────────
|
|
20
|
+
*
|
|
21
|
+
* 1. ⚠️⚠️ A WRONG MEMORY IS WORSE THAN NO MEMORY, and it is the reason this
|
|
22
|
+
* module is mostly restraint. A false fact goes into EVERY future prompt and
|
|
23
|
+
* the model acts on it forever — and it presents as the model being stupid
|
|
24
|
+
* rather than as us having poisoned it. This repo watched a TEST pin a false
|
|
25
|
+
* claim in place for a day while the model quoted it straight back and burned
|
|
26
|
+
* a round working around output that was already correct. So every entry
|
|
27
|
+
* carries WHY it was learned, and `forget()` is one call.
|
|
28
|
+
*
|
|
29
|
+
* 2. ⚠️ IT MUST NOT GROW FOREVER. Memory that only accumulates eventually eats
|
|
30
|
+
* the very context budget it exists to save. Bounded by entry count and by
|
|
31
|
+
* bytes; the oldest is evicted first.
|
|
32
|
+
*
|
|
33
|
+
* 3. ⚠️ NO SECRET IS EVER WRITTEN. The agent is routinely holding `.env`
|
|
34
|
+
* contents and API keys in its context, and these files are meant to be
|
|
35
|
+
* COMMITTED. The redaction here is deliberately blunt: a fact that looks like
|
|
36
|
+
* it carries a credential is refused outright rather than cleaned, because a
|
|
37
|
+
* half-redacted secret in a git history is still a leaked secret.
|
|
38
|
+
*
|
|
39
|
+
* 4. ⚠️ IT MUST BE REVIEWABLE — markdown under `.acuvo/memory/`, diffable and
|
|
40
|
+
* committable, for exactly the reason project-memory.mjs gives for ACUVO.md:
|
|
41
|
+
* a hidden per-user store means two developers on one codebase get two
|
|
42
|
+
* different agents and neither can see why.
|
|
43
|
+
*
|
|
44
|
+
* 5. ⭐ IT MUST NOT BREAK PROMPT CACHING. Measured 2026-08-11: DeepSeek caches
|
|
45
|
+
* automatically and gives **3.05x**, but only when the prompt prefix repeats
|
|
46
|
+
* BYTE-FOR-BYTE. The block this module renders is therefore DETERMINISTIC —
|
|
47
|
+
* sorted, no timestamps in the body, no reordering — so it is identical on
|
|
48
|
+
* every round of a session. Anything that reshuffled here would silently cost
|
|
49
|
+
* three times the money and nothing would report it.
|
|
50
|
+
*
|
|
51
|
+
* ── ⚠️ WHAT IS WORTH REMEMBERING, WHICH IS THE HARD PART ────────────────────
|
|
52
|
+
* Not "I read src/index.js". A memory is worth keeping only if a FUTURE session
|
|
53
|
+
* would otherwise get it wrong: the real test command, the module system, where
|
|
54
|
+
* things live, a convention, a mistake that cost rounds. The tool description
|
|
55
|
+
* says so in those words, because the model is the one deciding.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
import { readFileSync, writeFileSync, readdirSync, mkdirSync, rmSync, existsSync } from 'node:fs';
|
|
59
|
+
import { join } from 'node:path';
|
|
60
|
+
import { byCodePointOn } from './prefix-order.mjs';
|
|
61
|
+
|
|
62
|
+
/** Where learned facts live. Inside the workspace, so it can be committed. */
|
|
63
|
+
export const LEARNED_DIR = join('.acuvo', 'memory');
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Bounds. Both exist because the failure they prevent is silent: a memory that
|
|
67
|
+
* grows without limit does not error, it just quietly consumes the context
|
|
68
|
+
* budget until the useful part no longer fits.
|
|
69
|
+
*/
|
|
70
|
+
export const MAX_LEARNED_ENTRIES = 40;
|
|
71
|
+
export const MAX_LEARNED_BYTES = 4_000;
|
|
72
|
+
export const MAX_FACT_CHARS = 400;
|
|
73
|
+
export const MAX_WHY_CHARS = 200;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* ⚠️ REFUSE, DO NOT REDACT. A fact that looks like it carries a credential is
|
|
77
|
+
* rejected whole. Cleaning it and storing the rest is worse: it teaches the
|
|
78
|
+
* model the call succeeded, and a partially-redacted secret committed to a git
|
|
79
|
+
* history is still a leaked secret.
|
|
80
|
+
*
|
|
81
|
+
* Deliberately broad. A false positive costs one refused memory and a clear
|
|
82
|
+
* message; a false negative costs a key in a public repo.
|
|
83
|
+
*/
|
|
84
|
+
const SECRET_SHAPES = [
|
|
85
|
+
/sk-[a-z]*-?v?\d?-?[A-Za-z0-9_-]{16,}/i, // OpenAI / OpenRouter
|
|
86
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}/, // GitHub
|
|
87
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}/, // Slack
|
|
88
|
+
/\bAKIA[0-9A-Z]{16}\b/, // AWS
|
|
89
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
90
|
+
/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\./, // JWT
|
|
91
|
+
/(api[_-]?key|secret|password|token|passwd|credential)\s*[=:]\s*\S{8,}/i,
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
export function looksLikeSecret(text) {
|
|
95
|
+
const s = String(text ?? '');
|
|
96
|
+
for (const rx of SECRET_SHAPES) {
|
|
97
|
+
const m = s.match(rx);
|
|
98
|
+
if (m) return m[0].slice(0, 12);
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* ⚠️ A NAME BECOMES A FILENAME, so it is the one field an attacker (or a
|
|
105
|
+
* confused model) could use to write outside the directory. Reduced to a safe
|
|
106
|
+
* slug rather than validated-and-refused: the model should not have to guess our
|
|
107
|
+
* filename rules to record something true.
|
|
108
|
+
*/
|
|
109
|
+
export function slugify(name) {
|
|
110
|
+
const s = String(name ?? '')
|
|
111
|
+
.toLowerCase()
|
|
112
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
113
|
+
.replace(/^-+|-+$/g, '')
|
|
114
|
+
.slice(0, 60);
|
|
115
|
+
return s || 'note';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const dirFor = (root) => join(root, LEARNED_DIR);
|
|
119
|
+
|
|
120
|
+
function parseEntry(name, raw) {
|
|
121
|
+
// Frontmatter is deliberately tiny and hand-editable. A file that does not
|
|
122
|
+
// have it is skipped, never fatal — a human is expected to edit these.
|
|
123
|
+
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
124
|
+
if (!m) return null;
|
|
125
|
+
const head = {};
|
|
126
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
127
|
+
const kv = line.match(/^([a-zA-Z]+):\s*(.*)$/);
|
|
128
|
+
if (kv) head[kv[1]] = kv[2].trim();
|
|
129
|
+
}
|
|
130
|
+
const fact = m[2].trim();
|
|
131
|
+
if (!fact) return null;
|
|
132
|
+
return {
|
|
133
|
+
name: head.name || name,
|
|
134
|
+
fact,
|
|
135
|
+
why: head.why || '',
|
|
136
|
+
learnedAt: head.learnedAt || '',
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function serialise(entry) {
|
|
141
|
+
return [
|
|
142
|
+
'---',
|
|
143
|
+
`name: ${entry.name}`,
|
|
144
|
+
`why: ${entry.why}`,
|
|
145
|
+
`learnedAt: ${entry.learnedAt}`,
|
|
146
|
+
'---',
|
|
147
|
+
'',
|
|
148
|
+
entry.fact,
|
|
149
|
+
'',
|
|
150
|
+
].join('\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Read every learned fact. Never throws: a workspace with no memory is the
|
|
155
|
+
* normal case, and one corrupt file must not take the rest down with it.
|
|
156
|
+
*
|
|
157
|
+
* @returns {{ ok: true, entries: Array<{name:string,fact:string,why:string,learnedAt:string}>, skipped: number }}
|
|
158
|
+
*/
|
|
159
|
+
export function recall(root) {
|
|
160
|
+
const dir = dirFor(root);
|
|
161
|
+
if (!existsSync(dir)) return { ok: true, entries: [], skipped: 0 };
|
|
162
|
+
let names;
|
|
163
|
+
try { names = readdirSync(dir).filter((f) => f.endsWith('.md')); }
|
|
164
|
+
catch { return { ok: true, entries: [], skipped: 0 }; }
|
|
165
|
+
|
|
166
|
+
const entries = [];
|
|
167
|
+
let skipped = 0;
|
|
168
|
+
for (const f of names.sort()) {
|
|
169
|
+
let raw;
|
|
170
|
+
try { raw = readFileSync(join(dir, f), 'utf8'); } catch { skipped += 1; continue; }
|
|
171
|
+
const e = parseEntry(f.replace(/\.md$/, ''), raw);
|
|
172
|
+
if (e) entries.push(e); else skipped += 1;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* ⭐ SORTED BY NAME, NOT BY TIME. The prompt block must be byte-identical
|
|
176
|
+
* across every round of a session or prompt caching (3.05x) stops firing, and
|
|
177
|
+
* a time sort would reorder the moment anything new is learned mid-session.
|
|
178
|
+
*
|
|
179
|
+
* ⚠️⚠️ AND NOT BY `localeCompare`, WHICH IS WHAT THIS LINE USED TO SAY.
|
|
180
|
+
* This block renders into the system-message preamble — the very FIRST bytes of
|
|
181
|
+
* every prompt — and `localeCompare` with no locale argument resolves against
|
|
182
|
+
* the runtime's default locale and the Node build's ICU data. Two workers on
|
|
183
|
+
* the same repo could therefore render a different byte at position 0 and
|
|
184
|
+
* share no prompt cache whatsoever, which is exactly the fleet configuration
|
|
185
|
+
* this project is built for. `repo-map.mjs` wrote that rule down first
|
|
186
|
+
* ("⚠️ NOT `localeCompare` — ICU differs per machine"); this file did not
|
|
187
|
+
* inherit it. See `prefix-order.mjs`.
|
|
188
|
+
*/
|
|
189
|
+
entries.sort(byCodePointOn('name'));
|
|
190
|
+
return { ok: true, entries, skipped };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Record one fact. Replaces any existing fact of the same name — two
|
|
195
|
+
* contradictory beliefs about one thing is worse than either alone.
|
|
196
|
+
*
|
|
197
|
+
* @param {string} root
|
|
198
|
+
* @param {{ name: string, fact: string, why: string }} args
|
|
199
|
+
* @param {{ clock?: () => Date }} [opts]
|
|
200
|
+
*/
|
|
201
|
+
export function remember(root, args = {}, { clock = () => new Date() } = {}) {
|
|
202
|
+
const fact = typeof args.fact === 'string' ? args.fact.trim() : '';
|
|
203
|
+
if (!fact) return { ok: false, error: 'fact is required — a memory with no content is noise' };
|
|
204
|
+
if (fact.length > MAX_FACT_CHARS) {
|
|
205
|
+
return { ok: false, error: `fact is ${fact.length} characters, over the ${MAX_FACT_CHARS} limit — keep it to one thing a future session would otherwise get wrong` };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const why = typeof args.why === 'string' ? args.why.trim().slice(0, MAX_WHY_CHARS) : '';
|
|
209
|
+
if (!why) return { ok: false, error: 'why is required — a fact with no provenance cannot be judged later, and a wrong memory is worse than none' };
|
|
210
|
+
|
|
211
|
+
for (const [field, value] of [['fact', fact], ['why', why], ['name', args.name]]) {
|
|
212
|
+
const hit = looksLikeSecret(value);
|
|
213
|
+
if (hit) {
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
error: `refusing to remember: ${field} looks like it contains a credential (${hit}…). These files are committed to the repo, so nothing secret can go in one. Record the SHAPE of the fact instead, e.g. "deploy needs OPENROUTER_API_KEY set".`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const name = slugify(args.name);
|
|
222
|
+
const dir = dirFor(root);
|
|
223
|
+
try { mkdirSync(dir, { recursive: true }); }
|
|
224
|
+
catch (err) { return { ok: false, error: `could not create ${LEARNED_DIR}: ${err?.message ?? err}` }; }
|
|
225
|
+
|
|
226
|
+
const entry = { name, fact, why, learnedAt: clock().toISOString() };
|
|
227
|
+
try { writeFileSync(join(dir, `${name}.md`), serialise(entry), 'utf8'); }
|
|
228
|
+
catch (err) { return { ok: false, error: `could not write memory: ${err?.message ?? err}` }; }
|
|
229
|
+
|
|
230
|
+
const pruned = prune(root);
|
|
231
|
+
return { ok: true, name, path: join(LEARNED_DIR, `${name}.md`), evicted: pruned.evicted };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Remove a fact that turned out to be wrong. Safe when it does not exist. */
|
|
235
|
+
export function forget(root, name) {
|
|
236
|
+
const slug = slugify(name);
|
|
237
|
+
const file = join(dirFor(root), `${slug}.md`);
|
|
238
|
+
if (!existsSync(file)) return { ok: false, error: `no memory named "${slug}"` };
|
|
239
|
+
try { rmSync(file, { force: true }); } catch (err) { return { ok: false, error: String(err?.message ?? err) }; }
|
|
240
|
+
return { ok: true, name: slug };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* ⚠️ EVICT THE OLDEST, NEVER THE NEWEST. Sorted by `learnedAt` so the eviction
|
|
245
|
+
* order is about age rather than alphabet — the one place a time sort is right,
|
|
246
|
+
* because it never reaches the prompt.
|
|
247
|
+
*/
|
|
248
|
+
export function prune(root, { max = MAX_LEARNED_ENTRIES } = {}) {
|
|
249
|
+
const { entries } = recall(root);
|
|
250
|
+
if (entries.length <= max) return { ok: true, evicted: [] };
|
|
251
|
+
const byAge = [...entries].sort((a, b) => String(a.learnedAt).localeCompare(String(b.learnedAt)));
|
|
252
|
+
const doomed = byAge.slice(0, entries.length - max);
|
|
253
|
+
const evicted = [];
|
|
254
|
+
for (const e of doomed) {
|
|
255
|
+
try { rmSync(join(dirFor(root), `${e.name}.md`), { force: true }); evicted.push(e.name); } catch { /* leave it */ }
|
|
256
|
+
}
|
|
257
|
+
return { ok: true, evicted };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Render learned facts for the system prompt.
|
|
262
|
+
*
|
|
263
|
+
* ⭐ RETURNS EMPTY STRING WHEN THERE IS NOTHING. A heading with nothing under it
|
|
264
|
+
* teaches the model the feature is broken — this package's own rule that a
|
|
265
|
+
* control which presents itself and does nothing is worse than one that is absent.
|
|
266
|
+
*/
|
|
267
|
+
export function learnedPromptBlock(recalled) {
|
|
268
|
+
const entries = recalled?.entries ?? [];
|
|
269
|
+
if (entries.length === 0) return '';
|
|
270
|
+
|
|
271
|
+
const lines = [
|
|
272
|
+
'WHAT YOU HAVE LEARNED ABOUT THIS PROJECT BEFORE (from earlier sessions):',
|
|
273
|
+
];
|
|
274
|
+
let used = lines[0].length;
|
|
275
|
+
let shown = 0;
|
|
276
|
+
for (const e of entries) {
|
|
277
|
+
const line = ` · ${e.fact}`;
|
|
278
|
+
/**
|
|
279
|
+
* ── ⚠️⚠️ `continue`, NOT `break` — ONE BIG ENTRY USED TO DELETE ALL OF THEM ─
|
|
280
|
+
*
|
|
281
|
+
* `break` stopped at the first entry that did not fit, so a single oversized
|
|
282
|
+
* fact silently discarded EVERY fact after it. With an entry near the front
|
|
283
|
+
* of the sort order, that is the whole learned memory gone — the block still
|
|
284
|
+
* renders, still looks healthy, and is simply missing what the agent knew.
|
|
285
|
+
*
|
|
286
|
+
* ⚠️ AND IT IS ATTACKER-REACHABLE, WHICH IS WHY IT IS NOT MERELY A BUG. The
|
|
287
|
+
* entries are files in the repository and `recall` sorts by name, so whoever
|
|
288
|
+
* writes the repo picks BOTH the sort key and the size. One oversized entry
|
|
289
|
+
* named to sort first blanks the project's accumulated memory on every run,
|
|
290
|
+
* with nothing on screen to say so. Found by an adversarial pass over the
|
|
291
|
+
* untrusted-content fence.
|
|
292
|
+
*
|
|
293
|
+
* ⭐ Skipping keeps the budget honest AND keeps the small facts. The count
|
|
294
|
+
* below already says how many were left out, so nothing becomes silent.
|
|
295
|
+
*/
|
|
296
|
+
if (used + line.length > MAX_LEARNED_BYTES) continue;
|
|
297
|
+
lines.push(line);
|
|
298
|
+
used += line.length;
|
|
299
|
+
shown += 1;
|
|
300
|
+
}
|
|
301
|
+
if (shown < entries.length) {
|
|
302
|
+
lines.push(` (${entries.length - shown} more not shown — the memory is larger than the prompt budget)`);
|
|
303
|
+
}
|
|
304
|
+
lines.push('⚠️ These are notes from past runs, not gospel. If one contradicts what you can see in the');
|
|
305
|
+
lines.push(' files right now, believe the files and say so.');
|
|
306
|
+
return lines.join('\n');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* ⭐ THE MODEL DECIDES WHAT IS WORTH KEEPING, so the description is where the
|
|
311
|
+
* judgement lives. It names the test explicitly — would a future session get
|
|
312
|
+
* this wrong without it — because "remember useful things" produces a memory
|
|
313
|
+
* full of "I read a file".
|
|
314
|
+
*/
|
|
315
|
+
export function learnedToolSchemas() {
|
|
316
|
+
return [
|
|
317
|
+
{
|
|
318
|
+
type: 'function',
|
|
319
|
+
function: {
|
|
320
|
+
name: 'remember',
|
|
321
|
+
description:
|
|
322
|
+
'Record ONE durable fact about this project so future sessions do not have to rediscover it. '
|
|
323
|
+
+ 'Only worth it if a future session would otherwise get it WRONG: the real test command, the module '
|
|
324
|
+
+ 'system, where something lives, a house convention, or a mistake that cost you rounds. '
|
|
325
|
+
+ 'Do NOT record what you did this session, what a file contains, or anything that will be stale tomorrow. '
|
|
326
|
+
+ 'Never include a key, token or password — these files are committed to the repository.',
|
|
327
|
+
parameters: {
|
|
328
|
+
type: 'object',
|
|
329
|
+
additionalProperties: false,
|
|
330
|
+
required: ['name', 'fact', 'why'],
|
|
331
|
+
properties: {
|
|
332
|
+
name: { type: 'string', description: 'short kebab-case identifier, e.g. "test-command". Re-using a name REPLACES that fact.' },
|
|
333
|
+
fact: { type: 'string', description: `the fact itself, one or two sentences, max ${MAX_FACT_CHARS} characters` },
|
|
334
|
+
why: { type: 'string', description: 'how you learned it — the evidence, so a human can judge later whether to keep it' },
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
type: 'function',
|
|
341
|
+
function: {
|
|
342
|
+
name: 'forget',
|
|
343
|
+
description: 'Remove a previously recorded fact that has turned out to be wrong or is no longer true. A wrong memory is worse than no memory.',
|
|
344
|
+
parameters: {
|
|
345
|
+
type: 'object',
|
|
346
|
+
additionalProperties: false,
|
|
347
|
+
required: ['name'],
|
|
348
|
+
properties: { name: { type: 'string', description: 'the identifier the fact was recorded under' } },
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
];
|
|
353
|
+
}
|