@giovannijecha/jecode 0.7.1 → 0.7.3

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.
@@ -2,6 +2,7 @@
2
2
  // event stream. Only idempotent reads retry. Once a POST starts or response
3
3
  // bytes flow, a failure is surfaced rather than silently replayed.
4
4
  import { readSseJson } from "./sse.js";
5
+ import { sseStreamCharacterLimit } from "./stream-limits.js";
5
6
  const RETRYABLE = new Set([408, 409, 429, 500, 502, 503, 504]);
6
7
  const MAX_JSON_CHARS = 5_000_000;
7
8
  const MAX_ERROR_CHARS = 2_000;
@@ -33,11 +34,12 @@ async function asJson(url, res) {
33
34
  throw httpError(`${url} returned non-JSON`, res.status, text.slice(0, 500));
34
35
  }
35
36
  }
36
- export async function postSse(url, headers, body, signal, onStatus) {
37
+ export async function postSse(url, headers, body, maxOutputTokens, signal, onStatus) {
38
+ const maximumChars = sseStreamCharacterLimit(maxOutputTokens);
37
39
  const res = await request(url, { accept: "text/event-stream", ...headers }, body, signal, onStatus);
38
40
  if (res.body === null)
39
41
  throw httpError(`${url} returned no body`, res.status);
40
- return readSseJson(withIdleTimeout(url, res.body));
42
+ return readSseJson(withIdleTimeout(url, res.body), maximumChars);
41
43
  }
42
44
  async function request(url, headers, body, signal, onStatus) {
43
45
  const maxRetries = body === undefined ? GET_RETRIES : 0;
@@ -88,7 +88,7 @@ export const ollama = {
88
88
  max_tokens: req.maxTokens,
89
89
  reasoning_effort: effort,
90
90
  stream: true,
91
- }, req.signal, req.onStatus);
91
+ }, req.maxTokens, req.signal, req.onStatus);
92
92
  const reply = await assembleOllama(events, req.onStream);
93
93
  const notice = stopNotice(reply);
94
94
  if (notice !== undefined)
@@ -71,7 +71,7 @@ export const openaiCodex = {
71
71
  text: { verbosity: "low" },
72
72
  include: ["reasoning.encrypted_content"],
73
73
  prompt_cache_key: SESSION_ID,
74
- }, req.signal, req.onStatus);
74
+ }, req.maxTokens, req.signal, req.onStatus);
75
75
  const data = await assembleOpenAI(events, req.onStream);
76
76
  const notice = stopNotice(data);
77
77
  if (notice !== undefined)
@@ -86,7 +86,7 @@ export const openai = {
86
86
  store: false,
87
87
  include: ["reasoning.encrypted_content"],
88
88
  stream: true,
89
- }, req.signal, req.onStatus);
89
+ }, req.maxTokens, req.signal, req.onStatus);
90
90
  const data = await assembleOpenAI(events, req.onStream);
91
91
  const notice = stopNotice(data);
92
92
  if (notice !== undefined)
@@ -3,15 +3,15 @@
3
3
  // The format is small: `field: value` lines, a blank line ends an event. Only
4
4
  // `data` matters here — both providers put the event discriminator inside the
5
5
  // JSON payload, so the `event:` line is redundant and skipped.
