atom-agent 0.3.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.
@@ -0,0 +1,243 @@
1
+ // File snapshots for /rewind (ticket 01).
2
+ //
3
+ // Every write/edit auto-snapshots the affected file's prior bytes BEFORE the
4
+ // mutation runs (the capture calls live inside writeTool/editTool, so every
5
+ // caller — loop, tests, future subagents — is covered regardless of path).
6
+ // Snapshots are silent: no prompt, no config, and a snapshot failure never
7
+ // fails the mutation it precedes (capturePriorBytes resolves null).
8
+ //
9
+ // Session-scoped and in-memory (small files stay as Buffers; files over
10
+ // SNAPSHOT_OVERFLOW_BYTES spill a copy under the OS temp dir — never the
11
+ // repo itself). Restores are byte-exact and hash-verified (sha256 of the
12
+ // bytes on disk must equal the pre-mutation hash, not a model rewrite).
13
+ // Shell side effects (bash) are explicitly out of scope: commands are never
14
+ // snapshotted and cannot be undone — the /rewind UI says so outright.
15
+ import { createHash, randomBytes } from "node:crypto";
16
+ import { promises as fsp } from "node:fs";
17
+ import * as os from "node:os";
18
+ import * as path from "node:path";
19
+ // Large-file spill threshold + session cap (oldest checkpoints drop off;
20
+ // their temp copies are removed best-effort so the session cannot leak).
21
+ export const SNAPSHOT_OVERFLOW_BYTES = 256 * 1024;
22
+ export const MAX_CHECKPOINTS = 50;
23
+ const SNAPSHOT_DIR = "atom-snapshots";
24
+ let checkpoints = [];
25
+ let seq = 0;
26
+ // Probe for the live conversation lengths (registered once by App on mount;
27
+ // tools.ts must stay free of App/zen imports, so the lengths flow in here).
28
+ // Null when unregistered (plain tool tests) — marks default to 0.
29
+ let historyProbe = null;
30
+ export function registerHistoryProbe(fn) {
31
+ historyProbe = fn;
32
+ }
33
+ function currentMarks() {
34
+ try {
35
+ const m = historyProbe?.();
36
+ if (m !== null &&
37
+ m !== undefined &&
38
+ typeof m.history === "number" &&
39
+ Number.isFinite(m.history) &&
40
+ typeof m.turns === "number" &&
41
+ Number.isFinite(m.turns)) {
42
+ return { history: Math.max(0, Math.floor(m.history)), turns: Math.max(0, Math.floor(m.turns)) };
43
+ }
44
+ }
45
+ catch {
46
+ // A broken probe must never break the mutation being snapshotted.
47
+ }
48
+ return { history: 0, turns: 0 };
49
+ }
50
+ /** Test isolation (plus any future session reset): drops every checkpoint. */
51
+ export function clearSnapshots() {
52
+ for (const cp of checkpoints) {
53
+ for (const f of cp.files) {
54
+ if (f.overflowPath) {
55
+ void fsp.rm(f.overflowPath, { force: true }).catch(() => undefined);
56
+ }
57
+ }
58
+ }
59
+ checkpoints = [];
60
+ seq = 0;
61
+ }
62
+ /** Newest-last copy for the picker and tests (the stored entries stay private). */
63
+ export function listCheckpoints() {
64
+ return [...checkpoints];
65
+ }
66
+ export function getCheckpoint(id) {
67
+ return checkpoints.find((c) => c.id === id);
68
+ }
69
+ function snapshotDir() {
70
+ return path.join(os.tmpdir(), SNAPSHOT_DIR);
71
+ }
72
+ function newCheckpointId(nextSeq) {
73
+ return `${Date.now().toString(36)}-${nextSeq.toString(36)}${randomBytes(3).toString("hex")}`;
74
+ }
75
+ async function readPrior(abs) {
76
+ let bytes = null;
77
+ try {
78
+ bytes = await fsp.readFile(abs);
79
+ }
80
+ catch {
81
+ return { abs, existed: false, hash: null, bytes: null, overflowPath: null };
82
+ }
83
+ const hash = createHash("sha256").update(bytes).digest("hex");
84
+ if (bytes.byteLength > SNAPSHOT_OVERFLOW_BYTES) {
85
+ try {
86
+ const dir = snapshotDir();
87
+ await fsp.mkdir(dir, { recursive: true });
88
+ const name = `snapshot-${process.pid}-${Date.now().toString(36)}-${randomBytes(4).toString("hex")}.bin`;
89
+ const file = path.join(dir, name);
90
+ await fsp.writeFile(file, bytes);
91
+ return { abs, existed: true, hash, bytes: null, overflowPath: file };
92
+ }
93
+ catch {
94
+ // Spill failed — keep the in-memory bytes (restore still works).
95
+ }
96
+ }
97
+ return { abs, existed: true, hash, bytes, overflowPath: null };
98
+ }
99
+ function pushCheckpoint(label, files, marks) {
100
+ seq += 1;
101
+ const cp = {
102
+ id: newCheckpointId(seq),
103
+ seq,
104
+ at: Date.now(),
105
+ label,
106
+ historyLength: marks.history,
107
+ turnsLength: marks.turns,
108
+ files,
109
+ };
110
+ checkpoints.push(cp);
111
+ while (checkpoints.length > MAX_CHECKPOINTS) {
112
+ const dropped = checkpoints.shift();
113
+ for (const f of dropped?.files ?? []) {
114
+ if (f.overflowPath) {
115
+ void fsp.rm(f.overflowPath, { force: true }).catch(() => undefined);
116
+ }
117
+ }
118
+ }
119
+ return cp;
120
+ }
121
+ // THE capture hook: snapshot prior bytes, then record one checkpoint. Never
122
+ // throws and never returns an error into the mutation path — a missed
123
+ // snapshot just means no checkpoint for this write.
124
+ export async function capturePriorBytes(abs, label) {
125
+ try {
126
+ if (typeof abs !== "string" || abs.length === 0)
127
+ return null;
128
+ const file = await readPrior(abs);
129
+ const cleanLabel = typeof label === "string" && label.length > 0 ? label : "edit";
130
+ return pushCheckpoint(cleanLabel, [file], currentMarks());
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ }
136
+ async function priorBytesOf(f) {
137
+ if (!f.existed)
138
+ return null;
139
+ if (f.bytes !== null)
140
+ return f.bytes;
141
+ if (f.overflowPath) {
142
+ try {
143
+ return await fsp.readFile(f.overflowPath);
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ }
149
+ return null;
150
+ }
151
+ // Restore every file in the checkpoint to its prior bytes (or delete files
152
+ // the mutation created), hash-verified. Returns a one-line summary or an
153
+ // "Error: ..." string — never throws. onRestored lets the caller refresh
154
+ // derived state per file (tools.ts stale-read fingerprints); text is null
155
+ // for deletions. Observer errors never break the restore.
156
+ export async function restoreCheckpointFiles(id, onRestored) {
157
+ const cp = getCheckpoint(id);
158
+ if (!cp)
159
+ return "Error: unknown checkpoint";
160
+ let restored = 0;
161
+ for (const f of cp.files) {
162
+ try {
163
+ if (!f.existed) {
164
+ try {
165
+ await fsp.rm(f.abs, { force: true });
166
+ }
167
+ catch {
168
+ return `Error: rewind failed: cannot remove ${f.abs}`;
169
+ }
170
+ try {
171
+ onRestored?.(f.abs, null);
172
+ }
173
+ catch {
174
+ // ignore observer errors
175
+ }
176
+ restored += 1;
177
+ continue;
178
+ }
179
+ const prior = await priorBytesOf(f);
180
+ if (prior === null)
181
+ return `Error: rewind failed: snapshot for ${f.abs} is unreadable`;
182
+ try {
183
+ await fsp.mkdir(path.dirname(f.abs), { recursive: true });
184
+ await fsp.writeFile(f.abs, prior);
185
+ }
186
+ catch {
187
+ return `Error: rewind failed: cannot restore ${f.abs}`;
188
+ }
189
+ let check;
190
+ try {
191
+ check = await fsp.readFile(f.abs);
192
+ }
193
+ catch {
194
+ return `Error: rewind failed: cannot verify ${f.abs}`;
195
+ }
196
+ if (createHash("sha256").update(check).digest("hex") !== f.hash) {
197
+ return `Error: rewind failed: hash mismatch restoring ${f.abs}`;
198
+ }
199
+ try {
200
+ onRestored?.(f.abs, prior.toString("utf8"));
201
+ }
202
+ catch {
203
+ // ignore observer errors
204
+ }
205
+ restored += 1;
206
+ }
207
+ catch {
208
+ return `Error: rewind failed: cannot restore ${f.abs}`;
209
+ }
210
+ }
211
+ if (restored === 0)
212
+ return `(checkpoint #${cp.seq} — nothing to restore)`;
213
+ return `(rewound ${restored} file(s) to checkpoint #${cp.seq})`;
214
+ }
215
+ // Conversation-rewind cut for a checkpoint mark: drop the whole turn that
216
+ // contains the mark (submit's splice(rollbackTo) rollback semantics), so
217
+ // assistant/tool pairing can never split. A mark taken after a turn already
218
+ // committed (an assistant message without tool_calls sits inside the slice)
219
+ // keeps everything through the mark — only later turns drop. keepFirst is
220
+ // the floor (1 keeps the system prompt for API history; 0 for the display
221
+ // transcript, which has no system line). Pure — unit-tested directly.
222
+ export function conversationCutIndex(messages, mark, keepFirst = 1) {
223
+ const len = messages.length;
224
+ const floor = Math.max(0, Math.floor(keepFirst));
225
+ if (len <= floor)
226
+ return len;
227
+ const m = Math.max(floor, Math.min(Number.isFinite(mark) ? Math.floor(mark) : len, len));
228
+ let turnStart = -1;
229
+ for (let i = m - 1; i >= floor; i--) {
230
+ if (messages[i]?.role === "user") {
231
+ turnStart = i;
232
+ break;
233
+ }
234
+ }
235
+ if (turnStart === -1)
236
+ return floor;
237
+ for (let i = turnStart; i < m; i++) {
238
+ const msg = messages[i];
239
+ if (msg !== undefined && msg.role === "assistant" && msg.hasToolCalls !== true)
240
+ return m;
241
+ }
242
+ return turnStart;
243
+ }
package/dist/system.js ADDED
@@ -0,0 +1,22 @@
1
+ // Base system prompt for the Atom chatbot (owner-editable).
2
+ //
3
+ // System-prompt layering (two layers, appended at startup):
4
+ // final system = <lines below joined> + "\n\n" + <repo AGENTS.md>
5
+ // (see buildSystemPrompt in zen.ts; the AGENTS.md overlay is capped at
6
+ // 12KB). To change the bot's base identity, edit the lines below; to
7
+ // add project/repo instructions, edit AGENTS.md.
8
+ //
9
+ // NOTE: the first line is pinned — tests/app.test.tsx asserts the prompt
10
+ // starts with it. Keep it stable.
11
+ export const SYSTEM_PROMPT = [
12
+ "You are ATOM, a long-horizon coding agent that works through tools.",
13
+ "",
14
+ "Loop every task: explore, plan, implement, verify, report.",
15
+ "Plan 3+ step tasks with todowrite: full list up front, exactly one in_progress, mark completed immediately, never batch.",
16
+ "Read files before editing them. Search existing code before writing new code. Match surrounding patterns.",
17
+ "Prefer the smallest correct change. Fix root causes. Handle errors and edge cases. Remove dead code.",
18
+ "Ground every claim in tool output, never in memory. Run commands to check facts.",
19
+ "After each tool result, reflect briefly, then take the best next action toward the goal.",
20
+ "Keep calling tools until verified done. Never end on an unverified summary or a guess.",
21
+ "Done means tests and typecheck pass, or the blocker is named with its evidence.",
22
+ ].join("\n");