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
package/lib/audit.mjs
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ THE RUN LOG — EVIDENCE, NOT TELEMETRY ───────────────────────────────
|
|
3
|
+
*
|
|
4
|
+
* A regulated buyer cannot adopt an agent that edits their source and then
|
|
5
|
+
* forgets it did. Their question is never "is it good", it is "show me what it
|
|
6
|
+
* did on the fourteenth" — and today the only answer is a terminal scrollback
|
|
7
|
+
* that closed. `--json` gave a script one object per invocation, which is the
|
|
8
|
+
* same fact with the same lifetime: it exists until the pipe ends.
|
|
9
|
+
*
|
|
10
|
+
* ⭐ SO THIS FILE IS `--json` WITH A MEMORY, AND ALMOST NO NEW SHAPE. `toJson()`
|
|
11
|
+
* in `report.mjs` already decided what a run IS — ok, rounds, verification,
|
|
12
|
+
* changes, cost, refusals — and that shape is a stable contract we have already
|
|
13
|
+
* paid for. Re-deriving it here would give us two answers to one question and
|
|
14
|
+
* guarantee they drift. This module wraps it, corrects the one field it gets
|
|
15
|
+
* wrong (see MODEL below), and makes it durable.
|
|
16
|
+
*
|
|
17
|
+
* ── ⚠️ WHAT AN AUDIT LOG IS NOT ─────────────────────────────────────────────
|
|
18
|
+
* It is not a debug log and it must never become one. Every field here is a
|
|
19
|
+
* field someone may one day have to hand to a regulator, which means every
|
|
20
|
+
* field is a field that can leak. The pressure to add "and the model's reply,
|
|
21
|
+
* for debugging" will be constant; the answer is a debug log somewhere else.
|
|
22
|
+
*
|
|
23
|
+
* ── ⚠️⚠️ AND THE FAILURE MODE THAT MATTERS MOST IS SILENCE ──────────────────
|
|
24
|
+
* An audit log that quietly failed to write is worse than no audit log at all,
|
|
25
|
+
* because the buyer believes they have evidence and does not go looking for it
|
|
26
|
+
* until the day they need it. So nothing here throws — every function returns a
|
|
27
|
+
* result — and the caller is expected to WARN when a write fails rather than
|
|
28
|
+
* swallow it. A run must not die because logging did; a log must not fail
|
|
29
|
+
* quietly because the run lived.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { appendFileSync, mkdirSync, readdirSync, statSync, existsSync, unlinkSync } from 'node:fs';
|
|
33
|
+
import { join } from 'node:path';
|
|
34
|
+
import { createHash } from 'node:crypto';
|
|
35
|
+
|
|
36
|
+
import { toJson } from './report.mjs';
|
|
37
|
+
import { ensureAcuvoDirIgnored } from './acuvo-dir.mjs';
|
|
38
|
+
import { gpuSpend } from './budget.mjs';
|
|
39
|
+
|
|
40
|
+
/** Bump when a field changes meaning. Nobody reading this file in two years
|
|
41
|
+
* will know which writer produced a line, and guessing is how a compliance
|
|
42
|
+
* report ends up averaging two different definitions of `cost`. */
|
|
43
|
+
export const AUDIT_SCHEMA_VERSION = 1;
|
|
44
|
+
|
|
45
|
+
export const AUDIT_DIR = '.acuvo/audit';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* ⚠️ THE TASK IS TRUNCATED, THE HASH IS OF THE WHOLE THING. Someone pasting a
|
|
49
|
+
* 40KB spec into `--task` must not silently get a 40KB line per run — but they
|
|
50
|
+
* also must not be told the record covers the whole task when it holds a page
|
|
51
|
+
* of it. The hash closes that gap: an operator holding the original can prove
|
|
52
|
+
* it is the one this run acted on, without the log ever storing it.
|
|
53
|
+
*/
|
|
54
|
+
export const MAX_TASK_CHARS = 2_000;
|
|
55
|
+
/** One agent round can touch hundreds of files; the record stays a record. */
|
|
56
|
+
export const MAX_CHANGES = 200;
|
|
57
|
+
/** See `redact` — a bound on the input, not on the output. */
|
|
58
|
+
export const MAX_REDACT_CHARS = 20_000;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* ⚠️ THESE BOUND A LAPTOP'S DISK. THEY ARE NOT A RETENTION POLICY, and must
|
|
62
|
+
* never be described as one to a buyer: a real retention obligation is measured
|
|
63
|
+
* in years and is satisfied by shipping these files OFF the box, not by a CLI
|
|
64
|
+
* deciding when a record stops mattering. Ninety daily files is what keeps a
|
|
65
|
+
* machine running this every day from accumulating forever; an operator with an
|
|
66
|
+
* obligation raises it and syncs the directory somewhere durable.
|
|
67
|
+
*/
|
|
68
|
+
export const MAX_AUDIT_FILES = 90;
|
|
69
|
+
export const MAX_AUDIT_TOTAL_BYTES = 32 * 1024 * 1024;
|
|
70
|
+
|
|
71
|
+
/* ────────────────────────────────────────────────────────────────────────────
|
|
72
|
+
* SECRETS
|
|
73
|
+
* ──────────────────────────────────────────────────────────────────────────── */
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* ── ⭐⭐ THE REAL CONTROL IS OMISSION, NOT REDACTION ─────────────────────────
|
|
77
|
+
*
|
|
78
|
+
* The brief is "must never record secrets", and a regex is the weakest possible
|
|
79
|
+
* way to honour it. The strong control is the list of things this record does
|
|
80
|
+
* not have fields for, and it is deliberate every time:
|
|
81
|
+
*
|
|
82
|
+
* FILE CONTENTS — never. `describeChange` already reports paths, byte
|
|
83
|
+
* counts, line counts and the SHARE of a file replaced,
|
|
84
|
+
* with no text. That is enough to answer "what did it
|
|
85
|
+
* change" and it cannot leak a `.env` the agent read.
|
|
86
|
+
* COMMAND OUTPUT — never. Test output routinely prints connection strings
|
|
87
|
+
* and signed URLs on failure. We keep the exit code.
|
|
88
|
+
* THE MODEL'S PROSE — never. `toJson` already omits `outcome.note`, and it
|
|
89
|
+
* is the field most likely to quote a secret back.
|
|
90
|
+
* THE ENVIRONMENT — never. `scrubEnvironment` in `command.mjs` keeps it out
|
|
91
|
+
* of child processes; it has no business coming back in
|
|
92
|
+
* through the log.
|
|
93
|
+
*
|
|
94
|
+
* ⭐ What is LEFT is exactly what cannot be omitted without destroying the
|
|
95
|
+
* record's purpose: the task the human typed, the command that verified it, and
|
|
96
|
+
* the error strings from refusals. Those three are user-authored or echo
|
|
97
|
+
* user-authored text, so those three get scrubbed — and that is the whole job
|
|
98
|
+
* of the regex below.
|
|
99
|
+
*
|
|
100
|
+
* ── ⚠️ IT IS A DENYLIST, AND THAT IS A CONSCIOUS EXCEPTION ──────────────────
|
|
101
|
+
* Same exception `scrubEnvironment` makes, for the same reason, and it deserves
|
|
102
|
+
* the same honest statement of the guarantee: this catches conventionally
|
|
103
|
+
* shaped credentials. A bare 32-character password with no name in front of it
|
|
104
|
+
* survives, because the pattern that caught it would also redact every git SHA
|
|
105
|
+
* and half the file paths, and a log nobody can read is a log nobody keeps.
|
|
106
|
+
* Treat it as one layer. The layer that actually holds is the omission list.
|
|
107
|
+
*
|
|
108
|
+
* ⭐ AND IT ERRS TOWARDS OVER-REDACTION. `keychain: <six chars>` gets masked.
|
|
109
|
+
* That is the correct direction to be wrong in for a file whose entire promise
|
|
110
|
+
* is that it contains no secrets.
|
|
111
|
+
*/
|
|
112
|
+
const REDACTIONS = [
|
|
113
|
+
// First, because it is the only multi-line one and the others would shred it.
|
|
114
|
+
[/-----BEGIN[ A-Z]*PRIVATE KEY-----[\s\S]*?-----END[ A-Z]*PRIVATE KEY-----/g, '[redacted:private-key]'],
|
|
115
|
+
[/\b(?:sk|rk)-(?:or-v1-)?[A-Za-z0-9_-]{16,}/g, '[redacted:api-key]'],
|
|
116
|
+
[/\bgithub_pat_[A-Za-z0-9_]{20,}/g, '[redacted:github-token]'],
|
|
117
|
+
[/\bgh[pousr]_[A-Za-z0-9]{16,}/g, '[redacted:github-token]'],
|
|
118
|
+
[/\bAKIA[0-9A-Z]{16}\b/g, '[redacted:aws-key-id]'],
|
|
119
|
+
[/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, '[redacted:slack-token]'],
|
|
120
|
+
[/\bAIza[0-9A-Za-z_-]{35}/g, '[redacted:google-api-key]'],
|
|
121
|
+
[/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, '[redacted:jwt]'],
|
|
122
|
+
[/\b[Bb]earer\s+[A-Za-z0-9._~+/=-]{12,}/g, 'Bearer [redacted]'],
|
|
123
|
+
// `postgres://user:pass@host` — the credential nobody thinks of as a secret
|
|
124
|
+
// because it looks like a URL, and the one most likely to be in a task.
|
|
125
|
+
[/([A-Za-z][A-Za-z0-9+.-]*:\/\/)[^\s/:@]+:[^\s/@]+@/g, '$1[redacted]@'],
|
|
126
|
+
/**
|
|
127
|
+
* The name-then-value rule, last so the specific shapes above win.
|
|
128
|
+
*
|
|
129
|
+
* ⚠️ THE NAME CLASS EXCLUDES `.` ON PURPOSE. With it, `lib/auth.mjs: <text>`
|
|
130
|
+
* matched and the log started masking file references — an audit log that
|
|
131
|
+
* hides which file was touched has redacted the evidence instead of the
|
|
132
|
+
* secret.
|
|
133
|
+
*/
|
|
134
|
+
[/\b([A-Za-z0-9_-]*(?:key|token|secret|password|passwd|credential|auth|dsn)[A-Za-z0-9_-]*)(["'\s]*[:=]\s*["']?)([^\s"',;]{6,})/gi,
|
|
135
|
+
(_m, name, sep) => `${name}${sep}[redacted]`],
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Mask conventionally shaped credentials in a string. Pure.
|
|
140
|
+
*
|
|
141
|
+
* ⚠️ THE INPUT IS CAPPED BEFORE THE REGEXES RUN. The private-key pattern is a
|
|
142
|
+
* lazy scan to a terminator that may not exist; handed a megabyte of BEGIN
|
|
143
|
+
* markers it degrades badly, and "the audit writer hung" is a denial of service
|
|
144
|
+
* with an innocent explanation. Nothing that reaches here is legitimately
|
|
145
|
+
* longer than this anyway — the task is truncated to 2,000 characters and
|
|
146
|
+
* refusal errors to 300.
|
|
147
|
+
*/
|
|
148
|
+
export function redact(text) {
|
|
149
|
+
if (typeof text !== 'string' || text === '') return text;
|
|
150
|
+
let out = text.length > MAX_REDACT_CHARS ? text.slice(0, MAX_REDACT_CHARS) : text;
|
|
151
|
+
for (const [pattern, replacement] of REDACTIONS) out = out.replace(pattern, replacement);
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Redact every string anywhere in a structure. Pure.
|
|
157
|
+
*
|
|
158
|
+
* ⭐ WHY THIS IS RECURSIVE AND NOT A LIST OF FIELDS. A named list — "scrub
|
|
159
|
+
* task, command and refusals" — is a list someone forgets to extend the day
|
|
160
|
+
* they add a field, and the failure is invisible: the log keeps writing, the
|
|
161
|
+
* tests keep passing, and the new field carries the secret. Walking the whole
|
|
162
|
+
* object means a field added by a future `toJson` is scrubbed before anyone
|
|
163
|
+
* remembers this file exists.
|
|
164
|
+
*
|
|
165
|
+
* Keys are left alone: they are literals in our own source, never user text.
|
|
166
|
+
*/
|
|
167
|
+
export function deepRedact(value) {
|
|
168
|
+
if (typeof value === 'string') return redact(value);
|
|
169
|
+
if (Array.isArray(value)) return value.map(deepRedact);
|
|
170
|
+
if (value && typeof value === 'object') {
|
|
171
|
+
/** @type {Record<string, any>} */
|
|
172
|
+
const out = {};
|
|
173
|
+
for (const [k, v] of Object.entries(value)) out[k] = deepRedact(v);
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/* ────────────────────────────────────────────────────────────────────────────
|
|
180
|
+
* THE RECORD
|
|
181
|
+
* ──────────────────────────────────────────────────────────────────────────── */
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* ── ⚠️⚠️ THE MODEL THAT ANSWERED IS NOT THE MODEL THAT WAS ASKED FOR ────────
|
|
185
|
+
*
|
|
186
|
+
* `chain.mjs` exists because one provider is one outage, and its result says
|
|
187
|
+
* which candidate replied (`model`, `usedFallback`, `chainTried` —
|
|
188
|
+
* `chain.mjs:119-125`). ⚠️ `turn.mjs` throws all three away: the three
|
|
189
|
+
* `rounds.push` calls (lines 824, 834, 894) record note/usage/finishReason and
|
|
190
|
+
* no model, and the session returns `model: config.model` (line 1076) — the
|
|
191
|
+
* REQUESTED one. Grep `lib/` and `bin/` for `reply.model` and there are no hits.
|
|
192
|
+
*
|
|
193
|
+
* So on a day OpenRouter rate-limits, the record would swear the work was done
|
|
194
|
+
* by the model the user configured when a free fallback actually did it. For a
|
|
195
|
+
* buyer whose whole reason for reading this file is "which system processed our
|
|
196
|
+
* code", that is not a cosmetic inaccuracy — it is the log being wrong about
|
|
197
|
+
* the only thing they came to check.
|
|
198
|
+
*
|
|
199
|
+
* ⭐ SO IT REPORTS `null` UNTIL THE LOOP TELLS US. This is `describeChange`'s
|
|
200
|
+
* rule about a confidently wrong zero, applied where it costs something: a
|
|
201
|
+
* script can test for `null`, and it cannot know that a model name is a guess.
|
|
202
|
+
* The one-line fix in `turn.mjs` is in the handoff notes; this function reads
|
|
203
|
+
* it the moment it lands and needs no change when it does.
|
|
204
|
+
*/
|
|
205
|
+
export function answeringModels(outcome) {
|
|
206
|
+
const seen = [];
|
|
207
|
+
for (const r of outcome?.rounds ?? []) {
|
|
208
|
+
if (typeof r?.model === 'string' && r.model && !seen.includes(r.model)) seen.push(r.model);
|
|
209
|
+
}
|
|
210
|
+
// The session-level field is the other place the fix could land.
|
|
211
|
+
if (seen.length === 0 && typeof outcome?.answeredModel === 'string' && outcome.answeredModel) {
|
|
212
|
+
seen.push(outcome.answeredModel);
|
|
213
|
+
}
|
|
214
|
+
return seen;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** How many individual GPU calls a record itemises before it stops being a record. */
|
|
218
|
+
export const MAX_GPU_CALLS_LOGGED = 40;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* ── ⭐⭐ WHAT THE RUN COST, INCLUDING THE PART NOBODY BILLED US FOR ──────────
|
|
222
|
+
*
|
|
223
|
+
* `toJson`'s `costUsd` is `outcome.usage.cost` — model tokens, aggregated over
|
|
224
|
+
* the rounds. Every Modal container the run started was absent from it, which
|
|
225
|
+
* is why `acuvo spend` and `--fleet-budget` (which reads the same field through
|
|
226
|
+
* `summariseSpend`) were both pricing a fraction of the bill.
|
|
227
|
+
*
|
|
228
|
+
* ⭐ THE RUN'S OWN BUDGET IS THE AUTHORITY WHEN THERE IS ONE. `outcome.budget`
|
|
229
|
+
* is what that run's governor actually claimed, and in `--parallel` / `--best-of`
|
|
230
|
+
* several sessions share one process and therefore one module-level ledger —
|
|
231
|
+
* reading the ledger there would charge every sibling for all of them. The
|
|
232
|
+
* process ledger is the fallback for a run with no budget object at all.
|
|
233
|
+
*
|
|
234
|
+
* @param {any} outcome
|
|
235
|
+
* @param {{usd: number, calls: any[]}} [ledger] injected for tests
|
|
236
|
+
*/
|
|
237
|
+
export function runGpuSpend(outcome, ledger = gpuSpend()) {
|
|
238
|
+
const calls = (ledger?.calls ?? []).slice(0, MAX_GPU_CALLS_LOGGED);
|
|
239
|
+
const budget = outcome?.budget;
|
|
240
|
+
if (budget && typeof budget === 'object') {
|
|
241
|
+
const usd = Number.isFinite(budget.gpuUsd) ? budget.gpuUsd : 0;
|
|
242
|
+
return { usd, calls: usd > 0 ? calls : [], source: 'budget' };
|
|
243
|
+
}
|
|
244
|
+
return { usd: Number.isFinite(ledger?.usd) ? ledger.usd : 0, calls, source: 'ledger' };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** SHA-256, hex. Of the ORIGINAL task — see MAX_TASK_CHARS. */
|
|
248
|
+
export function taskFingerprint(task) {
|
|
249
|
+
return createHash('sha256').update(String(task ?? ''), 'utf8').digest('hex');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Build the record. Pure — `now` and the id are injected, so a test asserts on
|
|
254
|
+
* an exact line instead of on a regex with a timestamp hole in it.
|
|
255
|
+
*
|
|
256
|
+
* @param {any} outcome the object `runSession` returned
|
|
257
|
+
* @param {{ changes?: any[], task?: string|null, now?: Date|string, id?: string|null }} [opts]
|
|
258
|
+
*/
|
|
259
|
+
export function auditRecord(outcome, { changes = [], task = null, now = new Date(), id = null, gpu = null } = {}) {
|
|
260
|
+
const at = typeof now === 'string' ? now : now.toISOString();
|
|
261
|
+
const sha = taskFingerprint(task);
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Redacted BEFORE truncation. The other order looks equivalent and is not:
|
|
265
|
+
* a key at character 2,050 would be cut off today and survive the first time
|
|
266
|
+
* someone raises the limit, which is the kind of regression that ships
|
|
267
|
+
* because the test still passes.
|
|
268
|
+
*/
|
|
269
|
+
const scrubbed = redact(String(task ?? ''));
|
|
270
|
+
const trimmed = scrubbed.length > MAX_TASK_CHARS
|
|
271
|
+
? `${scrubbed.slice(0, MAX_TASK_CHARS)}…[+${scrubbed.length - MAX_TASK_CHARS} chars]`
|
|
272
|
+
: scrubbed;
|
|
273
|
+
|
|
274
|
+
const kept = changes.slice(0, MAX_CHANGES);
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* ⭐ THE SHAPE IS `toJson`'s, DELIBERATELY AND ENTIRELY. Two documents
|
|
278
|
+
* describing one run must not disagree, and the surest way to make them agree
|
|
279
|
+
* is for one of them to be the other.
|
|
280
|
+
*/
|
|
281
|
+
const base = toJson(outcome, { changes: kept, task: trimmed });
|
|
282
|
+
|
|
283
|
+
const answered = answeringModels(outcome);
|
|
284
|
+
const run = {
|
|
285
|
+
...base,
|
|
286
|
+
/**
|
|
287
|
+
* Replaces `toJson`'s scalar `model`, which is the requested one. A record
|
|
288
|
+
* that offered both under one key would be read as one fact.
|
|
289
|
+
*/
|
|
290
|
+
model: {
|
|
291
|
+
requested: base.model ?? null,
|
|
292
|
+
// The last one is the one that produced the final state.
|
|
293
|
+
answered: answered.length > 0 ? answered[answered.length - 1] : null,
|
|
294
|
+
chain: answered,
|
|
295
|
+
},
|
|
296
|
+
...(changes.length > kept.length ? { changesOmitted: changes.length - kept.length } : {}),
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* ── ⭐⭐ ONE BOOK. `costUsd` IS WHAT THE RUN COST, NOT WHAT THE MODEL COST ──
|
|
301
|
+
*
|
|
302
|
+
* ⚠️ AND THE SCHEMA VERSION IS DELIBERATELY NOT BUMPED. `costUsd` has always
|
|
303
|
+
* meant "what this run cost"; it was simply incomplete, and correcting a
|
|
304
|
+
* value is not redefining a field. The new `cost` block beside it is purely
|
|
305
|
+
* additive, and it is ABSENT on a run that spent no GPU — so every existing
|
|
306
|
+
* line, and every line a no-GPU run writes tomorrow, is byte-for-byte what it
|
|
307
|
+
* was. A version bump would have changed all of them to announce a field they
|
|
308
|
+
* do not carry.
|
|
309
|
+
*
|
|
310
|
+
* ⚠️⚠️ AND THE ESTIMATE IS NAMED IN THE RECORD ITSELF. This file's own header
|
|
311
|
+
* insists that a null cost must never be summed as zero; the same argument
|
|
312
|
+
* forbids a price-table figure being read as a bill. A regulated buyer
|
|
313
|
+
* reading this line has to be able to tell which dollars came off an invoice
|
|
314
|
+
* and which came off our arithmetic, so `cost.estimatedUsd` is separate from
|
|
315
|
+
* `cost.modelUsd` and `cost.estimated` says so in one boolean.
|
|
316
|
+
*/
|
|
317
|
+
const spend = gpu ?? runGpuSpend(outcome);
|
|
318
|
+
if (spend.usd > 0) {
|
|
319
|
+
const modelUsd = typeof base.costUsd === 'number' && Number.isFinite(base.costUsd) ? base.costUsd : null;
|
|
320
|
+
run.costUsd = (modelUsd ?? 0) + spend.usd;
|
|
321
|
+
run.cost = {
|
|
322
|
+
/** ⚠️ null means the provider never said — NOT zero. Same rule as costUsd. */
|
|
323
|
+
modelUsd,
|
|
324
|
+
estimatedUsd: spend.usd,
|
|
325
|
+
estimated: true,
|
|
326
|
+
basis: 'GPU seconds measured at the call site, priced from a published rate table — we are not billed per call',
|
|
327
|
+
calls: spend.calls.map((c) => ({
|
|
328
|
+
verb: c.verb,
|
|
329
|
+
kind: c.kind,
|
|
330
|
+
usd: c.usd,
|
|
331
|
+
seconds: c.seconds,
|
|
332
|
+
billedSeconds: c.billedSeconds,
|
|
333
|
+
basis: c.basis,
|
|
334
|
+
})),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
v: AUDIT_SCHEMA_VERSION,
|
|
340
|
+
// Deterministic: the instant plus the task's fingerprint. Two runs collide
|
|
341
|
+
// only if the same task starts in the same millisecond, and a random suffix
|
|
342
|
+
// would have cost this function its purity for that.
|
|
343
|
+
id: id ?? `${at}-${sha.slice(0, 8)}`,
|
|
344
|
+
at,
|
|
345
|
+
taskSha256: sha,
|
|
346
|
+
/**
|
|
347
|
+
* ⚠️ The wrapper above is exempt from the scrub because none of it comes
|
|
348
|
+
* from the user or the model — it is a version, a clock and two hashes.
|
|
349
|
+
* Everything that does is inside `run`, and all of it goes through the walk.
|
|
350
|
+
*/
|
|
351
|
+
run: deepRedact(run),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* One record, one line.
|
|
357
|
+
*
|
|
358
|
+
* ⚠️ NEVER TRUNCATE THE SERIALISED LINE TO FIT A BUDGET. A clipped JSON line is
|
|
359
|
+
* not a smaller record, it is an unparseable one — the run would be logged and
|
|
360
|
+
* simultaneously not exist, which is the worst of both. Every bound in this
|
|
361
|
+
* file is therefore on an INPUT (task chars, change count, refusal length), so
|
|
362
|
+
* the output is bounded by construction and always whole.
|
|
363
|
+
*
|
|
364
|
+
* ⭐ And this is why JSONL is the right format rather than a nicety:
|
|
365
|
+
* `JSON.stringify` escapes control characters, so a task containing newlines
|
|
366
|
+
* cannot break the one-record-per-line invariant the reader depends on.
|
|
367
|
+
*/
|
|
368
|
+
export function serializeRecord(record) {
|
|
369
|
+
return `${JSON.stringify(record)}\n`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Read a log back, tolerantly.
|
|
374
|
+
*
|
|
375
|
+
* ⭐ DAMAGED LINES ARE COUNTED, NOT SWALLOWED. A reader that silently skips
|
|
376
|
+
* what it cannot parse turns "three records were destroyed" into "there were
|
|
377
|
+
* three fewer runs" — and for an audit log that difference is the entire
|
|
378
|
+
* question. The count is itself evidence, so it comes back with the records.
|
|
379
|
+
*/
|
|
380
|
+
export function parseAuditLog(text) {
|
|
381
|
+
const records = [];
|
|
382
|
+
let damaged = 0;
|
|
383
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
384
|
+
if (line.trim() === '') continue;
|
|
385
|
+
try {
|
|
386
|
+
records.push(JSON.parse(line));
|
|
387
|
+
} catch {
|
|
388
|
+
damaged += 1;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return { records, damaged };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/* ────────────────────────────────────────────────────────────────────────────
|
|
395
|
+
* DISK
|
|
396
|
+
* ──────────────────────────────────────────────────────────────────────────── */
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* ⭐ ISO DATES SO THE NAMES SORT CHRONOLOGICALLY, and UTC so they do not
|
|
400
|
+
* reorder when the clock moves. Every prune decision below is a string sort;
|
|
401
|
+
* `10-8-2026.jsonl` would make it a lie twice a year.
|
|
402
|
+
*/
|
|
403
|
+
export function dayFileName(at) {
|
|
404
|
+
const iso = typeof at === 'string' ? at : at.toISOString();
|
|
405
|
+
return `${iso.slice(0, 10)}.jsonl`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Decide which files to delete. Pure — the whole reason it is separate.
|
|
410
|
+
*
|
|
411
|
+
* @param {{name: string, bytes: number}[]} files
|
|
412
|
+
* @returns {string[]} names to delete, oldest first
|
|
413
|
+
*/
|
|
414
|
+
export function planPrune(files, { maxFiles = MAX_AUDIT_FILES, maxTotalBytes = MAX_AUDIT_TOTAL_BYTES } = {}) {
|
|
415
|
+
const sorted = [...files].sort((a, b) => a.name.localeCompare(b.name));
|
|
416
|
+
let total = sorted.reduce((n, f) => n + (f.bytes || 0), 0);
|
|
417
|
+
const doomed = [];
|
|
418
|
+
/**
|
|
419
|
+
* ⚠️ `> 1` NOT `> 0` — THE NEWEST FILE IS NEVER DELETED, even if it alone
|
|
420
|
+
* blows the byte budget. It is the file we are appending to right now, so
|
|
421
|
+
* pruning it would destroy the very run being recorded, and a single
|
|
422
|
+
* pathological day would take the log with it every time.
|
|
423
|
+
*/
|
|
424
|
+
while (sorted.length > 1 && (sorted.length > maxFiles || total > maxTotalBytes)) {
|
|
425
|
+
const gone = sorted.shift();
|
|
426
|
+
total -= gone.bytes || 0;
|
|
427
|
+
doomed.push(gone.name);
|
|
428
|
+
}
|
|
429
|
+
return doomed;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** What is on disk, in the shape `planPrune` wants. Never throws. */
|
|
433
|
+
function listAuditFiles(dir) {
|
|
434
|
+
if (!existsSync(dir)) return [];
|
|
435
|
+
let names;
|
|
436
|
+
try {
|
|
437
|
+
names = readdirSync(dir).filter((n) => n.endsWith('.jsonl'));
|
|
438
|
+
} catch {
|
|
439
|
+
return [];
|
|
440
|
+
}
|
|
441
|
+
const out = [];
|
|
442
|
+
for (const name of names) {
|
|
443
|
+
try {
|
|
444
|
+
out.push({ name, bytes: statSync(join(dir, name)).size });
|
|
445
|
+
} catch {
|
|
446
|
+
// Vanished between the listing and the stat — a parallel run pruned it.
|
|
447
|
+
// Not our problem and not worth failing a write over.
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return out;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Append one record, then keep the directory bounded.
|
|
455
|
+
*
|
|
456
|
+
* ── ⚠️ WHAT "CRASH-SAFE" HONESTLY MEANS HERE ────────────────────────────────
|
|
457
|
+
* Not that the last write is atomic — it is one `writeSync` loop and a kill
|
|
458
|
+
* between iterations leaves a partial line. The guarantee is the one that
|
|
459
|
+
* matters: **a killed process cannot damage a record that was already written.**
|
|
460
|
+
* Three things buy it, and all three are load-bearing:
|
|
461
|
+
*
|
|
462
|
+
* 1. `flag: 'a'` — O_APPEND. Every write goes to the end. Nothing ever seeks
|
|
463
|
+
* backwards, so there is no offset at which an earlier line lives that a
|
|
464
|
+
* later write could land on.
|
|
465
|
+
* 2. ONE `appendFileSync` CALL for line-plus-newline. Two calls double the
|
|
466
|
+
* window in which a crash separates a record from its terminator, for no
|
|
467
|
+
* benefit; the newline is part of the buffer, not a follow-up.
|
|
468
|
+
* 3. ⭐ PRUNING DELETES WHOLE FILES AND NEVER REWRITES ONE. This is the rule
|
|
469
|
+
* that keeps "append-only" and "bounded" from being in tension. The
|
|
470
|
+
* obvious way to cap a log — read it, drop the oldest lines, write it back
|
|
471
|
+
* — is a read-modify-write over live evidence, and a crash in the middle
|
|
472
|
+
* of it loses everything the file held. Rotating by day and unlinking old
|
|
473
|
+
* days means the bound never touches a byte that is still wanted.
|
|
474
|
+
*
|
|
475
|
+
* A torn tail therefore costs at most the record being written when the machine
|
|
476
|
+
* died, and `parseAuditLog` reports it as damaged rather than hiding it.
|
|
477
|
+
*
|
|
478
|
+
* ⚠️ RETURNS A RESULT, NEVER THROWS — see the file header. The caller warns.
|
|
479
|
+
*/
|
|
480
|
+
export function appendAudit(root, record, { dir = AUDIT_DIR, maxFiles, maxTotalBytes } = {}) {
|
|
481
|
+
const target = join(root, dir);
|
|
482
|
+
let file;
|
|
483
|
+
try {
|
|
484
|
+
mkdirSync(target, { recursive: true });
|
|
485
|
+
/**
|
|
486
|
+
* ⚠️ THE MOMENT `.acuvo/` COMES INTO EXISTENCE IS THE MOMENT IT MUST STOP
|
|
487
|
+
* DIRTYING THE TREE — and this is one of only two places it happens in a
|
|
488
|
+
* normal run. Found by our own bench: the `git` task did the work correctly,
|
|
489
|
+
* committed exactly the right file, and failed on `left the tree dirty:
|
|
490
|
+
* ?? .acuvo/`.
|
|
491
|
+
*
|
|
492
|
+
* ⭐ HERE RATHER THAN AT STARTUP, deliberately. The first attempt called it
|
|
493
|
+
* once in `bin/acuvo.mjs` and created the directory on every invocation —
|
|
494
|
+
* including `--dry-run`, which promises to touch nothing. This path already
|
|
495
|
+
* respects every opt-out, so hanging the ignore off it inherits all of them.
|
|
496
|
+
*/
|
|
497
|
+
ensureAcuvoDirIgnored(root);
|
|
498
|
+
file = join(target, dayFileName(record?.at ?? new Date()));
|
|
499
|
+
appendFileSync(file, serializeRecord(record), { encoding: 'utf8', flag: 'a' });
|
|
500
|
+
} catch (err) {
|
|
501
|
+
return { ok: false, error: `could not write the audit record: ${err?.message ?? err}` };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* ⚠️ PRUNE FAILURES DO NOT FAIL THE WRITE. The record is already durable at
|
|
506
|
+
* this point; reporting `ok: false` because a stale file could not be
|
|
507
|
+
* unlinked would make the caller warn about a log that is, in fact, fine.
|
|
508
|
+
*/
|
|
509
|
+
const pruned = [];
|
|
510
|
+
for (const name of planPrune(listAuditFiles(target), { maxFiles, maxTotalBytes })) {
|
|
511
|
+
try {
|
|
512
|
+
unlinkSync(join(target, name));
|
|
513
|
+
pruned.push(name);
|
|
514
|
+
} catch {
|
|
515
|
+
// Read-only directory, or another process got there first. Either way the
|
|
516
|
+
// bound is advisory and the evidence is intact, which is the priority.
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return { ok: true, file, pruned };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* The whole job, from an outcome to a line on disk. This is what the CLI calls.
|
|
525
|
+
*
|
|
526
|
+
* @param {{ root: string, outcome: any, changes?: any[], task?: string|null, now?: Date|string }} args
|
|
527
|
+
*/
|
|
528
|
+
export function recordRun({ root, outcome, changes = [], task = null, now = new Date() }) {
|
|
529
|
+
return appendAudit(root, auditRecord(outcome, { changes, task, now }));
|
|
530
|
+
}
|