6
- import { addBounded, MAX_SSE_EVENT_CHARS, MAX_SSE_STREAM_CHARS, } from "./stream-limits.js";
7
- export async function* readSseJson(body) {
6
+ import { addBounded, MAX_SSE_EVENT_CHARS, } from "./stream-limits.js";
7
+ export async function* readSseJson(body, maximumChars) {
8
8
  const reader = body.getReader();
9
9
  const decoder = new TextDecoder();
10
10
  let buffer = "";
11
11
  let finished = false;
12
12
  let total = 0;
13
13
  const append = (text) => {
14
- total = addBounded(total, text.length, MAX_SSE_STREAM_CHARS, "SSE stream");
14
+ total = addBounded(total, text.length, maximumChars, "SSE stream");
15
15
  buffer += text;
16
16
  };
17
17
  try {
@@ -1,8 +1,20 @@
1
1
  // Response streams are remote input. Bound the pieces that otherwise grow
2
- // independently of the request's output-token setting.
2
+ // independently, while letting a larger requested output carry its necessarily
3
+ // larger framing, terminal envelope, and opaque reasoning payloads.
3
4
  export const MAX_SSE_EVENT_CHARS = 1_000_000;
4
- export const MAX_SSE_STREAM_CHARS = 4_000_000;
5
+ export const MAX_SSE_STREAM_CHARS = 256_000_000;
5
6
  export const MAX_TOOL_ARGUMENT_CHARS = 1_000_000;
7
+ const MIN_SSE_STREAM_CHARS = 4_000_000;
8
+ const SSE_CHARS_PER_OUTPUT_TOKEN = 512;
9
+ export function sseStreamCharacterLimit(maxOutputTokens) {
10
+ if (!Number.isSafeInteger(maxOutputTokens) || maxOutputTokens <= 0) {
11
+ throw new Error("max output tokens must be a positive safe integer");
12
+ }
13
+ const scaled = maxOutputTokens > Math.floor(MAX_SSE_STREAM_CHARS / SSE_CHARS_PER_OUTPUT_TOKEN)
14
+ ? MAX_SSE_STREAM_CHARS
15
+ : maxOutputTokens * SSE_CHARS_PER_OUTPUT_TOKEN;
16
+ return Math.max(MIN_SSE_STREAM_CHARS, scaled);
17
+ }
6
18
  export function addBounded(total, added, maximum, label) {
7
19
  if (added > maximum - total) {
8
20
  throw new Error(`${label} exceeded ${maximum} characters`);
@@ -1,17 +1,21 @@
1
1
  // Strict codecs for session files. Disk is an untrusted boundary even when
2
2
  // the directory is owner-only: every value is bounded and re-owned before it
3
3
  // can become conversation or provider input.
4
+ import { Buffer } from "node:buffer";
4
5
  import { CONTEXT_LIMITS } from "../context/projection.js";
5
- export const SESSION_SCHEMA = 2;
6
+ export const SESSION_SCHEMA = 3;
6
7
  export const SESSION_FILE_LIMITS = Object.freeze({
7
8
  text: 1_048_576,
9
+ metadataBytes: 64 * 1_024,
10
+ nodeBytes: 20 * 1_024 * 1_024,
8
11
  jsonDepth: 24,
9
12
  jsonNodes: 32_768,
10
13
  blocks: 8_192,
11
14
  details: 8_192,
12
15
  });
13
16
  export function encodeMeta(meta) {
14
- return line(meta);
17
+ decodeMeta(meta);
18
+ return boundedLine(meta, SESSION_FILE_LIMITS.metadataBytes);
15
19
  }
16
20
  export function decodeMeta(value) {
17
21
  if (!record(value) || !keys(value, "createdAt,id,version,workspaceDigest,workspaceRoot")) {
@@ -32,7 +36,8 @@ export function decodeMeta(value) {
32
36
  });
33
37
  }
34
38
  export function encodeHead(head) {
35
- return line(head);
39
+ decodeHead(head);
40
+ return boundedLine(head, SESSION_FILE_LIMITS.metadataBytes);
36
41
  }
37
42
  export function decodeHead(value) {
38
43
  if (!record(value) || !keys(value, "nodeId,parentId,revision,sequence,updatedAt,version")) {
@@ -55,7 +60,16 @@ export function decodeHead(value) {
55
60
  });
56
61
  }
57
62
  export function encodeNode(node, sequence, updatedAt) {
58
- return line({
63
+ const envelope = nodeEnvelope(node, sequence, updatedAt);
64
+ decodeNode(envelope);
65
+ return boundedLine(envelope, SESSION_FILE_LIMITS.nodeBytes);
66
+ }
67
+ /** Enforce the exact current-disk boundary before a turn enters the tree. */
68
+ export function assertPersistableNode(node) {
69
+ encodeNode(node, 1, node.createdAt);
70
+ }
71
+ function nodeEnvelope(node, sequence, updatedAt) {
72
+ return {
59
73
  version: SESSION_SCHEMA,
60
74
  sequence,
61
75
  updatedAt,
@@ -69,8 +83,9 @@ export function encodeNode(node, sequence, updatedAt) {
69
83
  messages: node.messages.map(messageRecord),
70
84
  blocks: node.blocks.flatMap(blockRecord),
71
85
  context: node.context ?? null,
86
+ failure: node.failure ?? null,
72
87
  },
73
- });
88
+ };
74
89
  }
75
90
  export function decodeNode(value) {
76
91
  if (!record(value) || !keys(value, "node,sequence,updatedAt,version"))
@@ -82,7 +97,9 @@ export function decodeNode(value) {
82
97
  const raw = value["node"];
83
98
  const nodeKeys = version === 1
84
99
  ? "blocks,createdAt,id,identity,messages,parentId,revision,settlement"
85
- : "blocks,context,createdAt,id,identity,messages,parentId,revision,settlement";
100
+ : version === 2
101
+ ? "blocks,context,createdAt,id,identity,messages,parentId,revision,settlement"
102
+ : "blocks,context,createdAt,failure,id,identity,messages,parentId,revision,settlement";
86
103
  if (!record(raw) || !keys(raw, nodeKeys)) {
87
104
  throw invalid();
88
105
  }
@@ -93,7 +110,7 @@ export function decodeNode(value) {
93
110
  !integer(raw["parentId"], 0) ||
94
111
  !integer(raw["revision"], 1) ||
95
112
  !timestamp(raw["createdAt"]) ||
96
- (raw["settlement"] !== "checkpointed" && raw["settlement"] !== "completed") ||
113
+ !settlement(raw["settlement"], version) ||
97
114
  !record(identity) || !keys(identity, "effort,model,providerId") ||
98
115
  !bounded(identity["providerId"], 128) || !bounded(identity["model"], 512) ||
99
116
  !bounded(identity["effort"], 32) ||
@@ -103,6 +120,9 @@ export function decodeNode(value) {
103
120
  const context = version === 1
104
121
  ? undefined
105
122
  : contextFromRecord(raw["context"], raw["id"], messages.length);
123
+ const failure = version < 3
124
+ ? undefined
125
+ : failureFromRecord(raw["failure"], raw["settlement"]);
106
126
  const node = Object.freeze({
107
127
  id: raw["id"],
108
128
  parentId: raw["parentId"],
@@ -117,9 +137,25 @@ export function decodeNode(value) {
117
137
  messages: Object.freeze(messages.map(messageFromRecord)),
118
138
  blocks: Object.freeze(blocks.map(blockFromRecord)),
119
139
  ...(context === undefined ? {} : { context: Object.freeze(context) }),
140
+ ...(failure === undefined ? {} : { failure: Object.freeze(failure) }),
120
141
  });
121
142
  return Object.freeze({ sequence: value["sequence"], updatedAt: value["updatedAt"], node });
122
143
  }
144
+ function failureFromRecord(value, settlement) {
145
+ const failed = settlement === "failed" || settlement === "interrupted";
146
+ if (value === null) {
147
+ if (failed)
148
+ throw invalid();
149
+ return undefined;
150
+ }
151
+ if (!failed || !record(value) || !keys(value, "text,tone"))
152
+ throw invalid();
153
+ if (!bounded(value["text"]) ||
154
+ (settlement === "failed" && value["tone"] !== "error") ||
155
+ (settlement === "interrupted" && value["tone"] !== "warn"))
156
+ throw invalid();
157
+ return { text: value["text"], tone: value["tone"] };
158
+ }
123
159
  function contextFromRecord(value, ownerId, ownerMessages) {
124
160
  if (value === null)
125
161
  return undefined;
@@ -340,6 +376,12 @@ function jsonValue(value, budget, depth) {
340
376
  function line(value) {
341
377
  return `${JSON.stringify(value, null, 2)}\n`;
342
378
  }
379
+ function boundedLine(value, maxBytes) {
380
+ const encoded = line(value);
381
+ if (Buffer.byteLength(encoded, "utf8") > maxBytes)
382
+ throw invalid();
383
+ return encoded;
384
+ }
343
385
  function keys(value, expected) {
344
386
  return Object.keys(value).sort().join(",") === expected;
345
387
  }
@@ -356,7 +398,11 @@ function integer(value, minimum) {
356
398
  return typeof value === "number" && Number.isSafeInteger(value) && value >= minimum;
357
399
  }
358
400
  function schema(value) {
359
- return value === 1 || value === SESSION_SCHEMA;
401
+ return value === 1 || value === 2 || value === SESSION_SCHEMA;
402
+ }
403
+ function settlement(value, version) {
404
+ return value === "checkpointed" || value === "completed" ||
405
+ (version >= 3 && (value === "failed" || value === "interrupted"));
360
406
  }
361
407
  function nullableInteger(value, minimum) {
362
408
  return value === null || integer(value, minimum);
@@ -21,9 +21,9 @@ export class SessionPersistence {
21
21
  const lease = await store.claim(id);
22
22
  try {
23
23
  const snapshot = await store.load(id);
24
- const conversation = snapshot.conversation.latestCompleted();
24
+ const conversation = snapshot.conversation.latestResumable();
25
25
  if (conversation === undefined)
26
- throw new Error("session has no completed turn to resume");
26
+ throw new Error("session has no resumable turn");
27
27
  return Object.freeze({
28
28
  conversation,
29
29
  persistence: new SessionPersistence(store, id, lease),
@@ -4,17 +4,17 @@
4
4
  // after that node is durable, so a crash leaves either the prior checkpoint or
5
5
  // one strictly recoverable mutation -- never an ambiguous partial history.
6
6
  import { createHash, randomUUID } from "node:crypto";
7
- import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, } from "node:fs/promises";
7
+ import { chmod, lstat, mkdir, open, opendir, readFile, readdir, realpath, rename, rm, stat, } from "node:fs/promises";
8
8
  import * as path from "node:path";
9
9
  import { atomicWrite } from "../atomic.js";
10
10
  import { CONVERSATION_LIMITS, ConversationTree } from "../conversation.js";
11
11
  import { userDataPath } from "../user-data.js";
12
- import { decodeHead, decodeMeta, decodeNode, encodeHead, encodeMeta, encodeNode, SESSION_SCHEMA, } from "./codec.js";
12
+ import { decodeHead, decodeMeta, decodeNode, encodeHead, encodeMeta, encodeNode, SESSION_FILE_LIMITS, SESSION_SCHEMA, } from "./codec.js";
13
13
  import { leaseOwner, leaseToken, pidIsAlive, removeLease, sessionLease, } from "./lease.js";
14
14
  const DIRECTORY_MODE = 0o700;
15
15
  const FILE_MODE = 0o600;
16
- const MAX_CATALOG_SCAN = 128;
17
- const MAX_JSON_BYTES = 20 * 1024 * 1024;
16
+ const MAX_CATALOG_ENTRIES = 4_096;
17
+ const CATALOG_READ_CONCURRENCY = 8;
18
18
  const SESSION_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
19
19
  const NODE_NAME = /^(\d{6})\.json$/;
20
20
  const ATOMIC_NODE_TEMP = /^\.\d{6}\.json\.\d+\.[a-f0-9-]+\.tmp$/;
@@ -37,45 +37,45 @@ export class DurableSessionStore {
37
37
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) {
38
38
  throw new Error("session catalogue limit is invalid");
39
39
  }
40
- const entries = await directoryEntries(this.#bucket);
41
- const names = entries
42
- .filter((entry) => entry.isDirectory() && SESSION_NAME.test(entry.name))
43
- .map((entry) => entry.name)
44
- .sort((left, right) => right.localeCompare(left))
45
- .slice(0, MAX_CATALOG_SCAN);
40
+ const names = await catalogNames(this.#bucket);
46
41
  const catalog = [];
47
- for (const id of names) {
48
- try {
49
- const snapshot = await this.load(id);
50
- const conversation = snapshot.conversation.latestCompleted();
51
- if (conversation === undefined)
52
- continue;
53
- catalog.push({
54
- id,
55
- createdAt: snapshot.meta.createdAt,
56
- updatedAt: snapshot.head.updatedAt,
57
- turns: selectedTurnCount(conversation),
58
- preview: firstUserText(conversation),
59
- active: await this.#leaseIsActive(id),
60
- });
61
- }
62
- catch {
63
- // Corrupt or foreign data never becomes a resume candidate.
64
- }
42
+ for (let start = 0; start < names.length; start += CATALOG_READ_CONCURRENCY) {
43
+ const batch = await Promise.all(names.slice(start, start + CATALOG_READ_CONCURRENCY)
44
+ .map(async (id) => {
45
+ try {
46
+ const snapshot = await this.load(id);
47
+ const conversation = snapshot.conversation.latestResumable();
48
+ if (conversation === undefined)
49
+ return undefined;
50
+ return {
51
+ id,
52
+ createdAt: snapshot.meta.createdAt,
53
+ updatedAt: snapshot.head.updatedAt,
54
+ turns: selectedTurnCount(conversation),
55
+ preview: firstUserText(conversation),
56
+ active: await this.#leaseIsActive(id),
57
+ };
58
+ }
59
+ catch {
60
+ // Corrupt or foreign data never becomes a resume candidate.
61
+ return undefined;
62
+ }
63
+ }));
64
+ catalog.push(...batch.filter((entry) => entry !== undefined));
65
65
  }
66
66
  return catalog
67
- .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
67
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || right.id.localeCompare(left.id))
68
68
  .slice(0, limit);
69
69
  }
70
70
  async load(id) {
71
71
  assertSessionId(id);
72
72
  const directory = this.#sessionDirectory(id);
73
73
  await assertDirectory(directory);
74
- const meta = decodeMeta(await readJson(path.join(directory, "meta.json"), 64 * 1024));
74
+ const meta = decodeMeta(await readJson(path.join(directory, "meta.json"), SESSION_FILE_LIMITS.metadataBytes));
75
75
  if (meta.id !== id || meta.workspaceDigest !== this.workspaceDigest ||
76
76
  workspaceKey(meta.workspaceRoot) !== workspaceKey(this.workspaceRoot))
77
77
  throw new Error("session belongs to a different workspace");
78
- let head = decodeHead(await readJson(path.join(directory, "head.json"), 64 * 1024));
78
+ let head = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
79
79
  const stored = await readNodes(path.join(directory, "nodes"));
80
80
  const ahead = stored.filter((entry) => entry.sequence > head.sequence);
81
81
  if (ahead.some((entry) => entry.sequence !== head.sequence + 1) || ahead.length > 1) {
@@ -242,6 +242,28 @@ export class DurableSessionStore {
242
242
  return owner !== undefined && pidIsAlive(owner.pid);
243
243
  }
244
244
  }
245
+ async function catalogNames(directory) {
246
+ try {
247
+ await assertDirectory(directory);
248
+ }
249
+ catch (error) {
250
+ if (error.code === "ENOENT")
251
+ return [];
252
+ throw error;
253
+ }
254
+ const names = [];
255
+ let entries = 0;
256
+ const handle = await opendir(directory);
257
+ for await (const entry of handle) {
258
+ entries++;
259
+ if (entries > MAX_CATALOG_ENTRIES) {
260
+ throw new Error(`session catalogue exceeds ${MAX_CATALOG_ENTRIES} entries`);
261
+ }
262
+ if (entry.isDirectory() && SESSION_NAME.test(entry.name))
263
+ names.push(entry.name);
264
+ }
265
+ return names.sort((left, right) => right.localeCompare(left));
266
+ }
245
267
  function assertSharedNodes(previous, next, replacedId) {
246
268
  for (const node of previous.nodes) {
247
269
  if (node.id === replacedId)
@@ -278,7 +300,7 @@ async function readNodes(directory) {
278
300
  const id = Number(NODE_NAME.exec(name)?.[1]);
279
301
  if (id !== index + 1)
280
302
  throw new Error("session conversation nodes are not contiguous");
281
- const decoded = decodeNode(await readJson(path.join(directory, name), MAX_JSON_BYTES));
303
+ const decoded = decodeNode(await readJson(path.join(directory, name), SESSION_FILE_LIMITS.nodeBytes));
282
304
  if (decoded.node.id !== id || sequences.has(decoded.sequence)) {
283
305
  throw new Error("session conversation node identity is invalid");
284
306
  }
package/dist/timeline.js CHANGED
@@ -7,7 +7,7 @@ import { usageFromHistory } from "./usage.js";
7
7
  import { heading } from "./tui/picker.js";
8
8
  export function timelinePicker(conversation, palette) {
9
9
  const entries = timelineEntries(conversation);
10
- const selectedId = conversation.latestCompleted()?.activeNodeId ?? 0;
10
+ const selectedId = conversation.latestResumable()?.activeNodeId ?? 0;
11
11
  const index = Math.max(0, entries.findIndex((entry) => entry.node.id === selectedId));
12
12
  return Object.freeze({
13
13
  picker: {
@@ -18,7 +18,7 @@ export function timelinePicker(conversation, palette) {
18
18
  options: entries.map((entry) => ({
19
19
  label: `${entry.prefix}${preview(entry.node)}`,
20
20
  hint: stamp(entry.node.createdAt),
21
- ...(entry.node.id === selectedId ? { value: "active" } : {}),
21
+ ...timelineValue(entry.node, selectedId),
22
22
  })),
23
23
  index,
24
24
  },
@@ -38,12 +38,12 @@ export async function selectTimeline(session, choose) {
38
38
  return true;
39
39
  }
40
40
  function timelineEntries(conversation) {
41
- const completed = conversation.nodes.filter((node) => node.settlement === "completed");
42
- const completedIds = new Set(completed.map((node) => node.id));
41
+ const resumable = conversation.nodes.filter((node) => node.settlement !== "checkpointed");
42
+ const resumableIds = new Set(resumable.map((node) => node.id));
43
43
  const children = new Map();
44
- for (const node of completed) {
44
+ for (const node of resumable) {
45
45
  let parentId = node.parentId;
46
- while (parentId !== 0 && !completedIds.has(parentId)) {
46
+ while (parentId !== 0 && !resumableIds.has(parentId)) {
47
47
  parentId = conversation.node(parentId)?.parentId ?? 0;
48
48
  }
49
49
  const siblings = children.get(parentId) ?? [];
@@ -70,6 +70,12 @@ function timelineEntries(conversation) {
70
70
  visit(0, []);
71
71
  return entries;
72
72
  }
73
+ function timelineValue(node, selectedId) {
74
+ const state = node.settlement === "completed" ? undefined : node.settlement;
75
+ const active = node.id === selectedId ? "active" : undefined;
76
+ const value = [state, active].filter((part) => part !== undefined).join(" · ");
77
+ return value === "" ? {} : { value };
78
+ }
73
79
  function preview(node) {
74
80
  for (const message of node.messages) {
75
81
  if (message.role !== "user")
package/dist/tools/fs.js CHANGED
@@ -4,12 +4,13 @@ import * as fs from "node:fs/promises";
4
4
  import * as path from "node:path";
5
5
  import { optionalBool, optionalInt, optionalString, requireString } from "./args.js";
6
6
  import { assertDirectWritableInRoot, displayPath, resolveDirectWritableInRoot, resolveExistingInRoot, } from "./paths.js";
7
- import { assertEditableText, assertReplacementFits, MAX_EDITABLE_CHARS, MAX_EDITABLE_LINES, readEditableText, } from "./text-boundary.js";
7
+ import { assertEditableText, assertReplacementFits, leadingText, MAX_EDITABLE_CHARS, MAX_EDITABLE_LINES, readEditableText, } from "./text-boundary.js";
8
8
  import { atomicWrite } from "../atomic.js";
9
9
  const MAX_READ_CHARS = 60_000;
10
10
  const MAX_LIST_CHARS = 60_000;
11
11
  const MAX_LIST_ENTRIES = 2_000;
12
12
  const READ_CHUNK_BYTES = 64 * 1024;
13
+ const DEFAULT_MUTATION_DEPENDENCIES = { atomicWrite };
13
14
  export const readFile = {
14
15
  name: "read_file",
15
16
  description: "Read a regular UTF-8 text file inside the workspace. Optionally start at a line " +
@@ -111,24 +112,35 @@ export const writeFile = {
111
112
  assertEditableText(content);
112
113
  // A write against a file that is already there is a replacement, and the
113
114
  // user is owed the difference rather than a wall of green.
114
- return { before: await current(target), after: content };
115
+ const before = await current(target);
116
+ return { before: before.text, after: content, beforeExists: before.exists };
115
117
  },
116
118
  async run(args, ctx) {
117
- const root = await resolveExistingInRoot(ctx.root, ".");
118
- const target = await resolveDirectWritableInRoot(root, requireString(args, "path"));
119
- const content = requireString(args, "content", true);
120
- assertEditableText(content);
121
- await fs.mkdir(path.dirname(target), { recursive: true });
122
- const validate = () => assertDirectWritableInRoot(root, target);
123
- await validate();
124
- await unchangedSinceApproval(target, ctx.preview?.before);
125
- await atomicWrite(target, content, { validate });
126
- return {
127
- output: `wrote ${displayPath(root, target)} (${content.length} characters)`,
128
- summary: count(content, "line"),
129
- };
119
+ return runWriteFile(args, ctx);
130
120
  },
131
121
  };
122
+ export async function runWriteFile(args, ctx, dependencies = DEFAULT_MUTATION_DEPENDENCIES) {
123
+ const root = await resolveExistingInRoot(ctx.root, ".");
124
+ const target = await resolveDirectWritableInRoot(root, requireString(args, "path"));
125
+ const content = requireString(args, "content", true);
126
+ assertEditableText(content);
127
+ await fs.mkdir(path.dirname(target), { recursive: true });
128
+ await assertDirectWritableInRoot(root, target);
129
+ const before = await current(target);
130
+ assertApproved(before, ctx.preview, "write");
131
+ await dependencies.atomicWrite(target, content, {
132
+ async validate(phase) {
133
+ await assertDirectWritableInRoot(root, target);
134
+ if (phase === "before-rename") {
135
+ await assertUnchanged(target, before, "write", ctx.preview !== undefined);
136
+ }
137
+ },
138
+ });
139
+ return {
140
+ output: `wrote ${displayPath(root, target)} (${content.length} characters)`,
141
+ summary: count(content, "line"),
142
+ };
143
+ }
132
144
  export const editFile = {
133
145
  name: "edit_file",
134
146
  description: "Replace an exact string in a file. The old text must appear exactly once " +
@@ -150,32 +162,43 @@ export const editFile = {
150
162
  async preview(args, ctx) {
151
163
  const root = await resolveExistingInRoot(ctx.root, ".");
152
164
  const target = await resolveDirectWritableInRoot(root, requireString(args, "path"), true);
153
- const before = await current(target);
165
+ const before = await current(target, true);
154
166
  // An edit that will not apply gets no preview: the run is about to say so
155
167
  // properly, and a diff of a match that does not exist would be a lie.
156
168
  try {
157
- return { before, after: applied(before, args).after };
169
+ return {
170
+ before: before.text,
171
+ after: applied(before.text, args).after,
172
+ beforeExists: true,
173
+ };
158
174
  }
159
175
  catch {
160
176
  return undefined;
161
177
  }
162
178
  },
163
179
  async run(args, ctx) {
164
- const root = await resolveExistingInRoot(ctx.root, ".");
165
- const target = await resolveDirectWritableInRoot(root, requireString(args, "path"), true);
166
- const before = await readEditableText(target);
167
- if (ctx.preview !== undefined && before !== ctx.preview.before) {
168
- throw new Error("file changed after the preview — inspect it and retry the edit");
169
- }
170
- const { after, made } = applied(before, args);
171
- const validate = () => assertDirectWritableInRoot(root, target, true);
172
- await atomicWrite(target, after, { validate });
173
- return {
174
- output: `edited ${displayPath(root, target)} (${made} replacement${made === 1 ? "" : "s"})`,
175
- summary: plural(made, "replacement", "replacements"),
176
- };
180
+ return runEditFile(args, ctx);
177
181
  },
178
182
  };
183
+ export async function runEditFile(args, ctx, dependencies = DEFAULT_MUTATION_DEPENDENCIES) {
184
+ const root = await resolveExistingInRoot(ctx.root, ".");
185
+ const target = await resolveDirectWritableInRoot(root, requireString(args, "path"), true);
186
+ const before = await current(target, true);
187
+ assertApproved(before, ctx.preview, "edit");
188
+ const { after, made } = applied(before.text, args);
189
+ await dependencies.atomicWrite(target, after, {
190
+ async validate(phase) {
191
+ await assertDirectWritableInRoot(root, target, true);
192
+ if (phase === "before-rename") {
193
+ await assertUnchanged(target, before, "edit", ctx.preview !== undefined);
194
+ }
195
+ },
196
+ });
197
+ return {
198
+ output: `edited ${displayPath(root, target)} (${made} replacement${made === 1 ? "" : "s"})`,
199
+ summary: plural(made, "replacement", "replacements"),
200
+ };
201
+ }
179
202
  async function readRange(target, offset, limit, signal) {
180
203
  throwIfAborted(signal);
181
204
  const firstLine = Math.max(1, offset ?? 1);
@@ -203,8 +226,7 @@ async function readRange(target, offset, limit, signal) {
203
226
  return;
204
227
  const room = MAX_READ_CHARS - text.length;
205
228
  if (fragment.length > room) {
206
- if (room > 0)
207
- text += fragment.slice(0, room);
229
+ text += leadingText(fragment, room);
208
230
  truncated = true;
209
231
  stopped = true;
210
232
  return;
@@ -284,9 +306,17 @@ function applied(before, args) {
284
306
  assertEditableText(after, "edited content");
285
307
  return { after, made };
286
308
  }
287
- /** What is on disk now, or nothing at all — a file that is not there yet. */
288
- async function current(target) {
289
- return readEditableText(target, { missingAsEmpty: true });
309
+ /** What is on disk now, preserving the difference between absent and empty. */
310
+ async function current(target, mustExist = false) {
311
+ try {
312
+ return { exists: true, text: await readEditableText(target) };
313
+ }
314
+ catch (error) {
315
+ if (!mustExist && error.code === "ENOENT") {
316
+ return { exists: false, text: "" };
317
+ }
318
+ throw error;
319
+ }
290
320
  }
291
321
  function countOccurrences(haystack, needle) {
292
322
  let occurrences = 0;
@@ -295,12 +325,24 @@ function countOccurrences(haystack, needle) {
295
325
  }
296
326
  return occurrences;
297
327
  }
298
- async function unchangedSinceApproval(target, approved) {
328
+ function assertApproved(current, preview, operation) {
329
+ if (preview === undefined)
330
+ return;
331
+ const existenceChanged = preview.beforeExists !== undefined && current.exists !== preview.beforeExists;
332
+ if (existenceChanged || current.text !== preview.before) {
333
+ throw changedFile(operation, true);
334
+ }
335
+ }
336
+ async function assertUnchanged(target, expected, operation, previewed) {
299
337
  const onDisk = await current(target);
300
- if (approved !== undefined && onDisk !== approved) {
301
- throw new Error("file changed after the preview — inspect it and retry the write");
338
+ if (onDisk.exists !== expected.exists || onDisk.text !== expected.text) {
339
+ throw changedFile(operation, previewed);
302
340
  }
303
341
  }
342
+ function changedFile(operation, previewed) {
343
+ const when = previewed ? "after the preview" : "while preparing the change";
344
+ return new Error(`file changed ${when} — inspect it and retry the ${operation}`);
345
+ }
304
346
  function count(text, noun) {
305
347
  if (text === "")
306
348
  return "empty";