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/session.mjs
ADDED
|
@@ -0,0 +1,1017 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ SESSION PERSISTENCE — THE HORIZON PROBLEM, ATTACKED FROM THE SIDE ───
|
|
3
|
+
*
|
|
4
|
+
* Every run of this CLI starts from nothing. `runSession` builds a system
|
|
5
|
+
* prompt, pre-reads the workspace, spends rounds gathering, and then the
|
|
6
|
+
* process exits and all of it evaporates. The round cap is not a soft limit —
|
|
7
|
+
* `stoppedBecause: 'round-cap'` is the MOST COMMON way a real task ends, and
|
|
8
|
+
* when it does, the user's only recovery is to re-type the prompt and pay for
|
|
9
|
+
* the entire gather a second time.
|
|
10
|
+
*
|
|
11
|
+
* ⭐ THE PLAN LEDGER ALREADY PROVED HALF OF THIS. `plan-ledger.mjs` found that
|
|
12
|
+
* re-issuing an identical command spent 3 of its 4 rounds re-deriving what the
|
|
13
|
+
* previous process had held in memory seconds earlier, and it fixed the WHAT:
|
|
14
|
+
* the outstanding deliverables survive. This file fixes the HOW-MUCH-IS-KNOWN:
|
|
15
|
+
* the conversation itself survives, so a resumed run does not re-read the files
|
|
16
|
+
* it already read, re-run the searches it already ran, or re-discover the shape
|
|
17
|
+
* of a project it already walked. A plan says "3 steps left". A session says
|
|
18
|
+
* "and here is everything I learned getting to them."
|
|
19
|
+
*
|
|
20
|
+
* ── ⚠️ THE THREE RULES THAT MAKE THIS SAFE, AND WHY EACH ONE EXISTS ─────────
|
|
21
|
+
*
|
|
22
|
+
* 1. **NO SECRET IS EVER WRITTEN HERE.** A message history contains file
|
|
23
|
+
* CONTENTS — `gatherWorkspaceContext` pre-reads the tree, and `read_file`
|
|
24
|
+
* results are verbatim. `turn.mjs` documents the worst bug this package has
|
|
25
|
+
* had: that pre-load put `OPENROUTER_API_KEY=sk-or-v1-…`, a database
|
|
26
|
+
* password and a private key into the prompt, and the provider chain fanned
|
|
27
|
+
* them to four companies. Writing that same transcript to a FILE is the same
|
|
28
|
+
* leak with a longer half-life — a prompt is gone when the process is; a
|
|
29
|
+
* session sits in `.acuvo/` until someone tars the directory up.
|
|
30
|
+
*
|
|
31
|
+
* So two guards, matching the two the rest of the package already uses:
|
|
32
|
+
* · `refusedCommitPath` (git.mjs) — the credential-FILE list, reused rather
|
|
33
|
+
* than re-typed, exactly as `gatherWorkspaceContext` reuses it. A tool call
|
|
34
|
+
* that touched `.env` or `id_rsa` has its content WITHHELD, not redacted:
|
|
35
|
+
* the whole file is the secret, so there is nothing in it worth keeping.
|
|
36
|
+
* · a redactor for conventionally-named credentials, whose name pattern is
|
|
37
|
+
* the same one `scrubEnvironment` (command.mjs) applies to a child's
|
|
38
|
+
* environment. See SECRET_NAME below for why it is a copy and what pins it.
|
|
39
|
+
*
|
|
40
|
+
* ⚠️ AND IT REDACTS RATHER THAN REFUSING, WHICH IS THE OPPOSITE OF
|
|
41
|
+
* `plan-ledger.mjs`. That file refuses, because its input is the model's own
|
|
42
|
+
* words chosen milliseconds ago and it can simply choose different ones.
|
|
43
|
+
* Here the input is a transcript that already happened; refusing to persist
|
|
44
|
+
* it throws away an entire run's work to avoid writing eight characters we
|
|
45
|
+
* can just as easily replace. `audit.mjs` settled this argument first: a log
|
|
46
|
+
* that refuses to be written is a log that does not exist.
|
|
47
|
+
*
|
|
48
|
+
* 2. **A RESUME RECONSTRUCTS CONTEXT AND NEVER REPLAYS A SIDE EFFECT.** This is
|
|
49
|
+
* the rule with teeth. A recorded round looks like
|
|
50
|
+
* `assistant(tool_calls) → tool(result)`, and an assistant message carrying
|
|
51
|
+
* `tool_calls` is not a memory of an action — in an OpenAI-shaped payload it
|
|
52
|
+
* is a PENDING action. Hand back a history ending in a dangling
|
|
53
|
+
* `tool_calls` with no matching result and two things happen: most providers
|
|
54
|
+
* reject the conversation outright, and any that accept it invite the model
|
|
55
|
+
* to reissue the call it already made. If the call was `run_command`, the
|
|
56
|
+
* resume just re-ran it. So a trailing unanswered call group is DROPPED, and
|
|
57
|
+
* `dropDanglingCalls` below is the whole of that guarantee.
|
|
58
|
+
*
|
|
59
|
+
* This module spawns nothing, imports no `child_process`, and returns only
|
|
60
|
+
* inert message objects. `test/session.test.mjs` asserts that statically,
|
|
61
|
+
* because "it doesn't currently" is not a guarantee anyone can rely on.
|
|
62
|
+
*
|
|
63
|
+
* 3. **IT CANNOT GROW WITHOUT BOUND.** `.acuvo/` is a hidden directory in
|
|
64
|
+
* somebody's project. A file that quietly accumulates megabytes there is a
|
|
65
|
+
* bug even when every byte is correct. One session is capped
|
|
66
|
+
* (MAX_SESSION_BYTES), the directory is capped (MAX_SESSIONS), and both caps
|
|
67
|
+
* are enforced by DROPPING THE OLDEST, never by refusing to write the newest
|
|
68
|
+
* — a persistence layer whose failure mode is "your most recent run was not
|
|
69
|
+
* saved" is worse than useless, because you only find out when you need it.
|
|
70
|
+
*
|
|
71
|
+
* ── ⚠️ WHAT THIS DELIBERATELY DOES NOT DO ──────────────────────────────────
|
|
72
|
+
* · It does not wire itself in. `saveSession` is called by the caller that
|
|
73
|
+
* owns the loop; see REGISTRATION_SNIPPET at the bottom.
|
|
74
|
+
* · It offers the model NO resume tool. A model rewriting its own message
|
|
75
|
+
* history mid-run is precisely the side-effect replay hazard rule 2 exists
|
|
76
|
+
* to prevent, and there is no task for which it is the right answer.
|
|
77
|
+
* `session_list` is read-only and that is the entire model-facing surface.
|
|
78
|
+
* · It never judges a session. "Was the work correct" is the auditor's
|
|
79
|
+
* question; this file records only what happened and why it stopped.
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
83
|
+
import { dirname } from 'node:path';
|
|
84
|
+
|
|
85
|
+
import { resolveInWorkspace } from './workspace.mjs';
|
|
86
|
+
import { refusedCommitPath } from './secret-paths.mjs';
|
|
87
|
+
import { ensureAcuvoDirIgnored } from './acuvo-dir.mjs';
|
|
88
|
+
|
|
89
|
+
/** Scratch, alongside `plan.json`, `mcp.json` and the screenshots `see_page`
|
|
90
|
+
* writes. A session is scratch of exactly that kind: useful to the next run,
|
|
91
|
+
* never part of the user's source tree. */
|
|
92
|
+
export const SESSION_DIR = '.acuvo/sessions';
|
|
93
|
+
export const SESSION_VERSION = 1;
|
|
94
|
+
|
|
95
|
+
/** One session file. Roughly a large transcript; past this the value of the
|
|
96
|
+
* extra history is lower than the cost of reading it back into a prompt. */
|
|
97
|
+
export const MAX_SESSION_BYTES = 240_000;
|
|
98
|
+
/** How many session files may exist. Twenty is several days of real work and
|
|
99
|
+
* about five megabytes worst case. */
|
|
100
|
+
export const MAX_SESSIONS = 20;
|
|
101
|
+
/** Per-message ceiling. A single `read_file` of a 200KB source file would
|
|
102
|
+
* otherwise be most of the budget on its own. */
|
|
103
|
+
export const MAX_MESSAGE_CHARS = 8_000;
|
|
104
|
+
export const MAX_TASK_CHARS = 400;
|
|
105
|
+
/** The metadata lists are for a HUMAN reading `--sessions`, so they are short
|
|
106
|
+
* by intent — the full detail is in the messages. */
|
|
107
|
+
export const MAX_FILES_RECORDED = 200;
|
|
108
|
+
export const MAX_COMMANDS_RECORDED = 100;
|
|
109
|
+
export const MAX_COMMAND_OUTPUT_CHARS = 1_500;
|
|
110
|
+
|
|
111
|
+
/** `memory-workspace.mjs` names the disk-less executor this. */
|
|
112
|
+
const MEMORY_ROOT = '(memory)';
|
|
113
|
+
const MEMORY_REFUSAL =
|
|
114
|
+
'this workspace has no disk, so a session cannot be saved — there is nothing to resume from and nothing to do about it here';
|
|
115
|
+
|
|
116
|
+
const err = (e) => (e instanceof Error ? e.message : String(e));
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* ── ⚠️ A DELIBERATE COPY OF `command.mjs`'s SECRET_NAME, AND WHAT PINS IT ───
|
|
120
|
+
*
|
|
121
|
+
* The brief for this module says to scrub "the same conventionally-named
|
|
122
|
+
* credentials the command executor scrubs". The honest way to do that would be
|
|
123
|
+
* to import the pattern — but `command.mjs` does not export it, and this module
|
|
124
|
+
* is not permitted to edit that file. A copy is therefore the only option, and
|
|
125
|
+
* an uncommented copy is how two definitions of "what is a secret" drift until
|
|
126
|
+
* one of them is wrong.
|
|
127
|
+
*
|
|
128
|
+
* ⭐ SO THE TEST PINS THEM WITHOUT EITHER FILE KNOWING ABOUT THE OTHER. It
|
|
129
|
+
* drives the REAL `scrubEnvironment` with a probe environment of ~30 variable
|
|
130
|
+
* names and asserts that every name it strips is a name this pattern matches,
|
|
131
|
+
* and vice versa. The two definitions cannot diverge silently; they can only
|
|
132
|
+
* diverge in a red test that names the offending variable.
|
|
133
|
+
*
|
|
134
|
+
* ⚠️ It is a denylist, and `command.mjs` already explains why that is the
|
|
135
|
+
* conscious exception here: an allowlist of variable names would refuse half of
|
|
136
|
+
* every real project's configuration. `MY_DB_STRING` survives it. This is one
|
|
137
|
+
* layer, not the boundary — rule 1's file-level withholding is the other.
|
|
138
|
+
*/
|
|
139
|
+
const SECRET_NAME = /(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|PRIVATE|SESSION|COOKIE|AUTH|_DSN$|CONNECTION_STRING)/i;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Literal secrets, recognised by SHAPE rather than by the name next to them.
|
|
143
|
+
* Same list `plan-ledger.mjs` uses to REFUSE, used here to REPLACE — see the
|
|
144
|
+
* header for why the two files answer the same threat differently.
|
|
145
|
+
*/
|
|
146
|
+
const SECRET_SHAPES = [
|
|
147
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, '[redacted private key]'],
|
|
148
|
+
[/\b(?:sk|rk)-or-v1-[A-Za-z0-9_-]{16,}/g, '[redacted openrouter key]'],
|
|
149
|
+
[/\bsk-[A-Za-z0-9]{16,}/g, '[redacted api key]'],
|
|
150
|
+
[/\bAKIA[0-9A-Z]{16}\b/g, '[redacted aws key id]'],
|
|
151
|
+
[/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, '[redacted github token]'],
|
|
152
|
+
[/\bxox[baprs]-[A-Za-z0-9-]{10,}/g, '[redacted slack token]'],
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* `NAME=value`, `NAME: value`, `"name": "value"` — the assignment form, which
|
|
157
|
+
* is how a credential appears inside a file the agent read rather than inside a
|
|
158
|
+
* key it happened to print.
|
|
159
|
+
*
|
|
160
|
+
* ⚠️ THE VALUE TEST IS WHAT KEEPS THIS FROM EATING THE TRANSCRIPT, AND THE
|
|
161
|
+
* FIRST VERSION OF IT WAS NOT ENOUGH. Matching on the NAME alone turns
|
|
162
|
+
* `let tokenCount = tokens.length;` into `let tokenCount = [redacted]` — caught
|
|
163
|
+
* by the test on the first run, and it is the failure that matters more than
|
|
164
|
+
* the leak in one specific way: a resumed session full of redacted CODE is
|
|
165
|
+
* worse than no resume at all, because the model reasons confidently about text
|
|
166
|
+
* that is not what the file says. A missing secret is a hole you can see.
|
|
167
|
+
*
|
|
168
|
+
* ⭐ SO THE DISCRIMINATOR IS THE FORM OF THE LINE, NOT THE NAME ON IT. Three
|
|
169
|
+
* shapes are credentials-in-a-file and everything else is code:
|
|
170
|
+
* · QUOTED — `"apiKey": "abcd1234efgh"`. A quoted literal next to a secret
|
|
171
|
+
* name is a hardcoded secret whether it is JSON, YAML or source.
|
|
172
|
+
* · ENV-STYLE — `DB_PASSWORD=hunter2hunter`, `export GITHUB_TOKEN=aaaa…`. No
|
|
173
|
+
* declaration keyword, `=`, no spaces around it: that is a
|
|
174
|
+
* dotenv line or a shell export, never an expression.
|
|
175
|
+
* · OPAQUE — an unquoted value with letters AND digits and no dotted
|
|
176
|
+
* access, not preceded by const/let/var. `hunter2secret` in a
|
|
177
|
+
* YAML file qualifies; `tokens.length` and `someVariable` do
|
|
178
|
+
* not.
|
|
179
|
+
* A `const`/`let`/`var` line with an unquoted value is never touched, which is
|
|
180
|
+
* what makes `apiKey = process.env.OPENROUTER_API_KEY` survive intact — and it
|
|
181
|
+
* should survive, because the thing it names is not in the file.
|
|
182
|
+
*/
|
|
183
|
+
const ASSIGNMENT = /^([ \t]*(?:export[ \t]+)?)(const[ \t]+|let[ \t]+|var[ \t]+)?(["']?)([A-Za-z_][A-Za-z0-9_.-]*)\3([ \t]*)([:=])([ \t]*)(\S+)[ \t]*$/gm;
|
|
184
|
+
const CODE_SHAPED = /[()${}[\]]|process\.env|import\.meta|require\(/;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* @param {unknown} text
|
|
188
|
+
* @returns {{ text: string, redactions: number }}
|
|
189
|
+
*/
|
|
190
|
+
export function redactSecrets(text) {
|
|
191
|
+
if (typeof text !== 'string' || text === '') return { text: typeof text === 'string' ? text : '', redactions: 0 };
|
|
192
|
+
let out = text;
|
|
193
|
+
let hits = 0;
|
|
194
|
+
for (const [rx, replacement] of SECRET_SHAPES) {
|
|
195
|
+
out = out.replace(rx, () => { hits += 1; return replacement; });
|
|
196
|
+
}
|
|
197
|
+
out = out.replace(ASSIGNMENT, (whole, lead, decl, quote, name, before, sep, after, value) => {
|
|
198
|
+
if (!SECRET_NAME.test(name)) return whole;
|
|
199
|
+
if (CODE_SHAPED.test(value)) return whole;
|
|
200
|
+
const bare = value.replace(/^["'`]|["'`,;]+$/g, '');
|
|
201
|
+
if (bare.length < 8) return whole;
|
|
202
|
+
|
|
203
|
+
const quoted = /^["'`]/.test(value);
|
|
204
|
+
const envStyle = !decl && sep === '=' && before === '' && after === '';
|
|
205
|
+
const opaque = !decl && !bare.includes('.') && /[0-9]/.test(bare) && /[A-Za-z]/.test(bare);
|
|
206
|
+
if (!quoted && !envStyle && !opaque) return whole;
|
|
207
|
+
|
|
208
|
+
hits += 1;
|
|
209
|
+
return `${lead}${decl ?? ''}${quote}${name}${quote}${before}${sep}${after}[redacted]`;
|
|
210
|
+
});
|
|
211
|
+
return { text: out, redactions: hits };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Would persisting THIS path's contents be persisting a credential file?
|
|
216
|
+
*
|
|
217
|
+
* ⭐ Reuses git.mjs deliberately. That list is already the package's single
|
|
218
|
+
* answer to "files that must never leave this machine", it is already tested,
|
|
219
|
+
* and a second copy is the one that goes stale. `turn.mjs` reuses it for the
|
|
220
|
+
* same reason on the prompt path.
|
|
221
|
+
*
|
|
222
|
+
* @param {unknown} path
|
|
223
|
+
* @returns {boolean}
|
|
224
|
+
*/
|
|
225
|
+
export function isCredentialPath(path) {
|
|
226
|
+
return typeof path === 'string' && path !== '' && refusedCommitPath(path) !== null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* ── ⚠️ NO COLONS. This is a FILENAME. ──────────────────────────────────────
|
|
231
|
+
* The obvious id is an ISO timestamp, and `2026-08-10T23:05:11.402Z` cannot be
|
|
232
|
+
* stored on Windows — `workspace.mjs` refuses `:` outright as a character the
|
|
233
|
+
* filesystem will not hold, so the save would fail on the owner's own laptop
|
|
234
|
+
* and nowhere else. `plan-ledger.mjs` hit the identical wall naming its
|
|
235
|
+
* quarantine files and solved it the same way.
|
|
236
|
+
*
|
|
237
|
+
* ⭐ UTC AND FIXED-WIDTH, so lexical order IS chronological order. `listSessions`
|
|
238
|
+
* and `pruneSessions` then need no stat call and no date parsing to know which
|
|
239
|
+
* session is the oldest — a sort of the directory listing is the answer.
|
|
240
|
+
*/
|
|
241
|
+
export function newSessionId(now = new Date()) {
|
|
242
|
+
const iso = new Date(now).toISOString();
|
|
243
|
+
const stamp = `${iso.slice(0, 10).replace(/-/g, '')}-${iso.slice(11, 19).replace(/:/g, '')}`;
|
|
244
|
+
const salt = Math.random().toString(36).slice(2, 6);
|
|
245
|
+
return `${stamp}-${salt}`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const ID_SHAPE = /^[0-9]{8}-[0-9]{6}-[a-z0-9]{2,8}$/;
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Resolve a file inside the session directory and PROVE it is inside it.
|
|
252
|
+
*
|
|
253
|
+
* ⚠️ EXPORTED FOR THE SAME REASON `resolvePlanFile` IS. Every caller in this
|
|
254
|
+
* module passes an id that this module generated, so the prefix assertion can
|
|
255
|
+
* never fire in production — which is exactly why it would rot unnoticed if it
|
|
256
|
+
* were private. The test drives it with `../../outside`, an absolute path and a
|
|
257
|
+
* traversal spelling.
|
|
258
|
+
*
|
|
259
|
+
* @returns {{ ok: true, absolute: string, relative: string, root: string } | { ok: false, error: string }}
|
|
260
|
+
*/
|
|
261
|
+
export function resolveSessionFile(root, name) {
|
|
262
|
+
if (typeof root !== 'string' || root.trim() === '') {
|
|
263
|
+
return { ok: false, error: 'no workspace directory was given, so a session cannot be stored' };
|
|
264
|
+
}
|
|
265
|
+
if (root === MEMORY_ROOT) return { ok: false, error: MEMORY_REFUSAL };
|
|
266
|
+
if (typeof name !== 'string' || name.trim() === '') {
|
|
267
|
+
return { ok: false, error: 'a session needs an id — call newSessionId(), or pass one from list_sessions' };
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* ⚠️ A SESSION ID IS ONE FILENAME, NOT A PATH — and the first version of this
|
|
271
|
+
* function did not say so. `resolveInWorkspace` is handed `${SESSION_DIR}/${name}`,
|
|
272
|
+
* so an id of `/etc/passwd` normalised to `.acuvo/sessions/etc/passwd`: still
|
|
273
|
+
* safely inside the workspace, but a nested directory nobody asked for, and a
|
|
274
|
+
* containment check that returns `ok` for `/etc/passwd` reads like a hole even
|
|
275
|
+
* when it is not one. Caught by the test that drives this with absolute paths.
|
|
276
|
+
*
|
|
277
|
+
* The single-segment rule is also what keeps the id and the FILENAME the same
|
|
278
|
+
* string, which is what `listSessions` and `pruneSessions` both rely on.
|
|
279
|
+
*/
|
|
280
|
+
if (/[\\/]/.test(name)) {
|
|
281
|
+
return { ok: false, error: `a session id is a single name, not a path — "${name}" contains a directory separator` };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const r = resolveInWorkspace(root, `${SESSION_DIR}/${name}`, 'write');
|
|
285
|
+
if (!r.ok) return { ok: false, error: r.reason };
|
|
286
|
+
if (!r.relative.startsWith(`${SESSION_DIR}/`)) {
|
|
287
|
+
return { ok: false, error: `sessions are only ever written inside ${SESSION_DIR}/ — "${r.relative}" is outside it` };
|
|
288
|
+
}
|
|
289
|
+
return { ok: true, absolute: r.absolute, relative: r.relative, root: r.root };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function truncate(text, max) {
|
|
293
|
+
const s = String(text ?? '');
|
|
294
|
+
if (s.length <= max) return s;
|
|
295
|
+
return `${s.slice(0, max)}\n… [truncated ${s.length - max} characters — this is a saved session, not the live file; read the file again if you need the rest]`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* ── THE PAIRING RULE ───────────────────────────────────────────────────────
|
|
300
|
+
* An OpenAI-shaped conversation is rejected outright when a `tool` message
|
|
301
|
+
* references an id that no preceding `tool_calls` declared — `turn.mjs` already
|
|
302
|
+
* documents that as an HTTP 400 which reads like a bug in the prompt. Trimming
|
|
303
|
+
* a history one message at a time breaks that invariant on the first cut, so
|
|
304
|
+
* this module never handles messages individually: it groups them, and a group
|
|
305
|
+
* is atomic.
|
|
306
|
+
*
|
|
307
|
+
* · an assistant message WITH tool_calls, plus every `tool` message answering
|
|
308
|
+
* it, is ONE group
|
|
309
|
+
* · anything else is a group of one
|
|
310
|
+
*
|
|
311
|
+
* @param {any[]} messages
|
|
312
|
+
* @returns {{ head: any[], groups: any[][] }}
|
|
313
|
+
*/
|
|
314
|
+
function groupMessages(messages) {
|
|
315
|
+
const head = [];
|
|
316
|
+
const groups = [];
|
|
317
|
+
let i = 0;
|
|
318
|
+
// The system message and the opening user message are the cacheable prefix —
|
|
319
|
+
// the task, the workspace shape, the rules. They are never candidates for
|
|
320
|
+
// dropping, because a history without them is not a resume, it is a new run
|
|
321
|
+
// with confusing extra context.
|
|
322
|
+
while (i < messages.length && messages[i]?.role === 'system') head.push(messages[i++]);
|
|
323
|
+
if (i < messages.length && messages[i]?.role === 'user') head.push(messages[i++]);
|
|
324
|
+
|
|
325
|
+
while (i < messages.length) {
|
|
326
|
+
const m = messages[i];
|
|
327
|
+
if (m?.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
|
|
328
|
+
const group = [m];
|
|
329
|
+
i += 1;
|
|
330
|
+
while (i < messages.length && messages[i]?.role === 'tool') group.push(messages[i++]);
|
|
331
|
+
groups.push(group);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
groups.push([m]);
|
|
335
|
+
i += 1;
|
|
336
|
+
}
|
|
337
|
+
return { head, groups };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* ── ⚠️⭐ THE SIDE-EFFECT GUARD ─────────────────────────────────────────────
|
|
342
|
+
*
|
|
343
|
+
* A session can stop in the middle of a round: `stoppedBecause: 'model-error'`
|
|
344
|
+
* breaks the loop right after the assistant message was pushed, and a Ctrl-C
|
|
345
|
+
* lands wherever it lands. What survives is an assistant message carrying
|
|
346
|
+
* `tool_calls` that nothing ever answered.
|
|
347
|
+
*
|
|
348
|
+
* That is not a record of an action. In a replayed payload it is a PENDING
|
|
349
|
+
* action, and the two things that happen next are both bad: strict providers
|
|
350
|
+
* reject the whole conversation, and lenient ones let the model helpfully
|
|
351
|
+
* reissue the call. If the call was `run_command`, the resume just re-ran it —
|
|
352
|
+
* silently, as its first act, before the user has said anything.
|
|
353
|
+
*
|
|
354
|
+
* So an unanswered call group is dropped, and the caller is TOLD how many, so
|
|
355
|
+
* the resume can say "the last round was incomplete and was discarded" rather
|
|
356
|
+
* than pretending the history is whole.
|
|
357
|
+
*
|
|
358
|
+
* ⚠️ It drops the GROUP, never just the assistant message. Keeping the partial
|
|
359
|
+
* tool replies would leave orphaned `tool` messages — the exact 400 the pairing
|
|
360
|
+
* rule exists to prevent, arrived at from the other direction.
|
|
361
|
+
*/
|
|
362
|
+
function dropDanglingCalls(groups) {
|
|
363
|
+
const kept = [];
|
|
364
|
+
let dropped = 0;
|
|
365
|
+
for (const group of groups) {
|
|
366
|
+
const head = group[0];
|
|
367
|
+
if (head?.role === 'assistant' && Array.isArray(head.tool_calls) && head.tool_calls.length > 0) {
|
|
368
|
+
const answered = new Set(group.slice(1).map((m) => m?.tool_call_id));
|
|
369
|
+
const unanswered = head.tool_calls.filter((c) => !answered.has(c?.id));
|
|
370
|
+
if (unanswered.length > 0) { dropped += 1; continue; }
|
|
371
|
+
}
|
|
372
|
+
kept.push(group);
|
|
373
|
+
}
|
|
374
|
+
return { groups: kept, dropped };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Which tool_call ids touched a credential file?
|
|
379
|
+
*
|
|
380
|
+
* The leak this closes is specific and easy to miss: the model calls
|
|
381
|
+
* `read_file` on `.env`, and the SECRET is not in the assistant message (which
|
|
382
|
+
* contains only the path) — it is in the `tool` message that answers it. So the
|
|
383
|
+
* ids have to be collected from the call and applied to the reply.
|
|
384
|
+
*/
|
|
385
|
+
function credentialCallIds(group) {
|
|
386
|
+
const head = group[0];
|
|
387
|
+
const ids = new Set();
|
|
388
|
+
if (head?.role !== 'assistant' || !Array.isArray(head.tool_calls)) return ids;
|
|
389
|
+
for (const call of head.tool_calls) {
|
|
390
|
+
let args = {};
|
|
391
|
+
try { args = JSON.parse(call?.function?.arguments || '{}'); } catch { /* an unparseable call cannot name a path */ }
|
|
392
|
+
const candidates = [args.path, args.file, args.filename, args.pattern];
|
|
393
|
+
if (candidates.some((p) => isCredentialPath(p))) ids.add(call?.id);
|
|
394
|
+
}
|
|
395
|
+
return ids;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const WITHHELD =
|
|
399
|
+
'[withheld: this tool call touched a credential file, so its contents were not saved with the session. '
|
|
400
|
+
+ 'Read the file again in this run if you need it.]';
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Scrub and cap one message. Returns a NEW object — the caller's array belongs
|
|
404
|
+
* to a live session that may still be in use, and mutating it here would edit
|
|
405
|
+
* the conversation a running loop is about to send.
|
|
406
|
+
*/
|
|
407
|
+
function sanitizeMessage(message, { withhold = false, maxChars = MAX_MESSAGE_CHARS } = {}) {
|
|
408
|
+
const out = { role: message?.role };
|
|
409
|
+
let redactions = 0;
|
|
410
|
+
|
|
411
|
+
if (typeof message?.content === 'string') {
|
|
412
|
+
if (withhold) {
|
|
413
|
+
out.content = WITHHELD;
|
|
414
|
+
redactions += 1;
|
|
415
|
+
} else {
|
|
416
|
+
const r = redactSecrets(message.content);
|
|
417
|
+
redactions += r.redactions;
|
|
418
|
+
out.content = truncate(r.text, maxChars);
|
|
419
|
+
}
|
|
420
|
+
} else if (message?.content !== undefined) {
|
|
421
|
+
// Non-string content (an array of parts, from a multimodal round). Keep the
|
|
422
|
+
// shape by stringifying rather than dropping it — a missing `content` on an
|
|
423
|
+
// assistant message is another way to earn a 400.
|
|
424
|
+
const r = redactSecrets(JSON.stringify(message.content));
|
|
425
|
+
redactions += r.redactions;
|
|
426
|
+
out.content = truncate(r.text, maxChars);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (typeof message?.name === 'string') out.name = message.name;
|
|
430
|
+
if (typeof message?.tool_call_id === 'string') out.tool_call_id = message.tool_call_id;
|
|
431
|
+
|
|
432
|
+
if (Array.isArray(message?.tool_calls)) {
|
|
433
|
+
out.tool_calls = message.tool_calls.map((call) => {
|
|
434
|
+
const raw = String(call?.function?.arguments ?? '{}');
|
|
435
|
+
// ⚠️ The ARGUMENTS of a write_file to `.env` contain the file body. The
|
|
436
|
+
// reply is not the only place a credential lives.
|
|
437
|
+
let args = raw;
|
|
438
|
+
if (isCredentialCall(call)) {
|
|
439
|
+
args = JSON.stringify({ withheld: true });
|
|
440
|
+
redactions += 1;
|
|
441
|
+
} else {
|
|
442
|
+
const r = redactSecrets(raw);
|
|
443
|
+
redactions += r.redactions;
|
|
444
|
+
args = truncate(r.text, maxChars);
|
|
445
|
+
}
|
|
446
|
+
return {
|
|
447
|
+
id: call?.id,
|
|
448
|
+
type: call?.type ?? 'function',
|
|
449
|
+
function: { name: call?.function?.name, arguments: args },
|
|
450
|
+
};
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
return { message: out, redactions };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function isCredentialCall(call) {
|
|
457
|
+
let args = {};
|
|
458
|
+
try { args = JSON.parse(call?.function?.arguments || '{}'); } catch { return false; }
|
|
459
|
+
return [args.path, args.file, args.filename].some((p) => isCredentialPath(p));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* The full sanitising pass: drop dangling calls, withhold credential contents,
|
|
464
|
+
* redact what is left, cap each message, then cap the WHOLE record by dropping
|
|
465
|
+
* the oldest middle groups.
|
|
466
|
+
*
|
|
467
|
+
* ⚠️ IT DROPS FROM THE MIDDLE, AND THE DIRECTION MATTERS. The head is the task
|
|
468
|
+
* and the workspace shape; the tail is what the model was doing when the budget
|
|
469
|
+
* ran out. Both ends are load-bearing and the middle is the part a resumed run
|
|
470
|
+
* can most afford to re-derive. Dropping the tail to save the middle would
|
|
471
|
+
* produce a session that resumes into work already done.
|
|
472
|
+
*
|
|
473
|
+
* @param {any[]} messages
|
|
474
|
+
* @param {{ maxBytes?: number, maxChars?: number }} [opts]
|
|
475
|
+
*/
|
|
476
|
+
export function sanitizeMessages(messages, { maxBytes = MAX_SESSION_BYTES, maxChars = MAX_MESSAGE_CHARS } = {}) {
|
|
477
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
478
|
+
return { messages: [], redactions: 0, droppedGroups: 0, droppedIncomplete: 0, truncated: false };
|
|
479
|
+
}
|
|
480
|
+
const { head, groups } = groupMessages(messages);
|
|
481
|
+
const pruned = dropDanglingCalls(groups);
|
|
482
|
+
|
|
483
|
+
let redactions = 0;
|
|
484
|
+
const cleanHead = head.map((m) => {
|
|
485
|
+
const s = sanitizeMessage(m, { maxChars });
|
|
486
|
+
redactions += s.redactions;
|
|
487
|
+
return s.message;
|
|
488
|
+
});
|
|
489
|
+
const cleanGroups = pruned.groups.map((group) => {
|
|
490
|
+
const withheldIds = credentialCallIds(group);
|
|
491
|
+
return group.map((m) => {
|
|
492
|
+
const withhold = m?.role === 'tool' && withheldIds.has(m.tool_call_id);
|
|
493
|
+
const s = sanitizeMessage(m, { withhold, maxChars });
|
|
494
|
+
redactions += s.redactions;
|
|
495
|
+
return s.message;
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// Byte cap, enforced by dropping the oldest droppable group and re-measuring.
|
|
500
|
+
// Measuring is cheap next to the alternative (estimating, and being wrong on
|
|
501
|
+
// the one transcript that mattered).
|
|
502
|
+
let kept = cleanGroups;
|
|
503
|
+
let droppedGroups = 0;
|
|
504
|
+
const size = () => JSON.stringify([...cleanHead, ...kept.flat()]).length;
|
|
505
|
+
while (kept.length > 0 && size() > maxBytes) {
|
|
506
|
+
kept = kept.slice(1);
|
|
507
|
+
droppedGroups += 1;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const out = [...cleanHead, ...kept.flat()];
|
|
511
|
+
if (droppedGroups > 0) {
|
|
512
|
+
/**
|
|
513
|
+
* ⚠️ SAY THAT THE HOLE IS THERE. A history with a silent gap is a history
|
|
514
|
+
* the model will reason across as if it were continuous — it sees its own
|
|
515
|
+
* earlier message, then a much later one, and infers a step it never took.
|
|
516
|
+
* One user message costs nothing and turns a lie into a known unknown.
|
|
517
|
+
*
|
|
518
|
+
* Inserted after the head, which is exactly where the hole is.
|
|
519
|
+
*/
|
|
520
|
+
out.splice(cleanHead.length, 0, {
|
|
521
|
+
role: 'user',
|
|
522
|
+
content: `[${droppedGroups} earlier round${droppedGroups === 1 ? '' : 's'} of this session were dropped to fit the saved-session size limit. `
|
|
523
|
+
+ 'Their work may already be on disk — check before redoing anything.]',
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return {
|
|
528
|
+
messages: out,
|
|
529
|
+
redactions,
|
|
530
|
+
droppedGroups,
|
|
531
|
+
droppedIncomplete: pruned.dropped,
|
|
532
|
+
truncated: droppedGroups > 0,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Pull the FACTS out of a finished session: what was written, what was run, and
|
|
538
|
+
* what happened when it ran.
|
|
539
|
+
*
|
|
540
|
+
* ⚠️ Read from `outcome.executed`, not from the messages. The tool records are
|
|
541
|
+
* structured; the messages are the model-facing rendering of them, and parsing
|
|
542
|
+
* a rendering back into data is how a summary starts disagreeing with the run.
|
|
543
|
+
*/
|
|
544
|
+
function extractActivity(executed) {
|
|
545
|
+
const files = [];
|
|
546
|
+
const commands = [];
|
|
547
|
+
if (!Array.isArray(executed)) return { files, commands };
|
|
548
|
+
|
|
549
|
+
for (const record of executed) {
|
|
550
|
+
const name = record?.name;
|
|
551
|
+
const result = record?.result;
|
|
552
|
+
if (name === 'write_file' || name === 'edit_file' || name === 'delete_file' || name === 'make_document') {
|
|
553
|
+
const path = result?.path ?? record?.args?.path;
|
|
554
|
+
if (typeof path !== 'string') continue;
|
|
555
|
+
if (files.length >= MAX_FILES_RECORDED) continue;
|
|
556
|
+
files.push({
|
|
557
|
+
path,
|
|
558
|
+
action: name === 'delete_file' ? 'deleted' : (result?.created === true ? 'created' : 'changed'),
|
|
559
|
+
ok: result?.ok === true,
|
|
560
|
+
bytes: typeof result?.bytes === 'number' ? result.bytes : undefined,
|
|
561
|
+
});
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
if (name === 'run_command' || name === 'evaluate') {
|
|
565
|
+
if (commands.length >= MAX_COMMANDS_RECORDED) continue;
|
|
566
|
+
const command = result?.command ?? (name === 'evaluate' ? 'evaluate' : record?.args?.command);
|
|
567
|
+
/**
|
|
568
|
+
* ⚠️ THE OUTPUT IS KEPT, BUT ONLY AS EVIDENCE. A resumed run must be able
|
|
569
|
+
* to see that `npm test` failed and HOW — that is the single most useful
|
|
570
|
+
* thing in the record. It must never be able to mistake it for a fresh
|
|
571
|
+
* result, which is why `at` is dropped and the resume note says plainly
|
|
572
|
+
* that nothing here was re-run.
|
|
573
|
+
*/
|
|
574
|
+
const tail = redactSecrets(String(result?.stderr || result?.stdout || '').trim());
|
|
575
|
+
commands.push({
|
|
576
|
+
command: typeof command === 'string' ? command : String(command ?? 'unknown'),
|
|
577
|
+
ok: result?.ok === true,
|
|
578
|
+
passed: result?.passed ?? null,
|
|
579
|
+
exitCode: result?.exitCode ?? null,
|
|
580
|
+
timedOut: result?.timedOut === true,
|
|
581
|
+
output: tail.text ? truncate(tail.text, MAX_COMMAND_OUTPUT_CHARS) : undefined,
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return { files, commands };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* @typedef {{ ok: false, error: string }} SessionRefused
|
|
590
|
+
* @typedef {{ ok: true, id: string, path: string, bytes: number, redactions: number, droppedGroups: number, droppedIncomplete: number, pruned: string[], resumable: boolean }} SessionSaved
|
|
591
|
+
*/
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Persist a finished (or abandoned) run.
|
|
595
|
+
*
|
|
596
|
+
* ⚠️ `outcome` may be a FAILURE. `runSession` returns `{ok:false, stage, error}`
|
|
597
|
+
* when round 1 could not reach the model, and that run is still worth a record:
|
|
598
|
+
* it is the one a user is most likely to re-issue, and knowing it died in
|
|
599
|
+
* `gather` rather than mid-work is what stops them re-running it unchanged.
|
|
600
|
+
* A failed session is saved and listable; it is simply not RESUMABLE, and the
|
|
601
|
+
* record says so rather than leaving the caller to infer it.
|
|
602
|
+
*
|
|
603
|
+
* @param {string} root
|
|
604
|
+
* @param {any} outcome the SessionOutcome from turn.mjs
|
|
605
|
+
* @param {{ task?: string, id?: string, now?: Date, keep?: number }} [meta]
|
|
606
|
+
* @returns {SessionSaved | SessionRefused}
|
|
607
|
+
*/
|
|
608
|
+
export function saveSession(root, outcome, meta = {}) {
|
|
609
|
+
const id = typeof meta.id === 'string' && meta.id ? meta.id : newSessionId(meta.now ?? new Date());
|
|
610
|
+
if (!ID_SHAPE.test(id)) {
|
|
611
|
+
return { ok: false, error: `session id "${id}" is not the expected YYYYMMDD-HHMMSS-xxxx shape — use newSessionId()` };
|
|
612
|
+
}
|
|
613
|
+
const f = resolveSessionFile(root, `${id}.json`);
|
|
614
|
+
if (!f.ok) return f;
|
|
615
|
+
|
|
616
|
+
const failed = !outcome || outcome.ok !== true;
|
|
617
|
+
const clean = sanitizeMessages(outcome?.messages ?? []);
|
|
618
|
+
const { files, commands } = extractActivity(outcome?.executed);
|
|
619
|
+
const taskText = redactSecrets(String(meta.task ?? '')).text;
|
|
620
|
+
|
|
621
|
+
const record = {
|
|
622
|
+
version: SESSION_VERSION,
|
|
623
|
+
id,
|
|
624
|
+
savedAt: new Date(meta.now ?? Date.now()).toISOString(),
|
|
625
|
+
root: f.root,
|
|
626
|
+
task: truncate(taskText, MAX_TASK_CHARS),
|
|
627
|
+
model: outcome?.model ?? null,
|
|
628
|
+
roundsUsed: typeof outcome?.roundsUsed === 'number' ? outcome.roundsUsed : 0,
|
|
629
|
+
maxRounds: typeof outcome?.maxRounds === 'number' ? outcome.maxRounds : null,
|
|
630
|
+
// ⚠️ `stage` is the honest answer for a failure — "round-cap" would be a
|
|
631
|
+
// lie about a run that never reached round 2.
|
|
632
|
+
stoppedBecause: failed ? `failed:${outcome?.stage ?? 'unknown'}` : String(outcome?.stoppedBecause ?? 'unknown'),
|
|
633
|
+
error: failed ? redactSecrets(String(outcome?.error ?? '')).text || null : null,
|
|
634
|
+
verification: outcome?.verification
|
|
635
|
+
? { ran: outcome.verification.ran === true, passed: outcome.verification.passed ?? null, command: outcome.verification.command ?? null }
|
|
636
|
+
: null,
|
|
637
|
+
usage: outcome?.usage ?? null,
|
|
638
|
+
files,
|
|
639
|
+
commands,
|
|
640
|
+
// Dropping every message is a legitimate outcome (a failure before round 1)
|
|
641
|
+
// and it is the difference between a listable record and a resumable one.
|
|
642
|
+
resumable: clean.messages.length > 0,
|
|
643
|
+
truncated: clean.truncated,
|
|
644
|
+
droppedGroups: clean.droppedGroups,
|
|
645
|
+
droppedIncomplete: clean.droppedIncomplete,
|
|
646
|
+
redactions: clean.redactions,
|
|
647
|
+
messages: clean.messages,
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
const written = writeRecord(f, record);
|
|
651
|
+
if (!written.ok) return written;
|
|
652
|
+
/**
|
|
653
|
+
* ⚠️ THE SECOND PLACE `.acuvo/` COMES INTO EXISTENCE IN A NORMAL RUN. A run
|
|
654
|
+
* with `--no-audit` but sessions on would otherwise create the directory with
|
|
655
|
+
* nothing to keep it out of the user's `git status` — which is the defect our
|
|
656
|
+
* own bench caught on the `git` task, arriving through a different door.
|
|
657
|
+
*
|
|
658
|
+
* ⭐ AFTER the write, not before: if the session could not be saved there is
|
|
659
|
+
* no directory of ours to hide, and creating one to ignore it would be the
|
|
660
|
+
* litter it exists to prevent.
|
|
661
|
+
*/
|
|
662
|
+
ensureAcuvoDirIgnored(root);
|
|
663
|
+
const pruned = pruneSessions(root, { keep: meta.keep ?? MAX_SESSIONS });
|
|
664
|
+
|
|
665
|
+
return {
|
|
666
|
+
ok: true,
|
|
667
|
+
id,
|
|
668
|
+
path: f.relative,
|
|
669
|
+
bytes: written.bytes,
|
|
670
|
+
redactions: clean.redactions,
|
|
671
|
+
droppedGroups: clean.droppedGroups,
|
|
672
|
+
droppedIncomplete: clean.droppedIncomplete,
|
|
673
|
+
pruned: pruned.ok ? pruned.removed : [],
|
|
674
|
+
resumable: record.resumable,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* ⚠️ UNIQUE TEMP NAME, THEN RENAME — the same rule and the same reason as
|
|
680
|
+
* `savePlan`. A fixed `.tmp` shared by two processes is worse than no temp file
|
|
681
|
+
* at all: both write to it and the rename publishes an interleaving of two
|
|
682
|
+
* documents, which then looks like a bug in the parser.
|
|
683
|
+
*/
|
|
684
|
+
let tmpCounter = 0;
|
|
685
|
+
|
|
686
|
+
function writeRecord(f, record) {
|
|
687
|
+
const body = `${JSON.stringify(record, null, 2)}\n`;
|
|
688
|
+
const bytes = Buffer.byteLength(body, 'utf8');
|
|
689
|
+
const tmp = `${f.absolute}.${process.pid}-${tmpCounter++}.tmp`;
|
|
690
|
+
try {
|
|
691
|
+
mkdirSync(dirname(f.absolute), { recursive: true });
|
|
692
|
+
writeFileSync(tmp, body, 'utf8');
|
|
693
|
+
renameSync(tmp, f.absolute);
|
|
694
|
+
} catch (e) {
|
|
695
|
+
try { unlinkSync(tmp); } catch { /* nothing to clean up */ }
|
|
696
|
+
return { ok: false, error: `could not write ${f.relative}: ${err(e)}` };
|
|
697
|
+
}
|
|
698
|
+
return { ok: true, bytes };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* ⚠️ VALIDATE THE FILE, DO NOT TRUST IT — `plan-ledger.mjs`'s rule, and it
|
|
703
|
+
* applies harder here because this file is bigger and a half-written one from a
|
|
704
|
+
* killed process is a realistic thing to find. Returns null for anything it does
|
|
705
|
+
* not fully recognise, and null means "treat as absent", never "throw".
|
|
706
|
+
*/
|
|
707
|
+
function parseSession(raw) {
|
|
708
|
+
let data;
|
|
709
|
+
try { data = JSON.parse(raw); } catch { return null; }
|
|
710
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
|
|
711
|
+
if (data.version !== SESSION_VERSION) return null;
|
|
712
|
+
if (typeof data.id !== 'string' || !ID_SHAPE.test(data.id)) return null;
|
|
713
|
+
if (!Array.isArray(data.messages)) return null;
|
|
714
|
+
if (!Array.isArray(data.files) || !Array.isArray(data.commands)) return null;
|
|
715
|
+
return data;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* @param {string} root
|
|
720
|
+
* @param {string} id
|
|
721
|
+
* @returns {{ ok: true, session: any } | SessionRefused}
|
|
722
|
+
*/
|
|
723
|
+
export function loadSession(root, id) {
|
|
724
|
+
const f = resolveSessionFile(root, `${String(id).replace(/\.json$/, '')}.json`);
|
|
725
|
+
if (!f.ok) return f;
|
|
726
|
+
if (!existsSync(f.absolute)) {
|
|
727
|
+
/**
|
|
728
|
+
* ⚠️ AN ERROR STRING IS AN INSTRUCTION. "not found" alone sends the reader
|
|
729
|
+
* back to guess another id; naming the command that lists them ends the
|
|
730
|
+
* guessing in one step.
|
|
731
|
+
*/
|
|
732
|
+
return { ok: false, error: `no saved session "${id}" in this workspace — run with --sessions to see the ids that exist` };
|
|
733
|
+
}
|
|
734
|
+
let raw;
|
|
735
|
+
try { raw = readFileSync(f.absolute, 'utf8'); } catch (e) {
|
|
736
|
+
return { ok: false, error: `could not read ${f.relative}: ${err(e)}` };
|
|
737
|
+
}
|
|
738
|
+
const session = parseSession(raw);
|
|
739
|
+
if (!session) {
|
|
740
|
+
return { ok: false, error: `${f.relative} is not a session this version can read — it is corrupt or was written by a newer build. Start a fresh run; the file is harmless where it is.` };
|
|
741
|
+
}
|
|
742
|
+
return { ok: true, session };
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Rebuild the conversation so a follow-up round starts with everything the
|
|
747
|
+
* previous run learned.
|
|
748
|
+
*
|
|
749
|
+
* ⭐ THE RETURNED ARRAY IS EXACTLY WHAT `runSession({ priorMessages })` WANTS.
|
|
750
|
+
* That path already exists for interactive chat — it appends one user message
|
|
751
|
+
* and rebuilds nothing — so resume needs no new plumbing in the loop, which is
|
|
752
|
+
* the whole reason this shape was chosen over a summary string.
|
|
753
|
+
*
|
|
754
|
+
* ⚠️ IT RE-RUNS NOTHING. Every command in the record is history and is labelled
|
|
755
|
+
* as history in the note below. The dangling-call guard has already removed the
|
|
756
|
+
* only structure that could have caused a replay; the note is what stops the
|
|
757
|
+
* model doing it on purpose because it assumed the environment was unchanged.
|
|
758
|
+
*
|
|
759
|
+
* @param {string} root
|
|
760
|
+
* @param {string} id
|
|
761
|
+
* @returns {{ ok: true, id: string, task: string, messages: any[], note: string, rootChanged: boolean, replayed: false } | SessionRefused}
|
|
762
|
+
*/
|
|
763
|
+
export function resumeMessages(root, id) {
|
|
764
|
+
const loaded = loadSession(root, id);
|
|
765
|
+
if (!loaded.ok) return loaded;
|
|
766
|
+
const s = loaded.session;
|
|
767
|
+
|
|
768
|
+
if (!s.resumable || s.messages.length === 0) {
|
|
769
|
+
return {
|
|
770
|
+
ok: false,
|
|
771
|
+
error: `session ${s.id} has no saved conversation (it stopped at "${s.stoppedBecause}" before anything was recorded), so there is nothing to resume. Start a fresh run with the same task.`,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* ⚠️ A SESSION IS ONLY VALID IN THE WORKSPACE IT WAS RECORDED IN. Every path
|
|
777
|
+
* in the history is relative to that root, and every file content is a
|
|
778
|
+
* snapshot of that tree. Resuming elsewhere is not refused — a directory can
|
|
779
|
+
* legitimately be moved or renamed — but it is ANNOUNCED, because a model
|
|
780
|
+
* that believes it already read `src/index.js` when it read a different
|
|
781
|
+
* project's `src/index.js` produces confident nonsense.
|
|
782
|
+
*/
|
|
783
|
+
const rootChanged = typeof s.root === 'string' && s.root !== '' && normalizeRoot(s.root) !== normalizeRoot(root);
|
|
784
|
+
|
|
785
|
+
const bits = [
|
|
786
|
+
`Resuming a previous session (${s.id}, saved ${s.savedAt}).`,
|
|
787
|
+
`It used ${s.roundsUsed}${s.maxRounds ? ` of ${s.maxRounds}` : ''} round${s.roundsUsed === 1 ? '' : 's'} and stopped because: ${s.stoppedBecause}.`,
|
|
788
|
+
'Everything above this line ALREADY HAPPENED — the files were written and the commands were run in that earlier process.',
|
|
789
|
+
'Nothing has been re-run for you now. Treat the tool results above as a record, not as fresh output:',
|
|
790
|
+
'if a command mattered and the code has changed since, run it again yourself.',
|
|
791
|
+
];
|
|
792
|
+
if (s.files.length > 0) {
|
|
793
|
+
bits.push(`Files it touched: ${s.files.slice(0, 12).map((f) => f.path).join(', ')}${s.files.length > 12 ? `, +${s.files.length - 12} more` : ''}.`);
|
|
794
|
+
}
|
|
795
|
+
if (s.droppedIncomplete > 0) {
|
|
796
|
+
bits.push('Its last round was incomplete and has been discarded, so the final tool call it started never finished.');
|
|
797
|
+
}
|
|
798
|
+
if (s.truncated) {
|
|
799
|
+
bits.push('Some middle rounds were dropped to fit the session size limit; work may exist on disk that is not in this history.');
|
|
800
|
+
}
|
|
801
|
+
if (rootChanged) {
|
|
802
|
+
bits.push(`⚠️ This session was recorded in a DIFFERENT workspace (${s.root}). Paths and file contents above may not describe the tree you are in — verify before trusting them.`);
|
|
803
|
+
}
|
|
804
|
+
const note = bits.join(' ');
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* The note rides as a `user` message rather than a `system` one on purpose:
|
|
808
|
+
* `runSession` treats a continuing turn's message list as opaque and appends
|
|
809
|
+
* the new task after it, so a second system message in the middle would sit
|
|
810
|
+
* behind the first one's rules and read as an override attempt. A user turn
|
|
811
|
+
* is what it actually is — the operator saying "here is where we left off".
|
|
812
|
+
*/
|
|
813
|
+
return {
|
|
814
|
+
ok: true,
|
|
815
|
+
id: s.id,
|
|
816
|
+
task: s.task,
|
|
817
|
+
messages: [...s.messages, { role: 'user', content: note }],
|
|
818
|
+
note,
|
|
819
|
+
rootChanged,
|
|
820
|
+
replayed: false,
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const normalizeRoot = (p) => (process.platform === 'win32' ? String(p).toLowerCase().replace(/\\/g, '/') : String(p));
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* The recent sessions, newest first, one line each.
|
|
828
|
+
*
|
|
829
|
+
* ⚠️ A CORRUPT FILE IS COUNTED, NOT THROWN. The listing is what a user reaches
|
|
830
|
+
* for when something has already gone wrong, so it is the last place that may
|
|
831
|
+
* fail on a bad file. Unreadable entries are reported as a number.
|
|
832
|
+
*
|
|
833
|
+
* @param {string} root
|
|
834
|
+
* @param {{ limit?: number }} [opts]
|
|
835
|
+
*/
|
|
836
|
+
export function listSessions(root, { limit = 10 } = {}) {
|
|
837
|
+
const dir = resolveSessionFile(root, 'probe.json');
|
|
838
|
+
if (!dir.ok) return { ok: false, error: dir.error };
|
|
839
|
+
const absDir = dirname(dir.absolute);
|
|
840
|
+
if (!existsSync(absDir)) return { ok: true, sessions: [], unreadable: 0 };
|
|
841
|
+
|
|
842
|
+
let names;
|
|
843
|
+
try { names = readdirSync(absDir); } catch (e) {
|
|
844
|
+
return { ok: false, error: `could not list ${SESSION_DIR}: ${err(e)}` };
|
|
845
|
+
}
|
|
846
|
+
// Lexical order IS chronological — see newSessionId. Newest first.
|
|
847
|
+
const ids = names.filter((n) => n.endsWith('.json') && ID_SHAPE.test(n.slice(0, -5))).sort().reverse();
|
|
848
|
+
|
|
849
|
+
const sessions = [];
|
|
850
|
+
let unreadable = 0;
|
|
851
|
+
for (const name of ids) {
|
|
852
|
+
if (sessions.length >= limit) break;
|
|
853
|
+
let raw;
|
|
854
|
+
try { raw = readFileSync(`${absDir}/${name}`, 'utf8'); } catch { unreadable += 1; continue; }
|
|
855
|
+
const s = parseSession(raw);
|
|
856
|
+
if (!s) { unreadable += 1; continue; }
|
|
857
|
+
sessions.push({
|
|
858
|
+
id: s.id,
|
|
859
|
+
savedAt: s.savedAt,
|
|
860
|
+
task: s.task,
|
|
861
|
+
roundsUsed: s.roundsUsed,
|
|
862
|
+
files: s.files.length,
|
|
863
|
+
commands: s.commands.length,
|
|
864
|
+
stoppedBecause: s.stoppedBecause,
|
|
865
|
+
resumable: s.resumable === true,
|
|
866
|
+
summary: summarizeSession(s),
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
return { ok: true, sessions, unreadable };
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* One line, and it has to earn its width: the id (which is what you type to
|
|
874
|
+
* resume), when, how far it got, what it produced, and why it stopped. The task
|
|
875
|
+
* is LAST and clipped, because it is the part you already recognise.
|
|
876
|
+
*/
|
|
877
|
+
export function summarizeSession(s) {
|
|
878
|
+
const when = String(s.savedAt ?? '').slice(0, 16).replace('T', ' ');
|
|
879
|
+
const parts = [
|
|
880
|
+
s.id,
|
|
881
|
+
when,
|
|
882
|
+
`${s.roundsUsed}r`,
|
|
883
|
+
`${s.files.length} file${s.files.length === 1 ? '' : 's'}`,
|
|
884
|
+
`${s.commands.length} cmd`,
|
|
885
|
+
s.stoppedBecause,
|
|
886
|
+
];
|
|
887
|
+
if (!s.resumable) parts.push('not resumable');
|
|
888
|
+
const task = String(s.task ?? '').replace(/\s+/g, ' ').trim();
|
|
889
|
+
return `${parts.join(' · ')} — ${task.length > 60 ? `${task.slice(0, 59)}…` : task}`;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Keep the newest `keep` sessions; delete the rest.
|
|
894
|
+
*
|
|
895
|
+
* ⚠️ IT DELETES THE OLDEST, NOT THE LARGEST, and it never refuses to save the
|
|
896
|
+
* newest. A cap enforced from the wrong end — "the directory is full, your run
|
|
897
|
+
* was not saved" — fails at exactly the moment the feature is needed, and the
|
|
898
|
+
* user finds out a day later when the resume they were counting on is missing.
|
|
899
|
+
*
|
|
900
|
+
* ⚠️ AND IT SWEEPS `.tmp` LEFTOVERS. A process killed between `writeFileSync`
|
|
901
|
+
* and `renameSync` leaves one behind; without this they are the one thing in
|
|
902
|
+
* here that genuinely grows for ever, because they match no id shape and so no
|
|
903
|
+
* other code path will ever look at them again.
|
|
904
|
+
*/
|
|
905
|
+
export function pruneSessions(root, { keep = MAX_SESSIONS } = {}) {
|
|
906
|
+
const dir = resolveSessionFile(root, 'probe.json');
|
|
907
|
+
if (!dir.ok) return { ok: false, error: dir.error };
|
|
908
|
+
const absDir = dirname(dir.absolute);
|
|
909
|
+
if (!existsSync(absDir)) return { ok: true, removed: [] };
|
|
910
|
+
|
|
911
|
+
let names;
|
|
912
|
+
try { names = readdirSync(absDir); } catch (e) {
|
|
913
|
+
return { ok: false, error: `could not list ${SESSION_DIR}: ${err(e)}` };
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const removed = [];
|
|
917
|
+
const ids = names.filter((n) => n.endsWith('.json') && ID_SHAPE.test(n.slice(0, -5))).sort();
|
|
918
|
+
const doomed = keep > 0 ? ids.slice(0, Math.max(0, ids.length - keep)) : ids;
|
|
919
|
+
|
|
920
|
+
for (const name of names) {
|
|
921
|
+
const stale = name.endsWith('.tmp') && olderThanAnHour(`${absDir}/${name}`);
|
|
922
|
+
if (!doomed.includes(name) && !stale) continue;
|
|
923
|
+
try { unlinkSync(`${absDir}/${name}`); removed.push(name); } catch {
|
|
924
|
+
// Locked by another process, or already gone. Pruning is housekeeping —
|
|
925
|
+
// it must never be able to fail a save.
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
return { ok: true, removed };
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/** A `.tmp` younger than an hour may belong to a live process writing right
|
|
932
|
+
* now. Deleting it would be this module racing itself. */
|
|
933
|
+
function olderThanAnHour(path) {
|
|
934
|
+
try { return Date.now() - statSync(path).mtimeMs > 3_600_000; } catch { return false; }
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* ⚠️ ONE READ-ONLY TOOL, AND NO RESUME TOOL. See the header: a model that can
|
|
939
|
+
* rewrite its own message history mid-run is the side-effect replay hazard this
|
|
940
|
+
* module is built around. Resume is an OPERATOR action, taken between runs,
|
|
941
|
+
* from the command line. What the model may do is LOOK — knowing that the same
|
|
942
|
+
* task was attempted an hour ago and died at the round cap is context worth
|
|
943
|
+
* having, and it costs one small tool result.
|
|
944
|
+
*/
|
|
945
|
+
export function sessionToolSchemas() {
|
|
946
|
+
return [
|
|
947
|
+
{
|
|
948
|
+
type: 'function',
|
|
949
|
+
function: {
|
|
950
|
+
name: 'list_sessions',
|
|
951
|
+
description: [
|
|
952
|
+
'List recent saved runs of this CLI in this workspace: when each ran, how many rounds it used,',
|
|
953
|
+
'how many files it wrote, and why it stopped.',
|
|
954
|
+
'Use it when the task looks like a continuation of earlier work — a previous run may already have',
|
|
955
|
+
'written some of what you were asked for.',
|
|
956
|
+
'This is READ-ONLY: it cannot resume anything and it re-runs nothing. To actually continue a',
|
|
957
|
+
'session the person running this CLI passes --resume <id>.',
|
|
958
|
+
].join(' '),
|
|
959
|
+
parameters: {
|
|
960
|
+
type: 'object',
|
|
961
|
+
properties: {
|
|
962
|
+
limit: { type: 'integer', description: 'How many to list, newest first. Default 10.' },
|
|
963
|
+
},
|
|
964
|
+
required: [],
|
|
965
|
+
},
|
|
966
|
+
},
|
|
967
|
+
},
|
|
968
|
+
];
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* ── THE REGISTRATION SNIPPET — NOT WIRED IN, ON PURPOSE ─────────────────────
|
|
973
|
+
*
|
|
974
|
+
* Three edits, in three files, none of which this module makes:
|
|
975
|
+
*
|
|
976
|
+
* 1. lib/tools.mjs — offer the read-only tool
|
|
977
|
+
* import { listSessions, sessionToolSchemas } from './session.mjs';
|
|
978
|
+
* …in TOOL_SCHEMAS: ...sessionToolSchemas(),
|
|
979
|
+
* …in executeToolCall: case 'list_sessions':
|
|
980
|
+
* return { id, name, args,
|
|
981
|
+
* result: listSessions(executor.root, { limit: args.limit }),
|
|
982
|
+
* mutated: false };
|
|
983
|
+
*
|
|
984
|
+
* 2. lib/turn.mjs — save at the end of a run. AFTER `releaseMcp()`, before
|
|
985
|
+
* the return, so a save can never keep a child process alive:
|
|
986
|
+
* import { saveSession } from './session.mjs';
|
|
987
|
+
* const saved = saveSession(executor.root, outcome, { task });
|
|
988
|
+
* if (saved.ok) onEvent({ type: 'session', id: saved.id, redactions: saved.redactions });
|
|
989
|
+
* ⚠️ Never let a failed save fail the run — the work is already on disk.
|
|
990
|
+
*
|
|
991
|
+
* 3. bin/acuvo.mjs — the operator surface:
|
|
992
|
+
* --sessions → listSessions(root).sessions.map(s => s.summary)
|
|
993
|
+
* --resume <id> → const r = resumeMessages(root, id);
|
|
994
|
+
* if (!r.ok) { fail(r.error); }
|
|
995
|
+
* runSession({ ..., priorMessages: r.messages });
|
|
996
|
+
* ⚠️ `--resume` with no follow-up task is a valid request ("carry on"), so
|
|
997
|
+
* pass the ORIGINAL task (`r.task`) when the user gives no new one.
|
|
998
|
+
*/
|
|
999
|
+
export const REGISTRATION_SNIPPET = `// lib/tools.mjs
|
|
1000
|
+
import { listSessions, sessionToolSchemas } from './session.mjs';
|
|
1001
|
+
export const TOOL_SCHEMAS = [ /* … */ ...sessionToolSchemas() ];
|
|
1002
|
+
// in executeToolCall's switch:
|
|
1003
|
+
case 'list_sessions':
|
|
1004
|
+
return { id, name, args, result: listSessions(executor.root, { limit: args.limit }), mutated: false };
|
|
1005
|
+
|
|
1006
|
+
// lib/turn.mjs — after releaseMcp(), before the return
|
|
1007
|
+
import { saveSession } from './session.mjs';
|
|
1008
|
+
const saved = saveSession(executor.root, outcome, { task });
|
|
1009
|
+
if (saved.ok) onEvent({ type: 'session', id: saved.id, redactions: saved.redactions });
|
|
1010
|
+
|
|
1011
|
+
// bin/acuvo.mjs
|
|
1012
|
+
if (flags.sessions) for (const s of listSessions(root).sessions) console.log(s.summary);
|
|
1013
|
+
if (flags.resume) {
|
|
1014
|
+
const r = resumeMessages(root, flags.resume);
|
|
1015
|
+
if (!r.ok) return fail(r.error);
|
|
1016
|
+
await runSession({ ...opts, task: task || r.task, priorMessages: r.messages });
|
|
1017
|
+
}`;
|