minimal-agent 0.18.8 → 0.18.10

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.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/src/loop.js +35 -5
  3. package/src/loop.ts +35 -5
  4. package/src/memory/factLearning.js +3 -3
  5. package/src/memory/factLearning.ts +3 -7
  6. package/src/memory/jobPolicy.js +21 -13
  7. package/src/memory/jobPolicy.ts +22 -11
  8. package/src/memory/learning.js +3 -3
  9. package/src/memory/learning.ts +3 -3
  10. package/src/memory/learningModel.js +4 -2
  11. package/src/memory/learningModel.ts +4 -2
  12. package/src/prompts/delegation.js +15 -0
  13. package/src/prompts/delegation.ts +20 -0
  14. package/src/prompts/policyRegistry.js +3 -2
  15. package/src/prompts/policyRegistry.ts +3 -2
  16. package/src/tools/agent/agent.js +2 -1
  17. package/src/tools/agent/agent.ts +2 -1
  18. package/src/tools/applypatch/applyPatch.js +1 -1
  19. package/src/tools/applypatch/applyPatch.ts +1 -1
  20. package/src/tools/edit/edit.js +11 -15
  21. package/src/tools/edit/edit.ts +10 -15
  22. package/src/tools/edit/multi-edit.js +14 -4
  23. package/src/tools/edit/multi-edit.ts +10 -4
  24. package/src/tools/memory/maintenance.js +2 -0
  25. package/src/tools/memory/maintenance.ts +2 -0
  26. package/src/tools/memory/memory.js +58 -14
  27. package/src/tools/memory/memory.ts +57 -13
  28. package/src/tools/memory/policy.js +16 -1
  29. package/src/tools/memory/policy.ts +16 -1
  30. package/src/tools/read/read.js +91 -47
  31. package/src/tools/read/read.ts +77 -46
  32. package/src/tools/shared/diffSummary.js +25 -3
  33. package/src/tools/shared/diffSummary.ts +23 -3
  34. package/src/tools/shared/editTransaction.js +45 -0
  35. package/src/tools/shared/editTransaction.ts +39 -0
  36. package/src/tools/shared/fileActivity.js +10 -2
  37. package/src/tools/shared/fileActivity.ts +11 -2
  38. package/src/tools/shared/fileState.js +2 -0
  39. package/src/tools/shared/fileState.ts +4 -0
  40. package/src/tools/shared/readBudget.js +12 -0
  41. package/src/tools/shared/readBudget.ts +12 -0
  42. package/src/tools/toolPromptGuides.js +6 -5
  43. package/src/tools/toolPromptGuides.ts +6 -5
  44. package/src/tools/types.js +2 -6
  45. package/src/tools/types.ts +2 -6
  46. package/src/types.ts +4 -2
@@ -38,10 +38,25 @@ export function requireCurrentEvidence(
38
38
  ? null
39
39
  : {
40
40
  ok: false,
41
- error: 'evidenceQuote 不是当前用户消息的逐字片段,拒绝写入推断性记忆',
41
+ error: 'evidenceQuote 不是当前用户消息的逐字片段,未写入。不要改写或伪造引用重试:用户事实可用 UpdateMemory(action=confirm) 请求确认,或 action=review 标记待复核;项目知识/经验使用 MemoryMaintenance,不把工具证据当用户原话。',
42
42
  };
43
43
  }
44
44
 
