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.
Files changed (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. 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
+ }
@@ -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
+ }