atom-agent 1.3.0 → 1.5.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 +62 -0
- package/README.md +220 -224
- package/dist/App.js +922 -341
- package/dist/adapters.js +127 -14
- package/dist/agent/goal-evaluator.js +3 -0
- package/dist/agent/loop.js +211 -430
- package/dist/agent/tool-pipeline.js +398 -0
- package/dist/agent/turn-events.js +12 -0
- package/dist/cli.js +57 -8
- package/dist/compact.js +72 -8
- package/dist/config.js +19 -0
- package/dist/context-manager.js +6 -2
- package/dist/extensions.js +6 -0
- package/dist/file-diffs.js +108 -0
- package/dist/kilo.js +1 -1
- package/dist/local-discovery.js +2 -2
- package/dist/media.js +276 -0
- package/dist/overflow.js +140 -0
- package/dist/policy.js +8 -0
- package/dist/scheduler.js +38 -9
- package/dist/session-revert.js +125 -0
- package/dist/sessions.js +101 -0
- package/dist/snapshots.js +69 -0
- package/dist/system.js +2 -89
- package/dist/telemetry.js +26 -1
- package/dist/todos.js +241 -0
- package/dist/tools/filesystem.js +102 -22
- package/dist/tools/registry.js +184 -45
- package/dist/tools/ripgrep.js +7 -6
- package/dist/tools/search.js +172 -17
- package/dist/tools/shared.js +6 -0
- package/dist/tools.js +7 -39
- package/dist/ui/diff-panel.js +5 -5
- package/dist/ui/diff-view.js +16 -7
- package/dist/ui/diff.js +73 -51
- package/dist/ui/errors.js +20 -6
- package/dist/ui/input.js +24 -20
- package/dist/ui/live-tail.js +36 -1
- package/dist/ui/markdown.js +9 -4
- package/dist/ui/modals.js +6 -4
- package/dist/ui/paint-scheduler.js +120 -0
- package/dist/ui/palette.js +4 -2
- package/dist/ui/pickers.js +4 -1
- package/dist/ui/side-by-side.js +88 -27
- package/dist/ui/status-bar.js +63 -8
- package/dist/ui/stream-store.js +7 -0
- package/dist/ui/theme.js +23 -1
- package/dist/ui/todo-panel.js +5 -2
- package/dist/ui/tool-inspector.js +33 -4
- package/dist/ui/transcript.js +9 -6
- package/dist/web/events.js +93 -0
- package/dist/web/runtime.js +790 -0
- package/dist/web/server.js +570 -0
- package/dist/web/ui/app.js +1925 -0
- package/dist/web/ui/index.html +135 -0
- package/dist/web/ui/styles.css +515 -0
- package/dist/zen.js +115 -4
- package/documentation/cli.md +5 -5
- package/documentation/configuration.md +11 -6
- package/documentation/development.md +4 -3
- package/documentation/extensions.md +1 -1
- package/documentation/goals.md +1 -1
- package/documentation/index.md +4 -4
- package/documentation/providers.md +2 -3
- package/documentation/skills.md +3 -3
- package/documentation/tools.md +8 -3
- package/documentation/troubleshooting.md +1 -1
- package/examples/extensions/01-audit-gate.js +2 -2
- package/examples/extensions/02-notes-tool.js +2 -2
- package/examples/extensions/03-custom-command.js +2 -2
- package/package.json +3 -2
package/dist/todos.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// Per-session todos (ticket 05): a task checklist scoped to each session
|
|
2
|
+
// that persists across compactions and restarts, so the agent's plan and
|
|
3
|
+
// progress are never lost when old chat text is summarized away.
|
|
4
|
+
//
|
|
5
|
+
// Storage: namespaced under the multi-session record's generic
|
|
6
|
+
// `metadata.todos` key (src/sessions.ts) via the existing
|
|
7
|
+
// updateSession/getSession APIs — no schema edits, no new files on disk.
|
|
8
|
+
// Todos live OUTSIDE compacted chat text, so compaction (summary+tail)
|
|
9
|
+
// can never summarize them away; the summary only carries a context
|
|
10
|
+
// backstop (see formatGoalForCompact), never the record of truth.
|
|
11
|
+
//
|
|
12
|
+
// Status vocabulary is exactly src/tools/todo.ts's
|
|
13
|
+
// ("pending" | "in_progress" | "completed") — matched, not reinvented.
|
|
14
|
+
// Runtime invariants (at most one in_progress, completed-stays-completed,
|
|
15
|
+
// all-completed clears) stay owned by the tools/todo.ts layer; this module
|
|
16
|
+
// validates SHAPE only, so a saved record always replays through
|
|
17
|
+
// todowriteTool cleanly.
|
|
18
|
+
//
|
|
19
|
+
// Totality (mirroring goal.ts): serialize/restore never throw. Old records
|
|
20
|
+
// without the todos key read as an empty list; a malformed todos value
|
|
21
|
+
// degrades to empty WITHOUT failing the session load (same posture as the
|
|
22
|
+
// goal field). The pure CRUD ops below throw Error on invalid input —
|
|
23
|
+
// those are programmer errors with explicit messages, never silent.
|
|
24
|
+
//
|
|
25
|
+
// Import budget: type-only import from ./tools/todo.js (erased at compile,
|
|
26
|
+
// zero runtime coupling) plus no value imports — this module never touches
|
|
27
|
+
// App, sessions, compact, config, or the tool registry.
|
|
28
|
+
// Namespace inside Session.metadata. Never read or write metadata.filediffs
|
|
29
|
+
// (ticket 06 owns it).
|
|
30
|
+
export const TODOS_METADATA_KEY = "todos";
|
|
31
|
+
const TODO_STATUSES = [
|
|
32
|
+
"pending",
|
|
33
|
+
"in_progress",
|
|
34
|
+
"completed",
|
|
35
|
+
];
|
|
36
|
+
const TODO_PRIORITIES = ["high", "medium", "low"];
|
|
37
|
+
function isRecord(value) {
|
|
38
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
// Shape check mirroring todowriteTool's item validation (same vocabulary,
|
|
41
|
+
// same strictness): non-empty content, known status, known priority when
|
|
42
|
+
// present, string activeForm when present. Unknown extra keys are ignored.
|
|
43
|
+
// Returns a clean deep copy, or null when invalid.
|
|
44
|
+
export function validateTodoRecord(value) {
|
|
45
|
+
try {
|
|
46
|
+
if (!isRecord(value))
|
|
47
|
+
return null;
|
|
48
|
+
if (typeof value["content"] !== "string" || value["content"].length === 0) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const status = value["status"];
|
|
52
|
+
if (status !== "pending" &&
|
|
53
|
+
status !== "in_progress" &&
|
|
54
|
+
status !== "completed") {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const clean = {
|
|
58
|
+
content: value["content"],
|
|
59
|
+
status,
|
|
60
|
+
};
|
|
61
|
+
if (value["priority"] !== undefined) {
|
|
62
|
+
const priority = value["priority"];
|
|
63
|
+
if (priority !== "high" && priority !== "medium" && priority !== "low") {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
clean.priority = priority;
|
|
67
|
+
}
|
|
68
|
+
if (value["activeForm"] !== undefined) {
|
|
69
|
+
if (typeof value["activeForm"] !== "string")
|
|
70
|
+
return null;
|
|
71
|
+
if (value["activeForm"].length > 0)
|
|
72
|
+
clean.activeForm = value["activeForm"];
|
|
73
|
+
}
|
|
74
|
+
return clean;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function cleanTodoList(list) {
|
|
81
|
+
return list.map((t) => ({ ...t }));
|
|
82
|
+
}
|
|
83
|
+
function checkedIndex(length, index, verb) {
|
|
84
|
+
if (typeof index !== "number" ||
|
|
85
|
+
!Number.isFinite(index) ||
|
|
86
|
+
Math.floor(index) !== index) {
|
|
87
|
+
throw new Error(`${verb}: index must be an integer (got ${String(index)})`);
|
|
88
|
+
}
|
|
89
|
+
if (index < 1 || index > length) {
|
|
90
|
+
throw new Error(`${verb}: index ${index} out of range (list has ${length} item(s))`);
|
|
91
|
+
}
|
|
92
|
+
return index - 1;
|
|
93
|
+
}
|
|
94
|
+
// Append one item (1-based position = end). Throws Error on a malformed item.
|
|
95
|
+
export function createTodo(list, item) {
|
|
96
|
+
const clean = validateTodoRecord(item);
|
|
97
|
+
if (!clean) {
|
|
98
|
+
throw new Error(`createTodo: item must be {content: non-empty string, status: "pending" | "in_progress" | "completed", priority?: "high" | "medium" | "low", activeForm?: string}`);
|
|
99
|
+
}
|
|
100
|
+
return [...cleanTodoList(list), clean];
|
|
101
|
+
}
|
|
102
|
+
// Patch ONE item by 1-based index (status transitions flow through here —
|
|
103
|
+
// completeTodo below is the named common case). Unknown patch keys are
|
|
104
|
+
// ignored; an empty patch is a no-op copy. Throws Error on a bad index or
|
|
105
|
+
// an invalid patched value.
|
|
106
|
+
export function updateTodo(list, index, patch) {
|
|
107
|
+
const at = checkedIndex(list.length, index, "updateTodo");
|
|
108
|
+
const p = isRecord(patch) ? { ...patch } : {};
|
|
109
|
+
const next = { ...list[at] };
|
|
110
|
+
if (p["status"] !== undefined) {
|
|
111
|
+
const status = p["status"];
|
|
112
|
+
if (status !== "pending" &&
|
|
113
|
+
status !== "in_progress" &&
|
|
114
|
+
status !== "completed") {
|
|
115
|
+
throw new Error(`updateTodo: status must be one of "pending", "in_progress", "completed" (got ${JSON.stringify(status) ?? String(status)})`);
|
|
116
|
+
}
|
|
117
|
+
next.status = status;
|
|
118
|
+
}
|
|
119
|
+
if (p["content"] !== undefined) {
|
|
120
|
+
if (typeof p["content"] !== "string" || p["content"].length === 0) {
|
|
121
|
+
throw new Error(`updateTodo: content must be a non-empty string`);
|
|
122
|
+
}
|
|
123
|
+
next.content = p["content"];
|
|
124
|
+
}
|
|
125
|
+
if (p["priority"] !== undefined) {
|
|
126
|
+
const priority = p["priority"];
|
|
127
|
+
if (priority !== "high" && priority !== "medium" && priority !== "low") {
|
|
128
|
+
throw new Error(`updateTodo: priority must be one of "high", "medium", "low" (got ${JSON.stringify(priority) ?? String(priority)})`);
|
|
129
|
+
}
|
|
130
|
+
next.priority = priority;
|
|
131
|
+
}
|
|
132
|
+
if (p["activeForm"] !== undefined) {
|
|
133
|
+
if (typeof p["activeForm"] !== "string") {
|
|
134
|
+
throw new Error(`updateTodo: activeForm must be a string`);
|
|
135
|
+
}
|
|
136
|
+
if (p["activeForm"].length > 0) {
|
|
137
|
+
next.activeForm = p["activeForm"];
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
delete next.activeForm;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const out = cleanTodoList(list);
|
|
144
|
+
out[at] = next;
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
// Mark one item completed by 1-based index. Throws Error on a bad index.
|
|
148
|
+
export function completeTodo(list, index) {
|
|
149
|
+
return updateTodo(list, index, { status: "completed" });
|
|
150
|
+
}
|
|
151
|
+
// Move the item at 1-based `from` to 1-based `to` (order = array order).
|
|
152
|
+
// Throws Error on a bad index.
|
|
153
|
+
export function reorderTodo(list, from, to) {
|
|
154
|
+
const fromAt = checkedIndex(list.length, from, "reorderTodo");
|
|
155
|
+
const toAt = checkedIndex(list.length, to, "reorderTodo");
|
|
156
|
+
if (fromAt === toAt)
|
|
157
|
+
return cleanTodoList(list);
|
|
158
|
+
const out = cleanTodoList(list);
|
|
159
|
+
const [moved] = out.splice(fromAt, 1);
|
|
160
|
+
out.splice(toAt, 0, moved);
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
// Serialize the live list for a session save: a deep copy (the save must
|
|
164
|
+
// never alias live state). Total: never throws; unserializable input reads
|
|
165
|
+
// as an empty list.
|
|
166
|
+
export function serializeTodosForPersist(list) {
|
|
167
|
+
try {
|
|
168
|
+
if (!Array.isArray(list))
|
|
169
|
+
return [];
|
|
170
|
+
const out = [];
|
|
171
|
+
for (const item of list) {
|
|
172
|
+
const clean = validateTodoRecord(item);
|
|
173
|
+
if (!clean)
|
|
174
|
+
return [];
|
|
175
|
+
const persisted = {
|
|
176
|
+
content: clean.content,
|
|
177
|
+
status: clean.status,
|
|
178
|
+
};
|
|
179
|
+
if (clean.priority !== undefined)
|
|
180
|
+
persisted.priority = clean.priority;
|
|
181
|
+
if (clean.activeForm !== undefined)
|
|
182
|
+
persisted.activeForm = clean.activeForm;
|
|
183
|
+
out.push(persisted);
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Restore a saved list: valid items come back verbatim (states intact, order
|
|
192
|
+
// intact); a missing key, a non-array, or ANY malformed item degrades to an
|
|
193
|
+
// empty list — never a throw, never a partial list (a half-restored plan is
|
|
194
|
+
// worse than a visibly empty one). Old records without the todos key land
|
|
195
|
+
// here safely.
|
|
196
|
+
export function restoreTodosFromPersist(value) {
|
|
197
|
+
try {
|
|
198
|
+
if (value === null || value === undefined)
|
|
199
|
+
return [];
|
|
200
|
+
if (!Array.isArray(value))
|
|
201
|
+
return [];
|
|
202
|
+
const out = [];
|
|
203
|
+
for (const item of value) {
|
|
204
|
+
const clean = validateTodoRecord(item);
|
|
205
|
+
if (!clean)
|
|
206
|
+
return [];
|
|
207
|
+
out.push(clean);
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// Read the checklist out of a session record's metadata (the switch-restore
|
|
216
|
+
// path): absent or corrupt reads as []. Never throws.
|
|
217
|
+
export function readSessionTodos(metadata) {
|
|
218
|
+
try {
|
|
219
|
+
if (!isRecord(metadata))
|
|
220
|
+
return [];
|
|
221
|
+
return restoreTodosFromPersist(metadata[TODOS_METADATA_KEY]);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// Stamp the checklist into a metadata object for an updateSession patch (the
|
|
228
|
+
// per-turn persist path): every other key (extension state, filediffs, …)
|
|
229
|
+
// passes through untouched — only metadata.todos is set. Never throws.
|
|
230
|
+
export function withSessionTodos(metadata, list) {
|
|
231
|
+
try {
|
|
232
|
+
const base = isRecord(metadata)
|
|
233
|
+
? { ...metadata }
|
|
234
|
+
: {};
|
|
235
|
+
base[TODOS_METADATA_KEY] = serializeTodosForPersist(list);
|
|
236
|
+
return base;
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return { [TODOS_METADATA_KEY]: [] };
|
|
240
|
+
}
|
|
241
|
+
}
|
package/dist/tools/filesystem.js
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
// bytes first (see snapshots.ts); validation failures return before capture.
|
|
3
3
|
import { promises as fsp } from "node:fs";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
+
import { MEDIA_MAX_BYTES, mediaDescriptor, oversizeImageError, saveMedia, sniffImageMime, sniffPdf, unsupportedBinaryError, } from "../media.js";
|
|
5
6
|
import { capturePriorBytes } from "../snapshots.js";
|
|
6
7
|
import { contentHash, fingerprintKey, readFingerprints } from "./fingerprints.js";
|
|
7
8
|
import { appendOverflow } from "./overflow.js";
|
|
8
9
|
import { getCachedRead, invalidatePath, normalizeReadWindow, setCachedRead } from "./read-cache.js";
|
|
9
10
|
import { invalidateListingsForFile } from "./dir-cache.js";
|
|
10
|
-
import { err, invalidCall, READ_CHAR_CAP, resolveSandbox, truncateHead } from "./shared.js";
|
|
11
|
+
import { err, invalidCall, READ_CHAR_CAP, READ_FILE_MAX_BYTES, resolveSandbox, truncateHead } from "./shared.js";
|
|
11
12
|
// offset/limit are 1-based line numbers. Output capped at ~64KB.
|
|
12
13
|
export async function readTool(args, cwd = process.cwd()) {
|
|
13
14
|
try {
|
|
@@ -26,6 +27,62 @@ export async function readTool(args, cwd = process.cwd()) {
|
|
|
26
27
|
const lines = entries.map((e) => (e.isDirectory() ? `${e.name}/` : e.name));
|
|
27
28
|
return `Directory listing for ${args.path}:\n${lines.join("\n")}`;
|
|
28
29
|
}
|
|
30
|
+
// Vision-input peek FIRST: image magic bytes sit in the first 12 bytes,
|
|
31
|
+
// so every file is classified with one tiny read. Supported images
|
|
32
|
+
// (PNG/JPEG/GIF/WebP) take the media path with their own MEDIA_MAX_BYTES
|
|
33
|
+
// cap; PDF magic gets convert-first guidance at any size; everything
|
|
34
|
+
// else falls into the existing guarded text path untouched.
|
|
35
|
+
const fileSize = st.size ?? 0;
|
|
36
|
+
let peek = null;
|
|
37
|
+
try {
|
|
38
|
+
const fh = await fsp.open(r.abs, "r");
|
|
39
|
+
try {
|
|
40
|
+
const buf = Buffer.alloc(12);
|
|
41
|
+
const { bytesRead } = await fh.read(buf, 0, 12, 0);
|
|
42
|
+
peek = buf.subarray(0, bytesRead);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
await fh.close();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// peek never breaks reads — null falls through to the text path
|
|
50
|
+
}
|
|
51
|
+
if (peek !== null) {
|
|
52
|
+
const mime = sniffImageMime(peek);
|
|
53
|
+
if (mime !== null) {
|
|
54
|
+
if (fileSize > MEDIA_MAX_BYTES)
|
|
55
|
+
return oversizeImageError(args.path, fileSize);
|
|
56
|
+
let raw;
|
|
57
|
+
try {
|
|
58
|
+
raw = await fsp.readFile(r.abs);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return err(`cannot read file: ${args.path}`);
|
|
62
|
+
}
|
|
63
|
+
const { id } = await saveMedia(raw, mime, args.path);
|
|
64
|
+
// Fingerprint on the utf8 decoding so a later edit compares
|
|
65
|
+
// consistently with editTool's own read+hash.
|
|
66
|
+
readFingerprints.set(fingerprintKey(r.abs), contentHash(raw.toString("utf8")));
|
|
67
|
+
return (`Image read successfully: ${args.path} (${mime}, ${raw.length} bytes, attached as vision input).\n` +
|
|
68
|
+
mediaDescriptor(id, mime, raw.length));
|
|
69
|
+
}
|
|
70
|
+
if (sniffPdf(peek)) {
|
|
71
|
+
return unsupportedBinaryError(args.path, "pdf", fileSize);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// OOM guard: never materialize a whole file past READ_FILE_MAX_BYTES
|
|
75
|
+
// (UTF-16 doubling + split/join copies can OOM the heap on one read).
|
|
76
|
+
// The size is known from the stat above, so this costs no extra I/O.
|
|
77
|
+
try {
|
|
78
|
+
const size = st.size ?? 0;
|
|
79
|
+
if (size > READ_FILE_MAX_BYTES) {
|
|
80
|
+
return err(`file too large to read (${size} bytes > 1MB): ${args.path}. Narrow with grep/glob first`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// size check never breaks reads (the read below still applies its cap)
|
|
85
|
+
}
|
|
29
86
|
// Read-cache fast path: same abs + window + unchanged mtime/size skips
|
|
30
87
|
// disk I/O. The stored hash refreshes the stale-read fingerprint so
|
|
31
88
|
// read→read→edit chains keep working without re-hashing.
|
|
@@ -48,32 +105,42 @@ export async function readTool(args, cwd = process.cwd()) {
|
|
|
48
105
|
catch {
|
|
49
106
|
return err(`cannot read file: ${args.path}`);
|
|
50
107
|
}
|
|
51
|
-
|
|
52
|
-
readFingerprints.set(fingerprintKey(r.abs), hash);
|
|
53
|
-
if (text.length === 0)
|
|
54
|
-
return "";
|
|
55
|
-
const offset = Math.max(1, Math.floor(args.offset ?? 1));
|
|
56
|
-
const limit = Math.max(1, Math.floor(args.limit ?? Number.MAX_SAFE_INTEGER));
|
|
57
|
-
const window = text.split("\n").slice(offset - 1, offset - 1 + limit);
|
|
58
|
-
let out = window.map((line, i) => `${offset + i}: ${line}`).join("\n");
|
|
59
|
-
if (out.length > READ_CHAR_CAP) {
|
|
60
|
-
const full = out;
|
|
61
|
-
const t = truncateHead(full, READ_CHAR_CAP, "\n[truncated: output exceeded 64KB]");
|
|
62
|
-
out = appendOverflow(t.head, t.note, "file output", full);
|
|
63
|
-
}
|
|
64
|
-
try {
|
|
65
|
-
const statInfo = { mtimeMs: st.mtimeMs ?? 0, size: st.size ?? 0 };
|
|
66
|
-
setCachedRead(r.abs, normOffset, normLimit, out, statInfo, hash);
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
// cache store never breaks reads
|
|
70
|
-
}
|
|
71
|
-
return out;
|
|
108
|
+
return readTextResult(r.abs, args, text, st, normOffset, normLimit);
|
|
72
109
|
}
|
|
73
110
|
catch (e) {
|
|
74
111
|
return err(e instanceof Error ? e.message : String(e));
|
|
75
112
|
}
|
|
76
113
|
}
|
|
114
|
+
// Shared text path for readTool: fingerprint + line window + 64KB cap +
|
|
115
|
+
// cache store. The small-file caller reuses its already-read bytes;
|
|
116
|
+
// the over-cap caller arrives here after the media peek.
|
|
117
|
+
function readTextResult(abs, args, text, st, normOffset, normLimit) {
|
|
118
|
+
const stat = st;
|
|
119
|
+
const hash = contentHash(text);
|
|
120
|
+
readFingerprints.set(fingerprintKey(abs), hash);
|
|
121
|
+
if (text.length === 0)
|
|
122
|
+
return "";
|
|
123
|
+
const offset = Math.max(1, Math.floor(args.offset ?? 1));
|
|
124
|
+
const limit = Math.max(1, Math.floor(args.limit ?? Number.MAX_SAFE_INTEGER));
|
|
125
|
+
const window = text.split("\n").slice(offset - 1, offset - 1 + limit);
|
|
126
|
+
let out = window.map((line, i) => `${offset + i}: ${line}`).join("\n");
|
|
127
|
+
if (out.length > READ_CHAR_CAP) {
|
|
128
|
+
const full = out;
|
|
129
|
+
const t = truncateHead(full, READ_CHAR_CAP, "\n[truncated: output exceeded 64KB]");
|
|
130
|
+
out = appendOverflow(t.head, t.note, "file output", full);
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const statInfo = { mtimeMs: stat.mtimeMs ?? 0, size: stat.size ?? 0 };
|
|
134
|
+
const { offset: o, limit: l } = normOffset !== undefined && normLimit !== undefined
|
|
135
|
+
? { offset: normOffset, limit: normLimit }
|
|
136
|
+
: normalizeReadWindow(args.offset, args.limit);
|
|
137
|
+
setCachedRead(abs, o, l, out, statInfo, hash);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// cache store never breaks reads
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
77
144
|
export async function writeTool(args, cwd = process.cwd()) {
|
|
78
145
|
try {
|
|
79
146
|
const r = resolveSandbox(args?.path, cwd);
|
|
@@ -110,6 +177,19 @@ export async function editTool(args, cwd = process.cwd()) {
|
|
|
110
177
|
}
|
|
111
178
|
if (typeof args.newString !== "string")
|
|
112
179
|
return err("newString must be a string");
|
|
180
|
+
// OOM guard (same rationale as readTool above): an edit materializes
|
|
181
|
+
// the whole file plus split/join copies, so refuse past the cap with
|
|
182
|
+
// guidance instead of risking the heap. Missing paths keep the legacy
|
|
183
|
+
// "no such file" error below.
|
|
184
|
+
try {
|
|
185
|
+
const st = await fsp.stat(r.abs);
|
|
186
|
+
if (st.isFile() && st.size > READ_FILE_MAX_BYTES) {
|
|
187
|
+
return err(`file too large to edit (${st.size} bytes > 1MB): ${args.path}. Use bash for targeted changes to huge files`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// stat failure falls through to the read below (missing → its error)
|
|
192
|
+
}
|
|
113
193
|
let text;
|
|
114
194
|
try {
|
|
115
195
|
text = await fsp.readFile(r.abs, "utf8");
|