45
+ export async function confirmMemoryCorrection(subject: string, previous: string, proposed: string, reason: string, signal?: AbortSignal): Promise<boolean> {
46
+ if (!hasAskHandler()) return false;
47
+ signal?.throwIfAborted();
48
+ const [answer] = await requestUserChoice([{
49
+ header: '纠正记忆',
50
+ question: `是否更新“${subject}”?\n原记录:${previous}\n拟更新为:${proposed}\n纠正依据:${reason}`,
51
+ options: [
52
+ { label: '暂不更新', description: '保留原记录,不把建议视为事实。' },
53
+ { label: '确认更新', description: '确认拟更新内容准确,并保存为本机长期记忆。' },
54
+ ],
55
+ }], signal);
56
+ signal?.throwIfAborted();
57
+ return answer?.selected.length === 1 && answer.selected[0] === '确认更新';
58
+ }
59
+
45
60
  export async function confirmSensitiveMemory(
46
61
  statement: string,
47
62
  sensitivity: 'normal' | 'sensitive',
@@ -1,7 +1,6 @@
1
1
  import { createReadStream } from 'node:fs';
2
2
  import { open, readFile, stat, writeFile } from 'node:fs/promises';
3
3
  import { extname } from 'node:path';
4
- import { createInterface } from 'node:readline';
5
4
  import { runBoundedProcess } from '../../runtime/boundedProcess.js';
6
5
  import { mkdir } from 'node:fs/promises';
7
6
  import { join } from 'node:path';
@@ -18,6 +17,8 @@ import { pruneRuntimeOutputs, sessionRuntimeDir, } from '../../session/runtimeSt
18
17
  import { detectImageMime, resolveLocalImage, } from '../../images/imageResolver.js';
19
18
  const inputSchema = z.object({
20
19
  file_path: filePathField('读取'),
20
+ column: z.number().int().positive().optional().describe('超长行续读:起始行内的 1-based UTF-16 字符位置;普通阅读省略。'),
21
+ max_chars: z.number().int().min(1000).max(1_000_000).optional().describe('期望正文字符预算,最多 100 万;Runtime 会按剩余上下文与并发读取预算收紧。'),
21
22
  offset: z
22
23
  .number()
23
24
  .int()
@@ -56,7 +57,6 @@ const IMAGE_EXTENSIONS = new Set([
56
57
  ]);
57
58
  const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
58
59
  const MAX_NOTEBOOK_BYTES = 32 * 1024 * 1024;
59
- const SMALL_TEXT_BYTES = 16 * 1024 * 1024;
60
60
  const description = `Reads local text files, images, and Jupyter notebooks.
61
61
 
62
62
  Usage:
@@ -114,7 +114,7 @@ function renderNotebook(raw, offset, limit) {
114
114
  partial: start > 0 || end < parsed.cells.length,
115
115
  };
116
116
  }
117
- async function readStreamedText(filePath, offset, limit, signal) {
117
+ async function readStreamedText(filePath, offset, limit, signal, maxChars = DEFAULT_MAX_RESULT_SIZE_CHARS, column = 1) {
118
118
  const handle = await open(filePath, 'r');
119
119
  const prefix = Buffer.alloc(3);
120
120
  const { bytesRead } = await handle.read(prefix, 0, 3, 0);
@@ -132,31 +132,81 @@ async function readStreamedText(filePath, offset, limit, signal) {
132
132
  ? 3
133
133
  : 0,
134
134
  });
135
- const rl = createInterface({ input: stream, crlfDelay: Infinity });
136
135
  const lines = [];
137
- let line = 0;
136
+ let line = 1;
138
137
  let hasMore = false;
139
- for await (const value of rl) {
140
- line += 1;
141
- if (line >= offset && lines.length < limit)
142
- lines.push(`${line}\t${value}`);
143
- else if (line >= offset + limit) {
138
+ let chars = 0;
139
+ let clippedLine;
140
+ let pending = '';
141
+ let skip = column - 1;
142
+ let sawData = false;
143
+ const append = (part, end) => {
144
+ if (line < offset) {
145
+ if (end)
146
+ line++;
147
+ return true;
148
+ }
149
+ if (line === offset && skip) {
150
+ const consumed = Math.min(skip, part.length);
151
+ part = part.slice(consumed);
152
+ skip -= consumed;
153
+ }
154
+ if (lines.length >= limit) {
144
155
  hasMore = true;
145
- rl.close();
146
- stream.destroy();
147
- break;
156
+ return false;
157
+ }
158
+ const allowance = maxChars - chars - String(line).length - 1;
159
+ if (pending.length + part.length > allowance) {
160
+ if (!lines.length) {
161
+ const preview = (pending + part).slice(0, Math.max(0, allowance)).replace(/[\uD800-\uDBFF]$/, '');
162
+ lines.push(`${line}\t${preview}`);
163
+ clippedLine = line;
164
+ }
165
+ hasMore = true;
166
+ return false;
148
167
  }
168
+ pending += part;
169
+ if (end) {
170
+ const numbered = `${line}\t${pending.replace(/\r$/, '')}`;
171
+ lines.push(numbered);
172
+ chars += numbered.length + 1;
173
+ pending = '';
174
+ line++;
175
+ }
176
+ return true;
177
+ };
178
+ try {
179
+ outer: for await (const chunk of stream) {
180
+ sawData = true;
181
+ const text = String(chunk);
182
+ let start = 0;
183
+ for (let end = text.indexOf('\n'); end !== -1; end = text.indexOf('\n', start)) {
184
+ if (!append(text.slice(start, end), true))
185
+ break outer;
186
+ start = end + 1;
187
+ }
188
+ if (start < text.length && !append(text.slice(start), false))
189
+ break;
190
+ }
191
+ if (!hasMore && sawData)
192
+ append('', true);
193
+ }
194
+ finally {
195
+ stream.destroy();
149
196
  }
150
197
  return {
151
198
  numbered: lines.join('\n'),
152
- totalKnown: line,
199
+ totalKnown: hasMore ? line : sawData ? line - 1 : 0,
153
200
  selectedCount: lines.length,
154
201
  hasMore,
202
+ clippedLine,
155
203
  };
156
204
  }
157
- async function call(input, state, cwd, runtimeIdentity, signal) {
205
+ async function call(input, state, cwd, runtimeIdentity, signal, maxChars = DEFAULT_MAX_RESULT_SIZE_CHARS) {
158
206
  if (signal?.aborted)
159
207
  return { ok: false, error: 'aborted' };
208
+ if (maxChars < 1000)
209
+ return { ok: false, error: '本批读取上下文预算已用完;请先处理已有结果或压缩上下文,再继续读取。' };
160
210
  const offset = input.offset ?? 1;
161
211
  const limit = input.limit ?? MAX_LINES_TO_READ;
162
212
  const resolved = resolveToolPath(input.file_path, cwd);
@@ -351,45 +401,39 @@ async function call(input, state, cwd, runtimeIdentity, signal) {
351
401
  let hasMore;
352
402
  let completeBytes;
353
403
  let totalUnits;
354
- if (info.size <= SMALL_TEXT_BYTES) {
355
- const snapshot = await readTextFile(filePath);
356
- completeBytes = snapshot.bytes;
357
- const all = snapshot.content.length === 0 ? [] : snapshot.content.split('\n');
358
- const start = offset - 1;
359
- const selected = all.slice(start, start + limit);
360
- content = selected
361
- .map((line, index) => `${start + index + 1}\t${line}`)
362
- .join('\n');
363
- totalLines = all.length;
364
- selectedCount = selected.length;
365
- hasMore = start + selected.length < all.length;
366
- totalUnits = totalLines;
367
- }
368
- else {
369
- const streamed = await readStreamedText(filePath, offset, limit, signal);
370
- content = streamed.numbered;
371
- totalLines = streamed.totalKnown;
372
- selectedCount = streamed.selectedCount;
373
- hasMore = streamed.hasMore;
374
- // A streamed read only knows the complete total after consuming EOF.
375
- totalUnits = hasMore ? undefined : totalLines;
376
- }
404
+ let clippedLine;
405
+ const streamed = await readStreamedText(filePath, offset, limit, signal, maxChars, input.column);
406
+ content = streamed.numbered;
407
+ totalLines = streamed.totalKnown;
408
+ selectedCount = streamed.selectedCount;
409
+ hasMore = streamed.hasMore;
410
+ clippedLine = streamed.clippedLine;
411
+ totalUnits = hasMore ? undefined : totalLines;
412
+ if (!hasMore && offset === 1 && (input.column ?? 1) === 1)
413
+ completeBytes = await readFile(filePath, { signal });
414
+ const afterRead = await stat(filePath);
415
+ if (afterRead.mtimeMs !== info.mtimeMs || afterRead.size !== info.size || afterRead.ino !== info.ino)
416
+ return { ok: false, error: '读取期间文件发生变化,请重新读取;本次不登记读取覆盖范围。' };
377
417
  if (!content)
378
418
  content = totalLines === 0 ? '<file is empty>' : `<offset=${offset} 已超出文件末尾,共 ${totalLines} 行>`;
379
419
  let truncated = false;
380
- if (content.length > DEFAULT_MAX_RESULT_SIZE_CHARS) {
381
- content = `${content.slice(0, DEFAULT_MAX_RESULT_SIZE_CHARS)}\n\n⚠️ 重要:Read 输出已截断;截断范围外仍有内容。请用较小 limit 重新读取本区间,避免跳过截断行;精确修改用 Edit/ApplyPatch,Write 覆盖要求完整读取(可分段覆盖全部范围)。`;
420
+ if (content.length > maxChars || clippedLine !== undefined) {
421
+ const nextColumn = (input.column ?? 1) + Math.max(0, content.length - String(offset).length - 1);
422
+ content = `${content.slice(0, maxChars)}\n\n⚠️ 重要:Read 输出已截断;截断范围外仍有内容。超长行请提高 max_chars 或使用流式处理,不能跳过未显示内容。`;
423
+ if (clippedLine !== undefined)
424
+ content += `\n继续读取 offset=${clippedLine} column=${nextColumn}`;
382
425
  truncated = true;
383
426
  }
384
- const partial = offset !== 1 || hasMore;
427
+ const partial = offset !== 1 || hasMore || (input.column ?? 1) > 1;
385
428
  if (hasMore && !truncated)
386
- content += `\n\n... (还有更多内容;继续使用 offset=${offset + limit})`;
429
+ content += `\n\n... (还有更多内容;继续使用 offset=${offset + selectedCount})`;
387
430
  const reads = recordRead(filePath, {
388
431
  offset,
432
+ column: input.column,
389
433
  limit,
390
434
  partial,
391
435
  truncated,
392
- range: selectedCount > 0
436
+ range: selectedCount > 0 && (input.column ?? 1) === 1
393
437
  ? {
394
438
  start: offset,
395
439
  end: offset + selectedCount - 1,
@@ -412,7 +456,7 @@ async function call(input, state, cwd, runtimeIdentity, signal) {
412
456
  path: filePath,
413
457
  unit: 'line',
414
458
  // A truncated response does not establish coverage of its full range.
415
- ...(!truncated && selectedCount > 0
459
+ ...(!truncated && selectedCount > 0 && (input.column ?? 1) === 1
416
460
  ? { start: offset, end: offset + selectedCount - 1 }
417
461
  : {}),
418
462
  truncated,
@@ -544,8 +588,8 @@ export const readTool = {
544
588
  parameters: toToolParameters(inputSchema),
545
589
  isReadOnly: true,
546
590
  isConcurrencySafe: true,
547
- maxResultSizeChars: DEFAULT_MAX_RESULT_SIZE_CHARS,
591
+ maxResultSizeChars: 1_010_000,
548
592
  interruptBehavior: () => 'cancel',
549
- call: (input, signal) => call(input, undefined, undefined, undefined, signal),
550
- callWithContext: (input, signal, context) => call(input, context.sessionState?.fileReads, context.cwd, context, signal),
593
+ call: (input, signal) => call(input, undefined, undefined, undefined, signal, input.max_chars ?? DEFAULT_MAX_RESULT_SIZE_CHARS),
594
+ callWithContext: (input, signal, context) => call(input, context.sessionState?.fileReads, context.cwd, context, signal, context.reserveReadChars?.(input.max_chars) ?? input.max_chars ?? DEFAULT_MAX_RESULT_SIZE_CHARS),
551
595
  };
@@ -1,7 +1,6 @@
1
1
  import { createReadStream } from 'node:fs';
2
2
  import { open, readFile, stat, writeFile } from 'node:fs/promises';
3
3
  import { extname } from 'node:path';
4
- import { createInterface } from 'node:readline';
5
4
  import { runBoundedProcess } from '../../runtime/boundedProcess.js';
6
5
  import { mkdir } from 'node:fs/promises';
7
6
  import { join } from 'node:path';
@@ -41,6 +40,8 @@ import {
41
40
 
42
41
  const inputSchema = z.object({
43
42
  file_path: filePathField('读取'),
43
+ column: z.number().int().positive().optional().describe('超长行续读:起始行内的 1-based UTF-16 字符位置;普通阅读省略。'),
44
+ max_chars: z.number().int().min(1000).max(1_000_000).optional().describe('期望正文字符预算,最多 100 万;Runtime 会按剩余上下文与并发读取预算收紧。'),
44
45
  offset: z
45
46
  .number()
46
47
  .int()
@@ -83,7 +84,6 @@ const IMAGE_EXTENSIONS = new Set([
83
84
  ]);
84
85
  const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
85
86
  const MAX_NOTEBOOK_BYTES = 32 * 1024 * 1024;
86
- const SMALL_TEXT_BYTES = 16 * 1024 * 1024;
87
87
 
88
88
  const description = `Reads local text files, images, and Jupyter notebooks.
89
89
 
@@ -164,11 +164,14 @@ async function readStreamedText(
164
164
  offset: number,
165
165
  limit: number,
166
166
  signal?: AbortSignal,
167
+ maxChars = DEFAULT_MAX_RESULT_SIZE_CHARS,
168
+ column = 1,
167
169
  ): Promise<{
168
170
  numbered: string;
169
171
  totalKnown: number;
170
172
  selectedCount: number;
171
173
  hasMore: boolean;
174
+ clippedLine?: number;
172
175
  }> {
173
176
  const handle = await open(filePath, 'r');
174
177
  const prefix = Buffer.alloc(3);
@@ -187,25 +190,57 @@ async function readStreamedText(
187
190
  ? 3
188
191
  : 0,
189
192
  });
190
- const rl = createInterface({ input: stream, crlfDelay: Infinity });
191
193
  const lines: string[] = [];
192
- let line = 0;
194
+ let line = 1;
193
195
  let hasMore = false;
194
- for await (const value of rl) {
195
- line += 1;
196
- if (line >= offset && lines.length < limit) lines.push(`${line}\t${value}`);
197
- else if (line >= offset + limit) {
198
- hasMore = true;
199
- rl.close();
200
- stream.destroy();
201
- break;
196
+ let chars = 0;
197
+ let clippedLine: number | undefined;
198
+ let pending = '';
199
+ let skip = column - 1;
200
+ let sawData = false;
201
+ const append = (part: string, end: boolean): boolean => {
202
+ if (line < offset) { if (end) line++; return true; }
203
+ if (line === offset && skip) {
204
+ const consumed = Math.min(skip, part.length);
205
+ part = part.slice(consumed); skip -= consumed;
202
206
  }
203
- }
207
+ if (lines.length >= limit) { hasMore = true; return false; }
208
+ const allowance = maxChars - chars - String(line).length - 1;
209
+ if (pending.length + part.length > allowance) {
210
+ if (!lines.length) {
211
+ const preview = (pending + part).slice(0, Math.max(0, allowance)).replace(/[\uD800-\uDBFF]$/, '');
212
+ lines.push(`${line}\t${preview}`);
213
+ clippedLine = line;
214
+ }
215
+ hasMore = true; return false;
216
+ }
217
+ pending += part;
218
+ if (end) {
219
+ const numbered = `${line}\t${pending.replace(/\r$/, '')}`;
220
+ lines.push(numbered); chars += numbered.length + 1;
221
+ pending = ''; line++;
222
+ }
223
+ return true;
224
+ };
225
+ try {
226
+ outer: for await (const chunk of stream) {
227
+ sawData = true;
228
+ const text = String(chunk);
229
+ let start = 0;
230
+ for (let end = text.indexOf('\n'); end !== -1; end = text.indexOf('\n', start)) {
231
+ if (!append(text.slice(start, end), true)) break outer;
232
+ start = end + 1;
233
+ }
234
+ if (start < text.length && !append(text.slice(start), false)) break;
235
+ }
236
+ if (!hasMore && sawData) append('', true);
237
+ } finally { stream.destroy(); }
204
238
  return {
205
239
  numbered: lines.join('\n'),
206
- totalKnown: line,
240
+ totalKnown: hasMore ? line : sawData ? line - 1 : 0,
207
241
  selectedCount: lines.length,
208
242
  hasMore,
243
+ clippedLine,
209
244
  };
210
245
  }
211
246
 
@@ -215,8 +250,10 @@ async function call(
215
250
  cwd?: string,
216
251
  runtimeIdentity?: RuntimeIdentity,
217
252
  signal?: AbortSignal,
253
+ maxChars = DEFAULT_MAX_RESULT_SIZE_CHARS,
218
254
  ): Promise<ToolResult> {
219
255
  if (signal?.aborted) return { ok: false, error: 'aborted' };
256
+ if (maxChars < 1000) return { ok: false, error: '本批读取上下文预算已用完;请先处理已有结果或压缩上下文,再继续读取。' };
220
257
  const offset = input.offset ?? 1;
221
258
  const limit = input.limit ?? MAX_LINES_TO_READ;
222
259
  const resolved = resolveToolPath(input.file_path, cwd);
@@ -454,47 +491,40 @@ async function call(
454
491
  let hasMore: boolean;
455
492
  let completeBytes: Buffer | undefined;
456
493
  let totalUnits: number | undefined;
457
- if (info.size <= SMALL_TEXT_BYTES) {
458
- const snapshot = await readTextFile(filePath);
459
- completeBytes = snapshot.bytes;
460
- const all =
461
- snapshot.content.length === 0 ? [] : snapshot.content.split('\n');
462
- const start = offset - 1;
463
- const selected = all.slice(start, start + limit);
464
- content = selected
465
- .map((line, index) => `${start + index + 1}\t${line}`)
466
- .join('\n');
467
- totalLines = all.length;
468
- selectedCount = selected.length;
469
- hasMore = start + selected.length < all.length;
470
- totalUnits = totalLines;
471
- } else {
472
- const streamed = await readStreamedText(filePath, offset, limit, signal);
473
- content = streamed.numbered;
474
- totalLines = streamed.totalKnown;
475
- selectedCount = streamed.selectedCount;
476
- hasMore = streamed.hasMore;
477
- // A streamed read only knows the complete total after consuming EOF.
478
- totalUnits = hasMore ? undefined : totalLines;
479
- }
494
+ let clippedLine: number | undefined;
495
+ const streamed = await readStreamedText(filePath, offset, limit, signal, maxChars, input.column);
496
+ content = streamed.numbered;
497
+ totalLines = streamed.totalKnown;
498
+ selectedCount = streamed.selectedCount;
499
+ hasMore = streamed.hasMore;
500
+ clippedLine = streamed.clippedLine;
501
+ totalUnits = hasMore ? undefined : totalLines;
502
+ if (!hasMore && offset === 1 && (input.column ?? 1) === 1)
503
+ completeBytes = await readFile(filePath, { signal });
504
+ const afterRead = await stat(filePath);
505
+ if (afterRead.mtimeMs !== info.mtimeMs || afterRead.size !== info.size || afterRead.ino !== info.ino)
506
+ return { ok: false, error: '读取期间文件发生变化,请重新读取;本次不登记读取覆盖范围。' };
480
507
  if (!content) content = totalLines === 0 ? '<file is empty>' : `<offset=${offset} 已超出文件末尾,共 ${totalLines} 行>`;
481
508
  let truncated = false;
482
- if (content.length > DEFAULT_MAX_RESULT_SIZE_CHARS) {
483
- content = `${content.slice(0, DEFAULT_MAX_RESULT_SIZE_CHARS)}\n\n⚠️ 重要:Read 输出已截断;截断范围外仍有内容。请用较小 limit 重新读取本区间,避免跳过截断行;精确修改用 Edit/ApplyPatch,Write 覆盖要求完整读取(可分段覆盖全部范围)。`;
509
+ if (content.length > maxChars || clippedLine !== undefined) {
510
+ const nextColumn = (input.column ?? 1) + Math.max(0, content.length - String(offset).length - 1);
511
+ content = `${content.slice(0, maxChars)}\n\n⚠️ 重要:Read 输出已截断;截断范围外仍有内容。超长行请提高 max_chars 或使用流式处理,不能跳过未显示内容。`;
512
+ if (clippedLine !== undefined) content += `\n继续读取 offset=${clippedLine} column=${nextColumn}`;
484
513
  truncated = true;
485
514
  }
486
- const partial = offset !== 1 || hasMore;
515
+ const partial = offset !== 1 || hasMore || (input.column ?? 1) > 1;
487
516
  if (hasMore && !truncated)
488
- content += `\n\n... (还有更多内容;继续使用 offset=${offset + limit})`;
517
+ content += `\n\n... (还有更多内容;继续使用 offset=${offset + selectedCount})`;
489
518
  const reads = recordRead(
490
519
  filePath,
491
520
  {
492
521
  offset,
522
+ column: input.column,
493
523
  limit,
494
524
  partial,
495
525
  truncated,
496
526
  range:
497
- selectedCount > 0
527
+ selectedCount > 0 && (input.column ?? 1) === 1
498
528
  ? {
499
529
  start: offset,
500
530
  end: offset + selectedCount - 1,
@@ -519,7 +549,7 @@ async function call(
519
549
  path: filePath,
520
550
  unit: 'line',
521
551
  // A truncated response does not establish coverage of its full range.
522
- ...(!truncated && selectedCount > 0
552
+ ...(!truncated && selectedCount > 0 && (input.column ?? 1) === 1
523
553
  ? { start: offset, end: offset + selectedCount - 1 }
524
554
  : {}),
525
555
  truncated,
@@ -687,9 +717,10 @@ export const readTool: Tool<ReadInput> = {
687
717
  parameters: toToolParameters(inputSchema),
688
718
  isReadOnly: true,
689
719
  isConcurrencySafe: true,
690
- maxResultSizeChars: DEFAULT_MAX_RESULT_SIZE_CHARS,
720
+ maxResultSizeChars: 1_010_000,
691
721
  interruptBehavior: () => 'cancel',
692
- call: (input, signal) => call(input, undefined, undefined, undefined, signal),
722
+ call: (input, signal) => call(input, undefined, undefined, undefined, signal, input.max_chars ?? DEFAULT_MAX_RESULT_SIZE_CHARS),
693
723
  callWithContext: (input, signal, context: ToolCallContext) =>
694
- call(input, context.sessionState?.fileReads, context.cwd, context, signal),
724
+ call(input, context.sessionState?.fileReads, context.cwd, context, signal,
725
+ context.reserveReadChars?.(input.max_chars) ?? input.max_chars ?? DEFAULT_MAX_RESULT_SIZE_CHARS),
695
726
  };
@@ -12,16 +12,38 @@ export function buildPatchSummary(before, after, label) {
12
12
  const totalBytes = Buffer.byteLength(before) + Buffer.byteLength(after);
13
13
  const totalLines = (before.match(/\n/g)?.length ?? 0) + (after.match(/\n/g)?.length ?? 0) + 2;
14
14
  if (totalBytes > MAX_BYTES || totalLines > MAX_LINES) {
15
+ // Strip identical outer lines before expensive diff work. A large file with
16
+ // a small edit still gets a real preview with original line coordinates.
17
+ let prefix = 0;
18
+ while (prefix < Math.min(before.length, after.length) && before[prefix] === after[prefix])
19
+ prefix++;
20
+ const start = before.lastIndexOf('\n', Math.max(0, prefix - 1)) + 1;
21
+ let suffix = 0;
22
+ while (suffix < Math.min(before.length, after.length) - start &&
23
+ before[before.length - suffix - 1] === after[after.length - suffix - 1])
24
+ suffix++;
25
+ const tail = before.indexOf('\n', before.length - suffix);
26
+ const suffixLength = tail < 0 ? 0 : before.length - tail - 1;
27
+ const oldWindow = before.slice(start, before.length - suffixLength);
28
+ const newWindow = after.slice(start, after.length - suffixLength);
29
+ if (Buffer.byteLength(oldWindow) + Buffer.byteLength(newWindow) <= MAX_BYTES &&
30
+ (oldWindow.match(/\n/g)?.length ?? 0) + (newWindow.match(/\n/g)?.length ?? 0) + 2 <= MAX_LINES) {
31
+ const local = buildPatchSummary(oldWindow, newWindow, label);
32
+ const shift = (before.slice(0, start).match(/\n/g)?.length ?? 0);
33
+ return { ...local, beforeHash, afterHash,
34
+ unifiedDiff: local.unifiedDiff?.replace(/@@ -(\d+)(,\d+)? \+(\d+)(,\d+)? @@/g, (_, a, ac = '', b, bc = '') => `@@ -${Number(a) + shift}${ac} +${Number(b) + shift}${bc} @@`) };
35
+ }
15
36
  return {
16
37
  beforeHash,
17
38
  afterHash,
18
- additions: 0,
19
- deletions: 0,
20
39
  truncated: true,
21
40
  omittedReason: 'too_large',
22
41
  };
23
42
  }
24
- const changes = diffLines(before, after);
43
+ const changes = diffLines(before, after, { timeout: 500, maxEditLength: 10_000 });
44
+ if (!changes)
45
+ return { beforeHash, afterHash,
46
+ truncated: true, omittedReason: 'too_large' };
25
47
  const additions = changes
26
48
  .filter((change) => change.added)
27
49
  .reduce((sum, change) => sum + (change.count ?? 0), 0);
@@ -22,17 +22,37 @@ export function buildPatchSummary(
22
22
  const totalLines =
23
23
  (before.match(/\n/g)?.length ?? 0) + (after.match(/\n/g)?.length ?? 0) + 2;
24
24
  if (totalBytes > MAX_BYTES || totalLines > MAX_LINES) {
25
+ // Strip identical outer lines before expensive diff work. A large file with
26
+ // a small edit still gets a real preview with original line coordinates.
27
+ let prefix = 0;
28
+ while (prefix < Math.min(before.length, after.length) && before[prefix] === after[prefix]) prefix++;
29
+ const start = before.lastIndexOf('\n', Math.max(0, prefix - 1)) + 1;
30
+ let suffix = 0;
31
+ while (suffix < Math.min(before.length, after.length) - start &&
32
+ before[before.length - suffix - 1] === after[after.length - suffix - 1]) suffix++;
33
+ const tail = before.indexOf('\n', before.length - suffix);
34
+ const suffixLength = tail < 0 ? 0 : before.length - tail - 1;
35
+ const oldWindow = before.slice(start, before.length - suffixLength);
36
+ const newWindow = after.slice(start, after.length - suffixLength);
37
+ if (Buffer.byteLength(oldWindow) + Buffer.byteLength(newWindow) <= MAX_BYTES &&
38
+ (oldWindow.match(/\n/g)?.length ?? 0) + (newWindow.match(/\n/g)?.length ?? 0) + 2 <= MAX_LINES) {
39
+ const local = buildPatchSummary(oldWindow, newWindow, label);
40
+ const shift = (before.slice(0, start).match(/\n/g)?.length ?? 0);
41
+ return { ...local, beforeHash, afterHash,
42
+ unifiedDiff: local.unifiedDiff?.replace(/@@ -(\d+)(,\d+)? \+(\d+)(,\d+)? @@/g,
43
+ (_, a, ac = '', b, bc = '') => `@@ -${Number(a) + shift}${ac} +${Number(b) + shift}${bc} @@`) };
44
+ }
25
45
  return {
26
46
  beforeHash,
27
47
  afterHash,
28
- additions: 0,
29
- deletions: 0,
30
48
  truncated: true,
31
49
  omittedReason: 'too_large',
32
50
  };
33
51
  }
34
52
 
35
- const changes = diffLines(before, after);
53
+ const changes = diffLines(before, after, { timeout: 500, maxEditLength: 10_000 });
54
+ if (!changes) return { beforeHash, afterHash,
55
+ truncated: true, omittedReason: 'too_large' };
36
56
  const additions = changes
37
57
  .filter((change) => change.added)
38
58
  .reduce((sum, change) => sum + (change.count ?? 0), 0);
@@ -0,0 +1,45 @@
1
+ import { readFile, stat, realpath } from 'node:fs/promises';
2
+ import { atomicWriteFile, encodeTextFile, readTextFile } from './fileUtils.js';
3
+ import { hashFileBytes } from './fileState.js';
4
+ // Bound the in-memory replacement engine independently of model context size.
5
+ export const MAX_EDIT_BYTES = 128 * 1024 * 1024;
6
+ const commits = new Map();
7
+ export async function readEditSnapshot(path) {
8
+ if ((await stat(path)).size > MAX_EDIT_BYTES)
9
+ throw new Error('文件超过 128 MiB 内存编辑边界;请使用流式转换写入临时文件并校验,不要用 Write 全文覆盖。');
10
+ const snapshot = await readTextFile(path);
11
+ if (snapshot.bytes.length > MAX_EDIT_BYTES)
12
+ throw new Error('读取期间文件超过编辑边界');
13
+ return snapshot;
14
+ }
15
+ export async function commitEdit(path, snapshot, content) {
16
+ const key = await realpath(path);
17
+ const previous = commits.get(key) ?? Promise.resolve();
18
+ let release;
19
+ const current = new Promise(resolve => { release = resolve; });
20
+ commits.set(key, current);
21
+ await previous;
22
+ try {
23
+ return await commitSnapshot(key, snapshot, content);
24
+ }
25
+ finally {
26
+ release();
27
+ if (commits.get(key) === current)
28
+ commits.delete(key);
29
+ }
30
+ }
31
+ async function commitSnapshot(path, snapshot, content) {
32
+ const bytes = encodeTextFile(content, snapshot.encoding);
33
+ if (bytes.length > MAX_EDIT_BYTES)
34
+ throw new Error('编辑结果超过 128 MiB 内存编辑边界');
35
+ const beforeHash = hashFileBytes(snapshot.bytes);
36
+ if (hashFileBytes(await readFile(path)) !== beforeHash)
37
+ throw new Error('编辑期间文件已被其他操作修改,未提交;请重新读取后再编辑。');
38
+ // Optimistic conflict check, not an OS-wide compare-and-swap guarantee.
39
+ if (!bytes.equals(snapshot.bytes))
40
+ await atomicWriteFile(path, bytes);
41
+ const afterHash = hashFileBytes(bytes);
42
+ if (hashFileBytes(await readFile(path)) !== afterHash)
43
+ throw new Error('提交后文件内容与预期不一致(可能被外部进程改写);请读取核验,不要盲目重复替换。');
44
+ return { beforeHash, afterHash };
45
+ }
@@ -0,0 +1,39 @@
1
+ import { readFile, stat, realpath } from 'node:fs/promises';
2
+ import { atomicWriteFile, encodeTextFile, readTextFile } from './fileUtils.js';
3
+ import { hashFileBytes } from './fileState.js';
4
+
5
+ // Bound the in-memory replacement engine independently of model context size.
6
+ export const MAX_EDIT_BYTES = 128 * 1024 * 1024;
7
+ const commits = new Map<string, Promise<void>>();
8
+ export async function readEditSnapshot(path: string) {
9
+ if ((await stat(path)).size > MAX_EDIT_BYTES)
10
+ throw new Error('文件超过 128 MiB 内存编辑边界;请使用流式转换写入临时文件并校验,不要用 Write 全文覆盖。');
11
+ const snapshot = await readTextFile(path);
12
+ if (snapshot.bytes.length > MAX_EDIT_BYTES) throw new Error('读取期间文件超过编辑边界');
13
+ return snapshot;
14
+ }
15
+
16
+ export async function commitEdit(path: string, snapshot: Awaited<ReturnType<typeof readEditSnapshot>>, content: string) {
17
+ const key = await realpath(path);
18
+ const previous = commits.get(key) ?? Promise.resolve();
19
+ let release!: () => void;
20
+ const current = new Promise<void>(resolve => { release = resolve; });
21
+ commits.set(key, current);
22
+ await previous;
23
+ try { return await commitSnapshot(key, snapshot, content); }
24
+ finally { release(); if (commits.get(key) === current) commits.delete(key); }
25
+ }
26
+
27
+ async function commitSnapshot(path: string, snapshot: Awaited<ReturnType<typeof readEditSnapshot>>, content: string) {
28
+ const bytes = encodeTextFile(content, snapshot.encoding);
29
+ if (bytes.length > MAX_EDIT_BYTES) throw new Error('编辑结果超过 128 MiB 内存编辑边界');
30
+ const beforeHash = hashFileBytes(snapshot.bytes);
31
+ if (hashFileBytes(await readFile(path)) !== beforeHash)
32
+ throw new Error('编辑期间文件已被其他操作修改,未提交;请重新读取后再编辑。');
33
+ // Optimistic conflict check, not an OS-wide compare-and-swap guarantee.
34
+ if (!bytes.equals(snapshot.bytes)) await atomicWriteFile(path, bytes);
35
+ const afterHash = hashFileBytes(bytes);
36
+ if (hashFileBytes(await readFile(path)) !== afterHash)
37
+ throw new Error('提交后文件内容与预期不一致(可能被外部进程改写);请读取核验,不要盲目重复替换。');
38
+ return { beforeHash, afterHash };
39
+ }
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { readFile, realpath } from 'node:fs/promises';
2
+ import { realpath, stat } from 'node:fs/promises';
3
+ import { createReadStream } from 'node:fs';
3
4
  import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
4
5
  import { getWorkingDir } from '../../bootstrap/workingDir.js';
5
6
  const MIME_BY_EXT = {
@@ -32,7 +33,14 @@ export async function fileActivityMetadata(filePath, activity, verification = 'n
32
33
  let sha256;
33
34
  if (activity !== 'deleted') {
34
35
  try {
35
- sha256 = createHash('sha256').update(await readFile(candidate)).digest('hex');
36
+ // A range Read must not secretly load/hash a gigabyte for UI metadata.
37
+ // Hash is optional for sources; committed artifacts use bounded buffers.
38
+ if (activity !== 'read' || (await stat(candidate)).size <= 8 * 1024 * 1024) {
39
+ const hash = createHash('sha256');
40
+ for await (const chunk of createReadStream(candidate))
41
+ hash.update(chunk);
42
+ sha256 = hash.digest('hex');
43
+ }
36
44
  }
37
45
  catch { /* The file activity remains useful even when hashing fails. */ }
38
46
  }